Unified · Split
| @@ -79,10 +79,15 @@ · 78 unmodified lines | |||
| 79 | "postgres": "^3.4.7", | 79 | "postgres": "^3.4.7", |
| 80 | }, | 80 | }, |
| 81 | "devDependencies": { | 81 | "devDependencies": { |
| 82 | "@effect/platform-node": "^0.104.1", | ||
| 82 | "@effect/vitest": "catalog:", | 83 | "@effect/vitest": "catalog:", |
| 84 | "@executor-v3/codemode-mcp": "workspace:*", | ||
| 85 | "@executor-v3/runtime-local-inproc": "workspace:*", | ||
| 86 | "@modelcontextprotocol/sdk": "^1.26.0", | ||
| 83 | "@types/node": "catalog:", | 87 | "@types/node": "catalog:", |
| 84 | "bun-types": "catalog:", | 88 | "bun-types": "catalog:", |
| 85 | "vitest": "catalog:", | 89 | "vitest": "catalog:", |
| 90 | "zod": "catalog:", | ||
| 86 | }, | 91 | }, |
| 87 | }, | 92 | }, |
| 88 | "packages/kitchen-sink": { | 93 | "packages/kitchen-sink": { |
| @@ -51,10 +51,10 @@ export type ToolInteractionDecision = · 50 unmodified lines | |||
| 51 | | { kind: "execute" } | 51 | | { kind: "execute" } |
| 52 | | { kind: "decline"; reason: string } | 52 | | { kind: "decline"; reason: string } |
| 53 | | { | 53 | | { |
| 54 | kind: "elicit"; | 54 | kind: "elicit"; |
| 55 | elicitation: ElicitationRequest; | 55 | elicitation: ElicitationRequest; |
| 56 | interactionId?: string; | 56 | interactionId?: string; |
| 57 | }; | 57 | }; |
| 58 | 58 | ||
| 59 | export type ToolElicitationRequest = { | 59 | export type ToolElicitationRequest = { |
| 60 | interactionId: string; | 60 | interactionId: string; |
| @@ -30,9 +30,12 @@ const createEndpoint = ( · 29 unmodified lines | |||
| 30 | return url; | 30 | return url; |
| 31 | }; | 31 | }; |
| 32 | 32 | ||
| 33 | type FetchInput = Parameters<typeof fetch>[0]; | ||
| 34 | type FetchInit = Parameters<typeof fetch>[1]; | ||
| 35 | |||
| 33 | const mergeHeadersForFetch = ( | 36 | const mergeHeadersForFetch = ( |
| 34 | input: RequestInfo | URL, | 37 | input: FetchInput, |
| 35 | init: RequestInit | undefined, | 38 | init: FetchInit, |
| 36 | headers: Record<string, string>, | 39 | headers: Record<string, string>, |
| 37 | ): Promise<Response> => { | 40 | ): Promise<Response> => { |
| 38 | const mergedHeaders = new Headers(init?.headers ?? {}); | 41 | const mergedHeaders = new Headers(init?.headers ?? {}); |
| @@ -84,7 +87,7 @@ export const createSdkMcpConnector = ( · 45 unmodified lines | |||
| 84 | requestInit, | 87 | requestInit, |
| 85 | eventSourceInit: requestInit | 88 | eventSourceInit: requestInit |
| 86 | ? { | 89 | ? { |
| 87 | fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) => | 90 | fetch: (requestInput: FetchInput, requestOptions: FetchInit) => |
| 88 | mergeHeadersForFetch(requestInput, requestOptions, headers), | 91 | mergeHeadersForFetch(requestInput, requestOptions, headers), |
| 89 | } | 92 | } |
| 90 | : undefined, | 93 | : undefined, |
| @@ -100,7 +103,7 @@ export const createSdkMcpConnector = ( · 9 unmodified lines | |||
| 100 | requestInit, | 103 | requestInit, |
| 101 | eventSourceInit: requestInit | 104 | eventSourceInit: requestInit |
| 102 | ? { | 105 | ? { |
| 103 | fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) => | 106 | fetch: (requestInput: FetchInput, requestOptions: FetchInit) => |
| 104 | mergeHeadersForFetch(requestInput, requestOptions, headers), | 107 | mergeHeadersForFetch(requestInput, requestOptions, headers), |
| 105 | } | 108 | } |
| 106 | : undefined, | 109 | : undefined, |
| @@ -28,7 +28,12 @@ · 27 unmodified lines | |||
| 28 | "devDependencies": { | 28 | "devDependencies": { |
| 29 | "@effect/vitest": "catalog:", | 29 | "@effect/vitest": "catalog:", |
| 30 | "@types/node": "catalog:", | 30 | "@types/node": "catalog:", |
| 31 | "@effect/platform-node": "^0.104.1", | ||
| 32 | "@executor-v3/runtime-local-inproc": "workspace:*", | ||
| 33 | "@modelcontextprotocol/sdk": "^1.26.0", | ||
| 31 | "bun-types": "catalog:", | 34 | "bun-types": "catalog:", |
| 32 | "vitest": "catalog:" | 35 | "vitest": "catalog:", |
| 36 | "zod": "catalog:", | ||
| 37 | "@executor-v3/codemode-mcp": "workspace:*" | ||
| 33 | } | 38 | } |
| 34 | } | 39 | } |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { HttpApi, OpenApi } from "@effect/platform"; | 1 | import { HttpApi, OpenApi } from "@effect/platform"; |
| 2 | 2 | ||
| 3 | import { ExecutionsApi } from "./executions/api"; | ||
| 3 | import { MembershipsApi } from "./memberships/api"; | 4 | import { MembershipsApi } from "./memberships/api"; |
| 4 | import { OrganizationsApi } from "./organizations/api"; | 5 | import { OrganizationsApi } from "./organizations/api"; |
| 5 | import { PoliciesApi } from "./policies/api"; | 6 | import { PoliciesApi } from "./policies/api"; |
| @@ -14,6 +15,13 @@ export { · 8 unmodified lines | |||
| 14 | ControlPlaneUnauthorizedError, | 15 | ControlPlaneUnauthorizedError, |
| 15 | } from "./errors"; | 16 | } from "./errors"; |
| 16 | 17 | ||
| 18 | export { | ||
| 19 | CreateExecutionPayloadSchema, | ||
| 20 | ResumeExecutionPayloadSchema, | ||
| 21 | type CreateExecutionPayload, | ||
| 22 | type ResumeExecutionPayload, | ||
| 23 | } from "./executions/api"; | ||
| 24 | |||
| 17 | export { | 25 | export { |
| 18 | CreateOrganizationPayloadSchema, | 26 | CreateOrganizationPayloadSchema, |
| 19 | UpdateOrganizationPayloadSchema, | 27 | UpdateOrganizationPayloadSchema, |
| @@ -55,6 +63,7 @@ export class ControlPlaneApi extends HttpApi.make("controlPlane") · 35 unmodified lines | |||
| 55 | .add(WorkspacesApi) | 63 | .add(WorkspacesApi) |
| 56 | .add(SourcesApi) | 64 | .add(SourcesApi) |
| 57 | .add(PoliciesApi) | 65 | .add(PoliciesApi) |
| 66 | .add(ExecutionsApi) | ||
| 58 | .annotateContext( | 67 | .annotateContext( |
| 59 | OpenApi.annotations({ | 68 | OpenApi.annotations({ |
| 60 | title: "Executor v3 Control Plane API", | 69 | title: "Executor v3 Control Plane API", |
| @@ -0,0 +1,62 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | ExecutionIdSchema, | ||
| 4 | ExecutionSchema, | ||
| 5 | WorkspaceIdSchema, | ||
| 6 | } from "#schema"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | ControlPlaneBadRequestError, | ||
| 11 | ControlPlaneForbiddenError, | ||
| 12 | ControlPlaneNotFoundError, | ||
| 13 | ControlPlaneStorageError, | ||
| 14 | ControlPlaneUnauthorizedError, | ||
| 15 | } from "../errors"; | ||
| 16 | |||
| 17 | export const CreateExecutionPayloadSchema = Schema.Struct({ | ||
| 18 | code: Schema.String, | ||
| 19 | }); | ||
| 20 | |||
| 21 | export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type; | ||
| 22 | |||
| 23 | export const ResumeExecutionPayloadSchema = Schema.Struct({ | ||
| 24 | responseJson: Schema.optional(Schema.String), | ||
| 25 | }); | ||
| 26 | |||
| 27 | export type ResumeExecutionPayload = typeof ResumeExecutionPayloadSchema.Type; | ||
| 28 | |||
| 29 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | ||
| 30 | const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema); | ||
| 31 | |||
| 32 | export class ExecutionsApi extends HttpApiGroup.make("executions") | ||
| 33 | .add( | ||
| 34 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/executions` | ||
| 35 | .setPayload(CreateExecutionPayloadSchema) | ||
| 36 | .addSuccess(ExecutionSchema) | ||
| 37 | .addError(ControlPlaneBadRequestError) | ||
| 38 | .addError(ControlPlaneUnauthorizedError) | ||
| 39 | .addError(ControlPlaneForbiddenError) | ||
| 40 | .addError(ControlPlaneNotFoundError) | ||
| 41 | .addError(ControlPlaneStorageError), | ||
| 42 | ) | ||
| 43 | .add( | ||
| 44 | HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/executions/${executionIdParam}` | ||
| 45 | .addSuccess(ExecutionSchema) | ||
| 46 | .addError(ControlPlaneBadRequestError) | ||
| 47 | .addError(ControlPlaneUnauthorizedError) | ||
| 48 | .addError(ControlPlaneForbiddenError) | ||
| 49 | .addError(ControlPlaneNotFoundError) | ||
| 50 | .addError(ControlPlaneStorageError), | ||
| 51 | ) | ||
| 52 | .add( | ||
| 53 | HttpApiEndpoint.post("resume")`/workspaces/${workspaceIdParam}/executions/${executionIdParam}/resume` | ||
| 54 | .setPayload(ResumeExecutionPayloadSchema) | ||
| 55 | .addSuccess(ExecutionSchema) | ||
| 56 | .addError(ControlPlaneBadRequestError) | ||
| 57 | .addError(ControlPlaneUnauthorizedError) | ||
| 58 | .addError(ControlPlaneForbiddenError) | ||
| 59 | .addError(ControlPlaneNotFoundError) | ||
| 60 | .addError(ControlPlaneStorageError), | ||
| 61 | ) | ||
| 62 | .prefix("/v1") {} | ||
| @@ -0,0 +1,125 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | Actor, | ||
| 6 | ActorForbiddenError, | ||
| 7 | ActorUnauthenticatedError, | ||
| 8 | requirePermission, | ||
| 9 | withPolicy, | ||
| 10 | } from "#domain"; | ||
| 11 | |||
| 12 | import { ControlPlaneApi } from "../api"; | ||
| 13 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 14 | import { | ||
| 15 | ControlPlaneForbiddenError, | ||
| 16 | ControlPlaneUnauthorizedError, | ||
| 17 | } from "../errors"; | ||
| 18 | import { ControlPlaneService } from "../service"; | ||
| 19 | |||
| 20 | const toForbiddenError = ( | ||
| 21 | operation: string, | ||
| 22 | cause: ActorForbiddenError, | ||
| 23 | ): ControlPlaneForbiddenError => | ||
| 24 | new ControlPlaneForbiddenError({ | ||
| 25 | operation, | ||
| 26 | message: "Access denied", | ||
| 27 | details: `${cause.permission} on ${cause.scope}`, | ||
| 28 | }); | ||
| 29 | |||
| 30 | const toUnauthorizedError = ( | ||
| 31 | operation: string, | ||
| 32 | cause: ActorUnauthenticatedError, | ||
| 33 | ): ControlPlaneUnauthorizedError => | ||
| 34 | new ControlPlaneUnauthorizedError({ | ||
| 35 | operation, | ||
| 36 | message: cause.message, | ||
| 37 | details: "Authentication required", | ||
| 38 | }); | ||
| 39 | |||
| 40 | const resolveWorkspaceActor = (workspaceId: string) => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 43 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 44 | |||
| 45 | return yield* actorResolver.resolveWorkspaceActor({ | ||
| 46 | workspaceId: workspaceId as never, | ||
| 47 | headers: request.headers, | ||
| 48 | }); | ||
| 49 | }); | ||
| 50 | |||
| 51 | const requireExecuteWorkspace = (workspaceId: string) => | ||
| 52 | requirePermission({ | ||
| 53 | permission: "workspace:read", | ||
| 54 | workspaceId: workspaceId as never, | ||
| 55 | }); | ||
| 56 | |||
| 57 | export const ControlPlaneExecutionsLive = HttpApiBuilder.group( | ||
| 58 | ControlPlaneApi, | ||
| 59 | "executions", | ||
| 60 | (handlers) => | ||
| 61 | handlers | ||
| 62 | .handle("create", ({ path, payload }) => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const service = yield* ControlPlaneService; | ||
| 65 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 66 | |||
| 67 | return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))( | ||
| 68 | service.createExecution({ | ||
| 69 | workspaceId: path.workspaceId, | ||
| 70 | payload, | ||
| 71 | createdByAccountId: actor.principal.accountId, | ||
| 72 | }), | ||
| 73 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 74 | }).pipe( | ||
| 75 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 76 | Effect.fail(toUnauthorizedError("executions.create", cause)), | ||
| 77 | ), | ||
| 78 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 79 | Effect.fail(toForbiddenError("executions.create", cause)), | ||
| 80 | ), | ||
| 81 | ), | ||
| 82 | ) | ||
| 83 | .handle("get", ({ path }) => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const service = yield* ControlPlaneService; | ||
| 86 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 87 | |||
| 88 | return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))( | ||
| 89 | service.getExecution({ | ||
| 90 | workspaceId: path.workspaceId, | ||
| 91 | executionId: path.executionId, | ||
| 92 | }), | ||
| 93 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 94 | }).pipe( | ||
| 95 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 96 | Effect.fail(toUnauthorizedError("executions.get", cause)), | ||
| 97 | ), | ||
| 98 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 99 | Effect.fail(toForbiddenError("executions.get", cause)), | ||
| 100 | ), | ||
| 101 | ), | ||
| 102 | ) | ||
| 103 | .handle("resume", ({ path, payload }) => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const service = yield* ControlPlaneService; | ||
| 106 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 107 | |||
| 108 | return yield* withPolicy(requireExecuteWorkspace(path.workspaceId))( | ||
| 109 | service.resumeExecution({ | ||
| 110 | workspaceId: path.workspaceId, | ||
| 111 | executionId: path.executionId, | ||
| 112 | payload, | ||
| 113 | resumedByAccountId: actor.principal.accountId, | ||
| 114 | }), | ||
| 115 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 116 | }).pipe( | ||
| 117 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 118 | Effect.fail(toUnauthorizedError("executions.resume", cause)), | ||
| 119 | ), | ||
| 120 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 121 | Effect.fail(toForbiddenError("executions.resume", cause)), | ||
| 122 | ), | ||
| 123 | ), | ||
| 124 | ), | ||
| 125 | ); | ||
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | ControlPlaneActorResolver, | 6 | ControlPlaneActorResolver, |
| 7 | type ControlPlaneActorResolverShape, | 7 | type ControlPlaneActorResolverShape, |
| 8 | } from "./auth/actor-resolver"; | 8 | } from "./auth/actor-resolver"; |
| 9 | import { ControlPlaneExecutionsLive } from "./executions/http"; | ||
| 9 | import { ControlPlaneMembershipsLive } from "./memberships/http"; | 10 | import { ControlPlaneMembershipsLive } from "./memberships/http"; |
| 10 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; | 11 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; |
| 11 | import { ControlPlanePoliciesLive } from "./policies/http"; | 12 | import { ControlPlanePoliciesLive } from "./policies/http"; |
| @@ -19,6 +20,7 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines | |||
| 19 | Layer.provide(ControlPlaneWorkspacesLive), | 20 | Layer.provide(ControlPlaneWorkspacesLive), |
| 20 | Layer.provide(ControlPlaneSourcesLive), | 21 | Layer.provide(ControlPlaneSourcesLive), |
| 21 | Layer.provide(ControlPlanePoliciesLive), | 22 | Layer.provide(ControlPlanePoliciesLive), |
| 23 | Layer.provide(ControlPlaneExecutionsLive), | ||
| 22 | ); | 24 | ); |
| 23 | 25 | ||
| 24 | export const makeControlPlaneWebHandler = <EService, EResolver>( | 26 | export const makeControlPlaneWebHandler = <EService, EResolver>( |
| @@ -49,6 +49,13 @@ export { · 48 unmodified lines | |||
| 49 | export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership"; | 49 | export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership"; |
| 50 | 50 | ||
| 51 | 51 | ||
| 52 | export { | ||
| 53 | CreateExecutionPayloadSchema, | ||
| 54 | ResumeExecutionPayloadSchema, | ||
| 55 | type CreateExecutionPayload, | ||
| 56 | type ResumeExecutionPayload, | ||
| 57 | } from "./executions/api"; | ||
| 58 | |||
| 52 | export { | 59 | export { |
| 53 | CreateOrganizationPayloadSchema, | 60 | CreateOrganizationPayloadSchema, |
| 54 | UpdateOrganizationPayloadSchema, | 61 | UpdateOrganizationPayloadSchema, |
| @@ -1,5 +1,7 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | AccountId, | 2 | AccountId, |
| 3 | Execution, | ||
| 4 | ExecutionId, | ||
| 3 | Organization, | 5 | Organization, |
| 4 | OrganizationId, | 6 | OrganizationId, |
| 5 | OrganizationMembership, | 7 | OrganizationMembership, |
| @@ -29,6 +31,10 @@ import type { · 23 unmodified lines | |||
| 29 | CreateSourcePayload, | 31 | CreateSourcePayload, |
| 30 | UpdateSourcePayload, | 32 | UpdateSourcePayload, |
| 31 | } from "./sources/api"; | 33 | } from "./sources/api"; |
| 34 | import type { | ||
| 35 | CreateExecutionPayload, | ||
| 36 | ResumeExecutionPayload, | ||
| 37 | } from "./executions/api"; | ||
| 32 | import type { | 38 | import type { |
| 33 | CreateWorkspacePayload, | 39 | CreateWorkspacePayload, |
| 34 | UpdateWorkspacePayload, | 40 | UpdateWorkspacePayload, |
| @@ -110,6 +116,24 @@ export type CreatePolicyInput = { · 75 unmodified lines | |||
| 110 | payload: CreatePolicyPayload; | 116 | payload: CreatePolicyPayload; |
| 111 | }; | 117 | }; |
| 112 | 118 | ||
| 119 | export type CreateExecutionInput = { | ||
| 120 | workspaceId: WorkspaceId; | ||
| 121 | payload: CreateExecutionPayload; | ||
| 122 | createdByAccountId: AccountId; | ||
| 123 | }; | ||
| 124 | |||
| 125 | export type GetExecutionInput = { | ||
| 126 | workspaceId: WorkspaceId; | ||
| 127 | executionId: ExecutionId; | ||
| 128 | }; | ||
| 129 | |||
| 130 | export type ResumeExecutionInput = { | ||
| 131 | workspaceId: WorkspaceId; | ||
| 132 | executionId: ExecutionId; | ||
| 133 | payload: ResumeExecutionPayload; | ||
| 134 | resumedByAccountId: AccountId; | ||
| 135 | }; | ||
| 136 | |||
| 113 | export type GetPolicyInput = { | 137 | export type GetPolicyInput = { |
| 114 | workspaceId: WorkspaceId; | 138 | workspaceId: WorkspaceId; |
| 115 | policyId: PolicyId; | 139 | policyId: PolicyId; |
| @@ -210,6 +234,22 @@ export type ControlPlaneServiceShape = { · 94 unmodified lines | |||
| 210 | input: RemoveSourceInput, | 234 | input: RemoveSourceInput, |
| 211 | ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>; | 235 | ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>; |
| 212 | 236 | ||
| 237 | createExecution: ( | ||
| 238 | input: CreateExecutionInput, | ||
| 239 | ) => Effect.Effect< | ||
| 240 | Execution, | ||
| 241 | ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError | ||
| 242 | >; | ||
| 243 | getExecution: ( | ||
| 244 | input: GetExecutionInput, | ||
| 245 | ) => Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError>; | ||
| 246 | resumeExecution: ( | ||
| 247 | input: ResumeExecutionInput, | ||
| 248 | ) => Effect.Effect< | ||
| 249 | Execution, | ||
| 250 | ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError | ||
| 251 | >; | ||
| 252 | |||
| 213 | listPolicies: ( | 253 | listPolicies: ( |
| 214 | workspaceId: WorkspaceId, | 254 | workspaceId: WorkspaceId, |
| 215 | ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>; | 255 | ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>; |
| @@ -1,4 +1,8 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type Account, | ||
| 3 | type Execution, | ||
| 4 | type ExecutionInteraction, | ||
| 5 | type LocalInstallation, | ||
| 2 | type Organization, | 6 | type Organization, |
| 3 | type OrganizationMembership, | 7 | type OrganizationMembership, |
| 4 | type Policy, | 8 | type Policy, |
| @@ -49,6 +53,60 @@ export const createControlPlaneRows = ({ · 44 unmodified lines | |||
| 49 | db, | 53 | db, |
| 50 | tables, | 54 | tables, |
| 51 | }: CreateControlPlaneRowsInput) => ({ | 55 | }: CreateControlPlaneRowsInput) => ({ |
| 56 | accounts: { | ||
| 57 | getById: (accountId: Account["id"]) => | ||
| 58 | rowEffect(backend, "rows.accounts.get_by_id", tableNames.accounts, async () => { | ||
| 59 | const row = await db | ||
| 60 | .select() | ||
| 61 | .from(tables.accountsTable) | ||
| 62 | .where(eq(tables.accountsTable.id, accountId)) | ||
| 63 | .limit(1); | ||
| 64 | |||
| 65 | return row[0] ? Option.some(asDomain<Account>(row[0])) : Option.none<Account>(); | ||
| 66 | }), | ||
| 67 | |||
| 68 | getByProviderAndSubject: (provider: Account["provider"], subject: Account["subject"]) => | ||
| 69 | rowEffect( | ||
| 70 | backend, | ||
| 71 | "rows.accounts.get_by_provider_and_subject", | ||
| 72 | tableNames.accounts, | ||
| 73 | async () => { | ||
| 74 | const row = await db | ||
| 75 | .select() | ||
| 76 | .from(tables.accountsTable) | ||
| 77 | .where( | ||
| 78 | and( | ||
| 79 | eq(tables.accountsTable.provider, provider), | ||
| 80 | eq(tables.accountsTable.subject, subject), | ||
| 81 | ), | ||
| 82 | ) | ||
| 83 | .limit(1); | ||
| 84 | |||
| 85 | return row[0] ? Option.some(asDomain<Account>(row[0])) : Option.none<Account>(); | ||
| 86 | }, | ||
| 87 | ), | ||
| 88 | |||
| 89 | insert: (account: Account) => | ||
| 90 | rowEffect(backend, "rows.accounts.insert", tableNames.accounts, async () => { | ||
| 91 | await db.insert(tables.accountsTable).values(account); | ||
| 92 | }), | ||
| 93 | |||
| 94 | upsert: (account: Account) => | ||
| 95 | rowEffect(backend, "rows.accounts.upsert", tableNames.accounts, async () => { | ||
| 96 | await db | ||
| 97 | .insert(tables.accountsTable) | ||
| 98 | .values(account) | ||
| 99 | .onConflictDoUpdate({ | ||
| 100 | target: [tables.accountsTable.provider, tables.accountsTable.subject], | ||
| 101 | set: { | ||
| 102 | email: account.email, | ||
| 103 | displayName: account.displayName, | ||
| 104 | updatedAt: account.updatedAt, | ||
| 105 | }, | ||
| 106 | }); | ||
| 107 | }), | ||
| 108 | }, | ||
| 109 | |||
| 52 | organizations: { | 110 | organizations: { |
| 53 | list: () => | 111 | list: () => |
| 54 | rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => { | 112 | rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => { |
| @@ -569,6 +627,179 @@ export const createControlPlaneRows = ({ · 514 unmodified lines | |||
| 569 | return deleted.length > 0; | 627 | return deleted.length > 0; |
| 570 | }), | 628 | }), |
| 571 | }, | 629 | }, |
| 630 | |||
| 631 | localInstallations: { | ||
| 632 | getById: (installationId: LocalInstallation["id"]) => | ||
| 633 | rowEffect( | ||
| 634 | backend, | ||
| 635 | "rows.local_installations.get_by_id", | ||
| 636 | tableNames.localInstallations, | ||
| 637 | async () => { | ||
| 638 | const row = await db | ||
| 639 | .select() | ||
| 640 | .from(tables.localInstallationsTable) | ||
| 641 | .where(eq(tables.localInstallationsTable.id, installationId)) | ||
| 642 | .limit(1); | ||
| 643 | |||
| 644 | return row[0] | ||
| 645 | ? Option.some(asDomain<LocalInstallation>(row[0])) | ||
| 646 | : Option.none<LocalInstallation>(); | ||
| 647 | }, | ||
| 648 | ), | ||
| 649 | |||
| 650 | upsert: (installation: LocalInstallation) => | ||
| 651 | rowEffect( | ||
| 652 | backend, | ||
| 653 | "rows.local_installations.upsert", | ||
| 654 | tableNames.localInstallations, | ||
| 655 | async () => { | ||
| 656 | await db | ||
| 657 | .insert(tables.localInstallationsTable) | ||
| 658 | .values(installation) | ||
| 659 | .onConflictDoUpdate({ | ||
| 660 | target: tables.localInstallationsTable.id, | ||
| 661 | set: { | ||
| 662 | accountId: installation.accountId, | ||
| 663 | organizationId: installation.organizationId, | ||
| 664 | workspaceId: installation.workspaceId, | ||
| 665 | updatedAt: installation.updatedAt, | ||
| 666 | }, | ||
| 667 | }); | ||
| 668 | }, | ||
| 669 | ), | ||
| 670 | }, | ||
| 671 | |||
| 672 | executions: { | ||
| 673 | getById: (executionId: Execution["id"]) => | ||
| 674 | rowEffect(backend, "rows.executions.get_by_id", tableNames.executions, async () => { | ||
| 675 | const row = await db | ||
| 676 | .select() | ||
| 677 | .from(tables.executionsTable) | ||
| 678 | .where(eq(tables.executionsTable.id, executionId)) | ||
| 679 | .limit(1); | ||
| 680 | |||
| 681 | return row[0] ? Option.some(asDomain<Execution>(row[0])) : Option.none<Execution>(); | ||
| 682 | }), | ||
| 683 | |||
| 684 | getByWorkspaceAndId: (workspaceId: Execution["workspaceId"], executionId: Execution["id"]) => | ||
| 685 | rowEffect( | ||
| 686 | backend, | ||
| 687 | "rows.executions.get_by_workspace_and_id", | ||
| 688 | tableNames.executions, | ||
| 689 | async () => { | ||
| 690 | const row = await db | ||
| 691 | .select() | ||
| 692 | .from(tables.executionsTable) | ||
| 693 | .where( | ||
| 694 | and( | ||
| 695 | eq(tables.executionsTable.workspaceId, workspaceId), | ||
| 696 | eq(tables.executionsTable.id, executionId), | ||
| 697 | ), | ||
| 698 | ) | ||
| 699 | .limit(1); | ||
| 700 | |||
| 701 | return row[0] ? Option.some(asDomain<Execution>(row[0])) : Option.none<Execution>(); | ||
| 702 | }, | ||
| 703 | ), | ||
| 704 | |||
| 705 | insert: (execution: Execution) => | ||
| 706 | rowEffect(backend, "rows.executions.insert", tableNames.executions, async () => { | ||
| 707 | await db.insert(tables.executionsTable).values(execution); | ||
| 708 | }), | ||
| 709 | |||
| 710 | update: ( | ||
| 711 | executionId: Execution["id"], | ||
| 712 | patch: Partial<Omit<Execution, "id" | "workspaceId" | "createdByAccountId" | "createdAt">>, | ||
| 713 | ) => | ||
| 714 | rowEffect(backend, "rows.executions.update", tableNames.executions, async () => { | ||
| 715 | const rows = await db | ||
| 716 | .update(tables.executionsTable) | ||
| 717 | .set(patch) | ||
| 718 | .where(eq(tables.executionsTable.id, executionId)) | ||
| 719 | .returning(); | ||
| 720 | |||
| 721 | return rows[0] ? Option.some(asDomain<Execution>(rows[0])) : Option.none<Execution>(); | ||
| 722 | }), | ||
| 723 | }, | ||
| 724 | |||
| 725 | executionInteractions: { | ||
| 726 | listByExecutionId: (executionId: ExecutionInteraction["executionId"]) => | ||
| 727 | rowEffect( | ||
| 728 | backend, | ||
| 729 | "rows.execution_interactions.list_by_execution_id", | ||
| 730 | tableNames.executionInteractions, | ||
| 731 | async () => { | ||
| 732 | const rows = await db | ||
| 733 | .select() | ||
| 734 | .from(tables.executionInteractionsTable) | ||
| 735 | .where(eq(tables.executionInteractionsTable.executionId, executionId)) | ||
| 736 | .orderBy( | ||
| 737 | desc(tables.executionInteractionsTable.updatedAt), | ||
| 738 | desc(tables.executionInteractionsTable.id), | ||
| 739 | ); | ||
| 740 | |||
| 741 | return asDomainArray<ExecutionInteraction>(rows); | ||
| 742 | }, | ||
| 743 | ), | ||
| 744 | |||
| 745 | getPendingByExecutionId: (executionId: ExecutionInteraction["executionId"]) => | ||
| 746 | rowEffect( | ||
| 747 | backend, | ||
| 748 | "rows.execution_interactions.get_pending_by_execution_id", | ||
| 749 | tableNames.executionInteractions, | ||
| 750 | async () => { | ||
| 751 | const row = await db | ||
| 752 | .select() | ||
| 753 | .from(tables.executionInteractionsTable) | ||
| 754 | .where( | ||
| 755 | and( | ||
| 756 | eq(tables.executionInteractionsTable.executionId, executionId), | ||
| 757 | eq(tables.executionInteractionsTable.status, "pending"), | ||
| 758 | ), | ||
| 759 | ) | ||
| 760 | .orderBy( | ||
| 761 | desc(tables.executionInteractionsTable.updatedAt), | ||
| 762 | desc(tables.executionInteractionsTable.id), | ||
| 763 | ) | ||
| 764 | .limit(1); | ||
| 765 | |||
| 766 | return row[0] | ||
| 767 | ? Option.some(asDomain<ExecutionInteraction>(row[0])) | ||
| 768 | : Option.none<ExecutionInteraction>(); | ||
| 769 | }, | ||
| 770 | ), | ||
| 771 | |||
| 772 | insert: (interaction: ExecutionInteraction) => | ||
| 773 | rowEffect( | ||
| 774 | backend, | ||
| 775 | "rows.execution_interactions.insert", | ||
| 776 | tableNames.executionInteractions, | ||
| 777 | async () => { | ||
| 778 | await db.insert(tables.executionInteractionsTable).values(interaction); | ||
| 779 | }, | ||
| 780 | ), | ||
| 781 | |||
| 782 | update: ( | ||
| 783 | interactionId: ExecutionInteraction["id"], | ||
| 784 | patch: Partial<Omit<ExecutionInteraction, "id" | "executionId" | "createdAt">>, | ||
| 785 | ) => | ||
| 786 | rowEffect( | ||
| 787 | backend, | ||
| 788 | "rows.execution_interactions.update", | ||
| 789 | tableNames.executionInteractions, | ||
| 790 | async () => { | ||
| 791 | const rows = await db | ||
| 792 | .update(tables.executionInteractionsTable) | ||
| 793 | .set(patch) | ||
| 794 | .where(eq(tables.executionInteractionsTable.id, interactionId)) | ||
| 795 | .returning(); | ||
| 796 | |||
| 797 | return rows[0] | ||
| 798 | ? Option.some(asDomain<ExecutionInteraction>(rows[0])) | ||
| 799 | : Option.none<ExecutionInteraction>(); | ||
| 800 | }, | ||
| 801 | ), | ||
| 802 | }, | ||
| 572 | }); | 803 | }); |
| 573 | 804 | ||
| 574 | export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>; | 805 | export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>; |
| @@ -11,13 +11,38 @@ import { · 10 unmodified lines | |||
| 11 | import { sql } from "drizzle-orm"; | 11 | import { sql } from "drizzle-orm"; |
| 12 | 12 | ||
| 13 | export const tableNames = { | 13 | export const tableNames = { |
| 14 | accounts: "accounts", | ||
| 14 | organizations: "organizations", | 15 | organizations: "organizations", |
| 15 | organizationMemberships: "organization_memberships", | 16 | organizationMemberships: "organization_memberships", |
| 16 | workspaces: "workspaces", | 17 | workspaces: "workspaces", |
| 17 | sources: "sources", | 18 | sources: "sources", |
| 18 | policies: "policies", | 19 | policies: "policies", |
| 20 | localInstallations: "local_installations", | ||
| 21 | executions: "executions", | ||
| 22 | executionInteractions: "execution_interactions", | ||
| 19 | } as const; | 23 | } as const; |
| 20 | 24 | ||
| 25 | export const accountsTable = pgTable( | ||
| 26 | tableNames.accounts, | ||
| 27 | { | ||
| 28 | id: text("id").notNull().primaryKey(), | ||
| 29 | provider: text("provider").notNull(), | ||
| 30 | subject: text("subject").notNull(), | ||
| 31 | email: text("email"), | ||
| 32 | displayName: text("display_name"), | ||
| 33 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | ||
| 34 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | ||
| 35 | }, | ||
| 36 | (table) => [ | ||
| 37 | uniqueIndex("accounts_provider_subject_idx").on(table.provider, table.subject), | ||
| 38 | index("accounts_updated_idx").on(table.updatedAt, table.id), | ||
| 39 | check( | ||
| 40 | "accounts_provider_check", | ||
| 41 | sql`${table.provider} in ('local', 'workos', 'service')`, | ||
| 42 | ), | ||
| 43 | ], | ||
| 44 | ); | ||
| 45 | |||
| 21 | export const organizationsTable = pgTable(tableNames.organizations, { | 46 | export const organizationsTable = pgTable(tableNames.organizations, { |
| 22 | id: text("id").notNull().primaryKey(), | 47 | id: text("id").notNull().primaryKey(), |
| 23 | slug: text("slug").notNull(), | 48 | slug: text("slug").notNull(), |
| @@ -154,12 +179,75 @@ export const policiesTable = pgTable( · 130 unmodified lines | |||
| 154 | ], | 179 | ], |
| 155 | ); | 180 | ); |
| 156 | 181 | ||
| 182 | export const localInstallationsTable = pgTable(tableNames.localInstallations, { | ||
| 183 | id: text("id").notNull().primaryKey(), | ||
| 184 | accountId: text("account_id").notNull(), | ||
| 185 | organizationId: text("organization_id").notNull(), | ||
| 186 | workspaceId: text("workspace_id").notNull(), | ||
| 187 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | ||
| 188 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | ||
| 189 | }); | ||
| 190 | |||
| 191 | export const executionsTable = pgTable( | ||
| 192 | tableNames.executions, | ||
| 193 | { | ||
| 194 | id: text("id").notNull().primaryKey(), | ||
| 195 | workspaceId: text("workspace_id").notNull(), | ||
| 196 | createdByAccountId: text("created_by_account_id").notNull(), | ||
| 197 | status: text("status").notNull(), | ||
| 198 | code: text("code").notNull(), | ||
| 199 | resultJson: text("result_json"), | ||
| 200 | errorText: text("error_text"), | ||
| 201 | logsJson: text("logs_json"), | ||
| 202 | startedAt: bigint("started_at", { mode: "number" }), | ||
| 203 | completedAt: bigint("completed_at", { mode: "number" }), | ||
| 204 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | ||
| 205 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | ||
| 206 | }, | ||
| 207 | (table) => [ | ||
| 208 | index("executions_workspace_idx").on(table.workspaceId, table.updatedAt, table.id), | ||
| 209 | check( | ||
| 210 | "executions_status_check", | ||
| 211 | sql`${table.status} in ('pending', 'running', 'waiting_for_interaction', 'completed', 'failed', 'cancelled')`, | ||
| 212 | ), | ||
| 213 | ], | ||
| 214 | ); | ||
| 215 | |||
| 216 | export const executionInteractionsTable = pgTable( | ||
| 217 | tableNames.executionInteractions, | ||
| 218 | { | ||
| 219 | id: text("id").notNull().primaryKey(), | ||
| 220 | executionId: text("execution_id").notNull(), | ||
| 221 | status: text("status").notNull(), | ||
| 222 | kind: text("kind").notNull(), | ||
| 223 | payloadJson: text("payload_json").notNull(), | ||
| 224 | responseJson: text("response_json"), | ||
| 225 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | ||
| 226 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | ||
| 227 | }, | ||
| 228 | (table) => [ | ||
| 229 | index("execution_interactions_execution_idx").on( | ||
| 230 | table.executionId, | ||
| 231 | table.updatedAt, | ||
| 232 | table.id, | ||
| 233 | ), | ||
| 234 | check( | ||
| 235 | "execution_interactions_status_check", | ||
| 236 | sql`${table.status} in ('pending', 'resolved', 'cancelled')`, | ||
| 237 | ), | ||
| 238 | ], | ||
| 239 | ); | ||
| 240 | |||
| 157 | export const drizzleSchema = { | 241 | export const drizzleSchema = { |
| 242 | accountsTable, | ||
| 158 | organizationsTable, | 243 | organizationsTable, |
| 159 | organizationMembershipsTable, | 244 | organizationMembershipsTable, |
| 160 | workspacesTable, | 245 | workspacesTable, |
| 161 | sourcesTable, | 246 | sourcesTable, |
| 162 | policiesTable, | 247 | policiesTable, |
| 248 | localInstallationsTable, | ||
| 249 | executionsTable, | ||
| 250 | executionInteractionsTable, | ||
| 163 | }; | 251 | }; |
| 164 | 252 | ||
| 165 | export type DrizzleTables = typeof drizzleSchema; | 253 | export type DrizzleTables = typeof drizzleSchema; |
| @@ -85,6 +85,20 @@ const createPostgresRuntime = async ( · 84 unmodified lines | |||
| 85 | 85 | ||
| 86 | const ddlStatements = [ | 86 | const ddlStatements = [ |
| 87 | ` | 87 | ` |
| 88 | create table if not exists accounts ( | ||
| 89 | id text primary key, | ||
| 90 | provider text not null, | ||
| 91 | subject text not null, | ||
| 92 | email text, | ||
| 93 | display_name text, | ||
| 94 | created_at bigint not null, | ||
| 95 | updated_at bigint not null, | ||
| 96 | constraint accounts_provider_check check (provider in ('local', 'workos', 'service')) | ||
| 97 | ); | ||
| 98 | `, | ||
| 99 | "create unique index if not exists accounts_provider_subject_idx on accounts (provider, subject);", | ||
| 100 | "create index if not exists accounts_updated_idx on accounts (updated_at, id);", | ||
| 101 | ` | ||
| 88 | create table if not exists organizations ( | 102 | create table if not exists organizations ( |
| 89 | id text primary key, | 103 | id text primary key, |
| 90 | slug text not null, | 104 | slug text not null, |
| @@ -172,6 +186,48 @@ create table if not exists policies ( · 81 unmodified lines | |||
| 172 | ); | 186 | ); |
| 173 | `, | 187 | `, |
| 174 | "create index if not exists policies_workspace_idx on policies (workspace_id, updated_at, id);", | 188 | "create index if not exists policies_workspace_idx on policies (workspace_id, updated_at, id);", |
| 189 | ` | ||
| 190 | create table if not exists local_installations ( | ||
| 191 | id text primary key, | ||
| 192 | account_id text not null, | ||
| 193 | organization_id text not null, | ||
| 194 | workspace_id text not null, | ||
| 195 | created_at bigint not null, | ||
| 196 | updated_at bigint not null | ||
| 197 | ); | ||
| 198 | `, | ||
| 199 | ` | ||
| 200 | create table if not exists executions ( | ||
| 201 | id text primary key, | ||
| 202 | workspace_id text not null, | ||
| 203 | created_by_account_id text not null, | ||
| 204 | status text not null, | ||
| 205 | code text not null, | ||
| 206 | result_json text, | ||
| 207 | error_text text, | ||
| 208 | logs_json text, | ||
| 209 | started_at bigint, | ||
| 210 | completed_at bigint, | ||
| 211 | created_at bigint not null, | ||
| 212 | updated_at bigint not null, | ||
| 213 | constraint executions_status_check check (status in ('pending', 'running', 'waiting_for_interaction', 'completed', 'failed', 'cancelled')) | ||
| 214 | ); | ||
| 215 | `, | ||
| 216 | "create index if not exists executions_workspace_idx on executions (workspace_id, updated_at, id);", | ||
| 217 | ` | ||
| 218 | create table if not exists execution_interactions ( | ||
| 219 | id text primary key, | ||
| 220 | execution_id text not null, | ||
| 221 | status text not null, | ||
| 222 | kind text not null, | ||
| 223 | payload_json text not null, | ||
| 224 | response_json text, | ||
| 225 | created_at bigint not null, | ||
| 226 | updated_at bigint not null, | ||
| 227 | constraint execution_interactions_status_check check (status in ('pending', 'resolved', 'cancelled')) | ||
| 228 | ); | ||
| 229 | `, | ||
| 230 | "create index if not exists execution_interactions_execution_idx on execution_interactions (execution_id, updated_at, id);", | ||
| 175 | ]; | 231 | ]; |
| 176 | 232 | ||
| 177 | export const ensureSchema = async (db: any): Promise<void> => { | 233 | export const ensureSchema = async (db: any): Promise<void> => { |
| @@ -0,0 +1,104 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import { makeToolInvokerFromTools } from "@executor-v3/codemode-core"; | ||
| 6 | import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | makeSqlControlPlaneRuntime, | ||
| 10 | } from "./index"; | ||
| 11 | import { withControlPlaneClient } from "./test-http-client"; | ||
| 12 | |||
| 13 | const makeExecutionResolver = () => { | ||
| 14 | const toolInvoker = makeToolInvokerFromTools({ | ||
| 15 | tools: { | ||
| 16 | "math.add": { | ||
| 17 | description: "Add two numbers", | ||
| 18 | inputSchema: Schema.standardSchemaV1( | ||
| 19 | Schema.Struct({ | ||
| 20 | a: Schema.optional(Schema.Number), | ||
| 21 | b: Schema.optional(Schema.Number), | ||
| 22 | }), | ||
| 23 | ), | ||
| 24 | execute: ({ | ||
| 25 | a, | ||
| 26 | b, | ||
| 27 | }) => ({ sum: (a ?? 0) + (b ?? 0) }), | ||
| 28 | }, | ||
| 29 | }, | ||
| 30 | }); | ||
| 31 | |||
| 32 | return () => | ||
| 33 | Effect.succeed({ | ||
| 34 | executor: makeInProcessExecutor(), | ||
| 35 | toolInvoker, | ||
| 36 | }); | ||
| 37 | }; | ||
| 38 | |||
| 39 | const makeRuntime = Effect.acquireRelease( | ||
| 40 | makeSqlControlPlaneRuntime({ | ||
| 41 | localDataDir: ":memory:", | ||
| 42 | executionResolver: makeExecutionResolver(), | ||
| 43 | }), | ||
| 44 | (runtime) => | ||
| 45 | Effect.tryPromise({ | ||
| 46 | try: async () => { | ||
| 47 | await runtime.close(); | ||
| 48 | await runtime.webHandler.dispose(); | ||
| 49 | }, | ||
| 50 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | ||
| 51 | }).pipe(Effect.orDie), | ||
| 52 | ); | ||
| 53 | |||
| 54 | describe("execution-http", () => { | ||
| 55 | it.scoped("creates and persists an execution through the HTTP API", () => | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const runtime = yield* makeRuntime; | ||
| 58 | const installation = runtime.localInstallation; | ||
| 59 | |||
| 60 | const createExecution = (yield* withControlPlaneClient( | ||
| 61 | { | ||
| 62 | runtime, | ||
| 63 | accountId: installation.accountId, | ||
| 64 | }, | ||
| 65 | (client) => | ||
| 66 | client.executions.create({ | ||
| 67 | path: { | ||
| 68 | workspaceId: installation.workspaceId, | ||
| 69 | }, | ||
| 70 | payload: { | ||
| 71 | code: "return await tools.math.add({ a: 20, b: 22 });", | ||
| 72 | }, | ||
| 73 | }), | ||
| 74 | )) as { | ||
| 75 | id: string; | ||
| 76 | status: string; | ||
| 77 | resultJson: string | null; | ||
| 78 | }; | ||
| 79 | |||
| 80 | expect(createExecution.status).toBe("completed"); | ||
| 81 | expect(createExecution.resultJson).toBe(JSON.stringify({ sum: 42 })); | ||
| 82 | |||
| 83 | const getExecution = (yield* withControlPlaneClient( | ||
| 84 | { | ||
| 85 | runtime, | ||
| 86 | accountId: installation.accountId, | ||
| 87 | }, | ||
| 88 | (client) => | ||
| 89 | client.executions.get({ | ||
| 90 | path: { | ||
| 91 | workspaceId: installation.workspaceId, | ||
| 92 | executionId: createExecution.id, | ||
| 93 | }, | ||
| 94 | }), | ||
| 95 | )) as { | ||
| 96 | id: string; | ||
| 97 | status: string; | ||
| 98 | }; | ||
| 99 | |||
| 100 | expect(getExecution.id).toBe(createExecution.id); | ||
| 101 | expect(getExecution.status).toBe("completed"); | ||
| 102 | }), | ||
| 103 | ); | ||
| 104 | }); | ||
| @@ -0,0 +1,365 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; | ||
| 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import { z } from "zod/v4"; | ||
| 11 | |||
| 12 | import { makeToolInvokerFromTools } from "@executor-v3/codemode-core"; | ||
| 13 | import { discoverMcpToolsFromConnector } from "@executor-v3/codemode-mcp"; | ||
| 14 | import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | makeSqlControlPlaneRuntime, | ||
| 18 | type ResolveExecutionEnvironment, | ||
| 19 | } from "./index"; | ||
| 20 | import { withControlPlaneClient } from "./test-http-client"; | ||
| 21 | |||
| 22 | type McpFormServer = { | ||
| 23 | endpoint: string; | ||
| 24 | close: () => Promise<void>; | ||
| 25 | }; | ||
| 26 | |||
| 27 | const registerFormGatedEchoTool = (server: McpServer) => { | ||
| 28 | server.registerTool( | ||
| 29 | "gated_echo", | ||
| 30 | { | ||
| 31 | description: "Asks for approval before echoing", | ||
| 32 | inputSchema: { | ||
| 33 | value: z.string(), | ||
| 34 | }, | ||
| 35 | }, | ||
| 36 | async ({ value }: { value: string }) => { | ||
| 37 | const response = await server.server.elicitInput({ | ||
| 38 | mode: "form", | ||
| 39 | message: `Approve gated echo for ${value}?`, | ||
| 40 | requestedSchema: { | ||
| 41 | type: "object", | ||
| 42 | properties: { | ||
| 43 | approve: { | ||
| 44 | type: "boolean", | ||
| 45 | title: "Approve", | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | required: ["approve"], | ||
| 49 | }, | ||
| 50 | }); | ||
| 51 | |||
| 52 | if ( | ||
| 53 | response.action !== "accept" | ||
| 54 | || !response.content | ||
| 55 | || response.content.approve !== true | ||
| 56 | ) { | ||
| 57 | return { | ||
| 58 | content: [{ type: "text", text: "denied" }], | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | |||
| 62 | return { | ||
| 63 | content: [{ type: "text", text: `approved:${value}` }], | ||
| 64 | }; | ||
| 65 | }, | ||
| 66 | ); | ||
| 67 | }; | ||
| 68 | |||
| 69 | const makeFormElicitationServer = Effect.acquireRelease( | ||
| 70 | Effect.promise<McpFormServer>( | ||
| 71 | () => | ||
| 72 | new Promise<McpFormServer>((resolve, reject) => { | ||
| 73 | const app = createMcpExpressApp({ host: "127.0.0.1" }); | ||
| 74 | const transports: Record<string, StreamableHTTPServerTransport> = {}; | ||
| 75 | const servers: Record<string, McpServer> = {}; | ||
| 76 | |||
| 77 | const createServer = () => { | ||
| 78 | const server = new McpServer( | ||
| 79 | { | ||
| 80 | name: "control-plane-mcp-form-elicitation-test-server", | ||
| 81 | version: "1.0.0", | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | capabilities: { | ||
| 85 | tools: {}, | ||
| 86 | }, | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | |||
| 90 | registerFormGatedEchoTool(server); | ||
| 91 | return server; | ||
| 92 | }; | ||
| 93 | |||
| 94 | app.post("/mcp", async (req: any, res: any) => { | ||
| 95 | const sessionIdHeader = req.headers["mcp-session-id"]; | ||
| 96 | const sessionId = | ||
| 97 | typeof sessionIdHeader === "string" | ||
| 98 | ? sessionIdHeader | ||
| 99 | : Array.isArray(sessionIdHeader) | ||
| 100 | ? sessionIdHeader[0] | ||
| 101 | : undefined; | ||
| 102 | |||
| 103 | try { | ||
| 104 | let transport: StreamableHTTPServerTransport; | ||
| 105 | |||
| 106 | if (sessionId && transports[sessionId]) { | ||
| 107 | transport = transports[sessionId]; | ||
| 108 | } else { | ||
| 109 | transport = new StreamableHTTPServerTransport({ | ||
| 110 | sessionIdGenerator: () => randomUUID(), | ||
| 111 | onsessioninitialized: (newSessionId) => { | ||
| 112 | transports[newSessionId] = transport; | ||
| 113 | }, | ||
| 114 | }); | ||
| 115 | |||
| 116 | transport.onclose = () => { | ||
| 117 | const closedSessionId = transport.sessionId; | ||
| 118 | if (closedSessionId && transports[closedSessionId]) { | ||
| 119 | delete transports[closedSessionId]; | ||
| 120 | } | ||
| 121 | if (closedSessionId && servers[closedSessionId]) { | ||
| 122 | void servers[closedSessionId].close().catch(() => undefined); | ||
| 123 | delete servers[closedSessionId]; | ||
| 124 | } | ||
| 125 | }; | ||
| 126 | |||
| 127 | const server = createServer(); | ||
| 128 | await server.connect(transport); | ||
| 129 | const newSessionId = transport.sessionId; | ||
| 130 | if (newSessionId) { | ||
| 131 | servers[newSessionId] = server; | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | await transport.handleRequest(req, res, req.body); | ||
| 136 | } catch (error) { | ||
| 137 | if (!res.headersSent) { | ||
| 138 | res.status(500).json({ | ||
| 139 | jsonrpc: "2.0", | ||
| 140 | error: { | ||
| 141 | code: -32603, | ||
| 142 | message: | ||
| 143 | error instanceof Error ? error.message : "Internal server error", | ||
| 144 | }, | ||
| 145 | id: null, | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | }); | ||
| 150 | |||
| 151 | app.get("/mcp", async (req: any, res: any) => { | ||
| 152 | const sessionIdHeader = req.headers["mcp-session-id"]; | ||
| 153 | const sessionId = | ||
| 154 | typeof sessionIdHeader === "string" | ||
| 155 | ? sessionIdHeader | ||
| 156 | : Array.isArray(sessionIdHeader) | ||
| 157 | ? sessionIdHeader[0] | ||
| 158 | : undefined; | ||
| 159 | |||
| 160 | if (!sessionId || !transports[sessionId]) { | ||
| 161 | res.status(400).send("Invalid or missing session ID"); | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | |||
| 165 | await transports[sessionId].handleRequest(req, res); | ||
| 166 | }); | ||
| 167 | |||
| 168 | app.delete("/mcp", async (req: any, res: any) => { | ||
| 169 | const sessionIdHeader = req.headers["mcp-session-id"]; | ||
| 170 | const sessionId = | ||
| 171 | typeof sessionIdHeader === "string" | ||
| 172 | ? sessionIdHeader | ||
| 173 | : Array.isArray(sessionIdHeader) | ||
| 174 | ? sessionIdHeader[0] | ||
| 175 | : undefined; | ||
| 176 | |||
| 177 | if (!sessionId || !transports[sessionId]) { | ||
| 178 | res.status(400).send("Invalid or missing session ID"); | ||
| 179 | return; | ||
| 180 | } | ||
| 181 | |||
| 182 | const transport = transports[sessionId]; | ||
| 183 | await transport.handleRequest(req, res, req.body); | ||
| 184 | await transport.close(); | ||
| 185 | delete transports[sessionId]; | ||
| 186 | |||
| 187 | if (servers[sessionId]) { | ||
| 188 | await servers[sessionId].close().catch(() => undefined); | ||
| 189 | delete servers[sessionId]; | ||
| 190 | } | ||
| 191 | }); | ||
| 192 | |||
| 193 | const listener = app.listen(0, "127.0.0.1", () => { | ||
| 194 | const address = listener.address(); | ||
| 195 | if (!address || typeof address === "string") { | ||
| 196 | reject(new Error("failed to resolve MCP test server address")); | ||
| 197 | return; | ||
| 198 | } | ||
| 199 | |||
| 200 | resolve({ | ||
| 201 | endpoint: `http://127.0.0.1:${address.port}/mcp`, | ||
| 202 | close: async () => { | ||
| 203 | for (const transport of Object.values(transports)) { | ||
| 204 | await transport.close().catch(() => undefined); | ||
| 205 | } | ||
| 206 | |||
| 207 | for (const server of Object.values(servers)) { | ||
| 208 | await server.close().catch(() => undefined); | ||
| 209 | } | ||
| 210 | |||
| 211 | await new Promise<void>((closeResolve, closeReject) => { | ||
| 212 | listener.close((error: Error | undefined) => { | ||
| 213 | if (error) { | ||
| 214 | closeReject(error); | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | closeResolve(); | ||
| 218 | }); | ||
| 219 | }); | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | }); | ||
| 223 | |||
| 224 | listener.once("error", reject); | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | (server) => | ||
| 228 | Effect.tryPromise({ | ||
| 229 | try: () => server.close(), | ||
| 230 | catch: (error: unknown) => | ||
| 231 | error instanceof Error ? error : new Error(String(error)), | ||
| 232 | }).pipe(Effect.orDie), | ||
| 233 | ); | ||
| 234 | |||
| 235 | const makeMcpExecutionResolver = ( | ||
| 236 | endpoint: string, | ||
| 237 | ): ResolveExecutionEnvironment => | ||
| 238 | ({ onElicitation }) => | ||
| 239 | (Effect.gen(function* () { | ||
| 240 | const discovered = yield* discoverMcpToolsFromConnector({ | ||
| 241 | connect: async () => { | ||
| 242 | const client = new Client( | ||
| 243 | { | ||
| 244 | name: "control-plane-mcp-execution-client", | ||
| 245 | version: "1.0.0", | ||
| 246 | }, | ||
| 247 | { capabilities: { elicitation: { form: {} } } }, | ||
| 248 | ); | ||
| 249 | const transport = new StreamableHTTPClientTransport(new URL(endpoint)); | ||
| 250 | await client.connect(transport); | ||
| 251 | |||
| 252 | return { | ||
| 253 | client, | ||
| 254 | close: async () => { | ||
| 255 | await client.close(); | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | }, | ||
| 259 | namespace: "source.form", | ||
| 260 | sourceKey: "mcp.form", | ||
| 261 | }); | ||
| 262 | |||
| 263 | return { | ||
| 264 | executor: makeInProcessExecutor(), | ||
| 265 | toolInvoker: makeToolInvokerFromTools({ | ||
| 266 | tools: discovered.tools, | ||
| 267 | onElicitation, | ||
| 268 | }), | ||
| 269 | }; | ||
| 270 | }) as Effect.Effect< | ||
| 271 | { | ||
| 272 | executor: ReturnType<typeof makeInProcessExecutor>; | ||
| 273 | toolInvoker: ReturnType<typeof makeToolInvokerFromTools>; | ||
| 274 | }, | ||
| 275 | unknown | ||
| 276 | >); | ||
| 277 | |||
| 278 | describe("execution-mcp-resume", () => { | ||
| 279 | it.scoped("pauses on MCP elicitation and resumes the same live execution", () => | ||
| 280 | Effect.gen(function* () { | ||
| 281 | const mcpServer = yield* makeFormElicitationServer; | ||
| 282 | const runtime = yield* Effect.acquireRelease( | ||
| 283 | makeSqlControlPlaneRuntime({ | ||
| 284 | localDataDir: ":memory:", | ||
| 285 | executionResolver: makeMcpExecutionResolver(mcpServer.endpoint), | ||
| 286 | }), | ||
| 287 | (runtime) => | ||
| 288 | Effect.tryPromise({ | ||
| 289 | try: async () => { | ||
| 290 | await runtime.close(); | ||
| 291 | await runtime.webHandler.dispose(); | ||
| 292 | }, | ||
| 293 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | ||
| 294 | }).pipe(Effect.orDie), | ||
| 295 | ); | ||
| 296 | |||
| 297 | const installation = runtime.localInstallation; | ||
| 298 | |||
| 299 | const created = (yield* withControlPlaneClient( | ||
| 300 | { | ||
| 301 | runtime, | ||
| 302 | accountId: installation.accountId, | ||
| 303 | }, | ||
| 304 | (client) => | ||
| 305 | client.executions.create({ | ||
| 306 | path: { | ||
| 307 | workspaceId: installation.workspaceId, | ||
| 308 | }, | ||
| 309 | payload: { | ||
| 310 | code: 'return await tools.source.form.gated_echo({ value: "from-control-plane" });', | ||
| 311 | }, | ||
| 312 | }), | ||
| 313 | )) as { | ||
| 314 | id: string; | ||
| 315 | status: string; | ||
| 316 | }; | ||
| 317 | |||
| 318 | expect(created.status).toBe("waiting_for_interaction"); | ||
| 319 | |||
| 320 | const pendingInteraction = yield* runtime.persistence.rows.executionInteractions.getPendingByExecutionId( | ||
| 321 | created.id as never, | ||
| 322 | ); | ||
| 323 | |||
| 324 | expect(pendingInteraction._tag).toBe("Some"); | ||
| 325 | if (pendingInteraction._tag === "Some") { | ||
| 326 | expect(pendingInteraction.value.kind).toBe("form"); | ||
| 327 | expect(pendingInteraction.value.payloadJson).toContain("Approve gated echo"); | ||
| 328 | } | ||
| 329 | |||
| 330 | const resumed = (yield* withControlPlaneClient( | ||
| 331 | { | ||
| 332 | runtime, | ||
| 333 | accountId: installation.accountId, | ||
| 334 | }, | ||
| 335 | (client) => | ||
| 336 | client.executions.resume({ | ||
| 337 | path: { | ||
| 338 | workspaceId: installation.workspaceId, | ||
| 339 | executionId: created.id as never, | ||
| 340 | }, | ||
| 341 | payload: { | ||
| 342 | responseJson: JSON.stringify({ | ||
| 343 | action: "accept", | ||
| 344 | content: { | ||
| 345 | approve: true, | ||
| 346 | }, | ||
| 347 | }), | ||
| 348 | }, | ||
| 349 | }), | ||
| 350 | )) as { | ||
| 351 | id: string; | ||
| 352 | status: string; | ||
| 353 | resultJson: string | null; | ||
| 354 | }; | ||
| 355 | |||
| 356 | expect(resumed.id).toBe(created.id); | ||
| 357 | expect(resumed.status).toBe("completed"); | ||
| 358 | expect(resumed.resultJson).toBe( | ||
| 359 | JSON.stringify({ | ||
| 360 | content: [{ type: "text", text: "approved:from-control-plane" }], | ||
| 361 | }), | ||
| 362 | ); | ||
| 363 | }), | ||
| 364 | ); | ||
| 365 | }); | ||
| @@ -0,0 +1,358 @@ | |||
| 1 | import type { ControlPlaneServiceShape } from "#api"; | ||
| 2 | import { | ||
| 3 | ControlPlaneBadRequestError, | ||
| 4 | ControlPlaneNotFoundError, | ||
| 5 | ControlPlaneStorageError, | ||
| 6 | } from "#api"; | ||
| 7 | import { | ||
| 8 | ControlPlanePersistenceError, | ||
| 9 | type SqlControlPlaneRows, | ||
| 10 | } from "#persistence"; | ||
| 11 | import type { Execution } from "#schema"; | ||
| 12 | import * as Deferred from "effect/Deferred"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | type ResolveExecutionEnvironment, | ||
| 19 | ResumeUnsupportedError, | ||
| 20 | } from "./execution-state"; | ||
| 21 | import { | ||
| 22 | makeLiveExecutionManager, | ||
| 23 | type LiveExecutionManager, | ||
| 24 | } from "./live-execution"; | ||
| 25 | |||
| 26 | const badRequest = ( | ||
| 27 | operation: string, | ||
| 28 | message: string, | ||
| 29 | details: string, | ||
| 30 | ): ControlPlaneBadRequestError => | ||
| 31 | new ControlPlaneBadRequestError({ | ||
| 32 | operation, | ||
| 33 | message, | ||
| 34 | details, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const notFound = ( | ||
| 38 | operation: string, | ||
| 39 | message: string, | ||
| 40 | details: string, | ||
| 41 | ): ControlPlaneNotFoundError => | ||
| 42 | new ControlPlaneNotFoundError({ | ||
| 43 | operation, | ||
| 44 | message, | ||
| 45 | details, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const storageFromPersistence = ( | ||
| 49 | operation: string, | ||
| 50 | error: ControlPlanePersistenceError, | ||
| 51 | ): ControlPlaneStorageError => | ||
| 52 | new ControlPlaneStorageError({ | ||
| 53 | operation, | ||
| 54 | message: error.message, | ||
| 55 | details: error.details ?? "Persistence operation failed", | ||
| 56 | }); | ||
| 57 | |||
| 58 | const mapStorageError = <A>( | ||
| 59 | operation: string, | ||
| 60 | effect: Effect.Effect<A, ControlPlanePersistenceError>, | ||
| 61 | ): Effect.Effect<A, ControlPlaneStorageError> => | ||
| 62 | effect.pipe(Effect.mapError((error) => storageFromPersistence(operation, error))); | ||
| 63 | |||
| 64 | const requireTrimmed = ( | ||
| 65 | operation: string, | ||
| 66 | fieldName: string, | ||
| 67 | value: string, | ||
| 68 | ): Effect.Effect<string, ControlPlaneBadRequestError> => { | ||
| 69 | const trimmed = value.trim(); | ||
| 70 | if (trimmed.length === 0) { | ||
| 71 | return Effect.fail( | ||
| 72 | badRequest( | ||
| 73 | operation, | ||
| 74 | `Invalid ${fieldName}`, | ||
| 75 | `${fieldName} must be a non-empty string`, | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | return Effect.succeed(trimmed); | ||
| 81 | }; | ||
| 82 | |||
| 83 | const serializeJson = (value: unknown): string | null => { | ||
| 84 | if (value === undefined) { | ||
| 85 | return null; | ||
| 86 | } | ||
| 87 | |||
| 88 | return JSON.stringify(value); | ||
| 89 | }; | ||
| 90 | |||
| 91 | const ElicitationActionSchema = Schema.Literal("accept", "decline", "cancel"); | ||
| 92 | |||
| 93 | const ElicitationResponseSchema = Schema.Struct({ | ||
| 94 | action: ElicitationActionSchema, | ||
| 95 | content: Schema.optional( | ||
| 96 | Schema.Record({ | ||
| 97 | key: Schema.String, | ||
| 98 | value: Schema.Unknown, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | }); | ||
| 102 | |||
| 103 | const decodeElicitationResponse = Schema.decodeUnknown(ElicitationResponseSchema); | ||
| 104 | |||
| 105 | const defaultExecutionResolver: ResolveExecutionEnvironment = () => | ||
| 106 | Effect.fail( | ||
| 107 | new Error("Execution environment resolver is not configured"), | ||
| 108 | ); | ||
| 109 | |||
| 110 | const defaultLiveExecutionManager = makeLiveExecutionManager(); | ||
| 111 | |||
| 112 | const fetchExecution = ( | ||
| 113 | rows: SqlControlPlaneRows, | ||
| 114 | input: { | ||
| 115 | workspaceId: Execution["workspaceId"]; | ||
| 116 | executionId: Execution["id"]; | ||
| 117 | operation: string; | ||
| 118 | }, | ||
| 119 | ): Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError> => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const existing = yield* mapStorageError( | ||
| 122 | input.operation, | ||
| 123 | rows.executions.getByWorkspaceAndId(input.workspaceId, input.executionId), | ||
| 124 | ); | ||
| 125 | |||
| 126 | if (Option.isNone(existing)) { | ||
| 127 | return yield* Effect.fail( | ||
| 128 | notFound( | ||
| 129 | input.operation, | ||
| 130 | "Execution not found", | ||
| 131 | `workspaceId=${input.workspaceId} executionId=${input.executionId}`, | ||
| 132 | ), | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | |||
| 136 | return existing.value; | ||
| 137 | }); | ||
| 138 | |||
| 139 | export const makeRuntimeExecutionsService = ( | ||
| 140 | rows: SqlControlPlaneRows, | ||
| 141 | executionResolver: ResolveExecutionEnvironment = defaultExecutionResolver, | ||
| 142 | liveExecutionManager: LiveExecutionManager = defaultLiveExecutionManager, | ||
| 143 | ): Pick< | ||
| 144 | ControlPlaneServiceShape, | ||
| 145 | | "createExecution" | ||
| 146 | | "getExecution" | ||
| 147 | | "resumeExecution" | ||
| 148 | > => ({ | ||
| 149 | createExecution: ({ workspaceId, payload, createdByAccountId }) => | ||
| 150 | (Effect.gen(function* () { | ||
| 151 | const code = yield* requireTrimmed("executions.create", "code", payload.code); | ||
| 152 | const now = Date.now(); | ||
| 153 | const execution: Execution = { | ||
| 154 | id: `exec_${crypto.randomUUID()}` as Execution["id"], | ||
| 155 | workspaceId, | ||
| 156 | createdByAccountId, | ||
| 157 | status: "pending", | ||
| 158 | code, | ||
| 159 | resultJson: null, | ||
| 160 | errorText: null, | ||
| 161 | logsJson: null, | ||
| 162 | startedAt: null, | ||
| 163 | completedAt: null, | ||
| 164 | createdAt: now, | ||
| 165 | updatedAt: now, | ||
| 166 | }; | ||
| 167 | |||
| 168 | yield* mapStorageError( | ||
| 169 | "executions.create.insert", | ||
| 170 | rows.executions.insert(execution), | ||
| 171 | ); | ||
| 172 | |||
| 173 | const running = yield* mapStorageError( | ||
| 174 | "executions.create.mark_running", | ||
| 175 | rows.executions.update(execution.id, { | ||
| 176 | status: "running", | ||
| 177 | startedAt: now, | ||
| 178 | updatedAt: now, | ||
| 179 | }), | ||
| 180 | ); | ||
| 181 | |||
| 182 | if (Option.isNone(running)) { | ||
| 183 | return yield* Effect.fail( | ||
| 184 | notFound( | ||
| 185 | "executions.create", | ||
| 186 | "Execution not found after insert", | ||
| 187 | `executionId=${execution.id}`, | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | } | ||
| 191 | |||
| 192 | const environment = yield* executionResolver({ | ||
| 193 | workspaceId, | ||
| 194 | accountId: createdByAccountId, | ||
| 195 | executionId: execution.id, | ||
| 196 | onElicitation: liveExecutionManager.createOnElicitation({ | ||
| 197 | rows, | ||
| 198 | executionId: execution.id, | ||
| 199 | }), | ||
| 200 | }).pipe( | ||
| 201 | Effect.mapError((error) => | ||
| 202 | new ControlPlaneStorageError({ | ||
| 203 | operation: "executions.create.environment", | ||
| 204 | message: error instanceof Error ? error.message : String(error), | ||
| 205 | details: "Execution environment resolution failed", | ||
| 206 | }), | ||
| 207 | ), | ||
| 208 | ); | ||
| 209 | |||
| 210 | const nextState = yield* liveExecutionManager.registerStateWaiter(execution.id); | ||
| 211 | |||
| 212 | yield* Effect.sync(() => { | ||
| 213 | Effect.runFork( | ||
| 214 | environment.executor.execute(code, environment.toolInvoker).pipe( | ||
| 215 | Effect.flatMap((outcome) => { | ||
| 216 | const completedAt = Date.now(); | ||
| 217 | return mapStorageError( | ||
| 218 | "executions.create.complete", | ||
| 219 | rows.executions.update(execution.id, { | ||
| 220 | status: outcome.error ? "failed" : "completed", | ||
| 221 | resultJson: serializeJson(outcome.result), | ||
| 222 | errorText: outcome.error ?? null, | ||
| 223 | logsJson: serializeJson(outcome.logs ?? null), | ||
| 224 | completedAt, | ||
| 225 | updatedAt: completedAt, | ||
| 226 | }), | ||
| 227 | ).pipe( | ||
| 228 | Effect.flatMap((updated) => | ||
| 229 | Option.isNone(updated) | ||
| 230 | ? Effect.fail( | ||
| 231 | notFound( | ||
| 232 | "executions.create", | ||
| 233 | "Execution not found after completion", | ||
| 234 | `executionId=${execution.id}`, | ||
| 235 | ), | ||
| 236 | ) | ||
| 237 | : Effect.succeed(updated.value), | ||
| 238 | ), | ||
| 239 | Effect.flatMap((updated) => | ||
| 240 | liveExecutionManager.finishRun({ | ||
| 241 | executionId: execution.id, | ||
| 242 | state: updated.status === "completed" ? "completed" : "failed", | ||
| 243 | }).pipe(Effect.as(updated)), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | }), | ||
| 247 | Effect.catchAll((error) => { | ||
| 248 | const completedAt = Date.now(); | ||
| 249 | return mapStorageError( | ||
| 250 | "executions.create.complete_error", | ||
| 251 | rows.executions.update(execution.id, { | ||
| 252 | status: "failed", | ||
| 253 | errorText: error instanceof Error ? error.message : String(error), | ||
| 254 | completedAt, | ||
| 255 | updatedAt: completedAt, | ||
| 256 | }), | ||
| 257 | ).pipe( | ||
| 258 | Effect.zipRight( | ||
| 259 | liveExecutionManager.finishRun({ | ||
| 260 | executionId: execution.id, | ||
| 261 | state: "failed", | ||
| 262 | }), | ||
| 263 | ), | ||
| 264 | Effect.catchAll(() => liveExecutionManager.clearRun(execution.id)), | ||
| 265 | ); | ||
| 266 | }), | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | }); | ||
| 270 | |||
| 271 | yield* Deferred.await(nextState); | ||
| 272 | |||
| 273 | return yield* fetchExecution(rows, { | ||
| 274 | workspaceId, | ||
| 275 | executionId: execution.id, | ||
| 276 | operation: "executions.create", | ||
| 277 | }); | ||
| 278 | }) as Effect.Effect< | ||
| 279 | Execution, | ||
| 280 | ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError | ||
| 281 | >), | ||
| 282 | |||
| 283 | getExecution: ({ workspaceId, executionId }) => | ||
| 284 | fetchExecution(rows, { | ||
| 285 | workspaceId, | ||
| 286 | executionId, | ||
| 287 | operation: "executions.get", | ||
| 288 | }), | ||
| 289 | |||
| 290 | resumeExecution: ({ workspaceId, executionId, payload }) => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const existing = yield* fetchExecution(rows, { | ||
| 293 | workspaceId, | ||
| 294 | executionId, | ||
| 295 | operation: "executions.resume", | ||
| 296 | }); | ||
| 297 | |||
| 298 | if (existing.status !== "waiting_for_interaction") { | ||
| 299 | return yield* Effect.fail( | ||
| 300 | badRequest( | ||
| 301 | "executions.resume", | ||
| 302 | "Execution is not waiting for interaction", | ||
| 303 | `executionId=${executionId} status=${existing.status}`, | ||
| 304 | ), | ||
| 305 | ); | ||
| 306 | } | ||
| 307 | |||
| 308 | const responseJson = payload.responseJson; | ||
| 309 | const response = | ||
| 310 | responseJson === undefined | ||
| 311 | ? { action: "accept" as const } | ||
| 312 | : yield* Effect.try({ | ||
| 313 | try: () => JSON.parse(responseJson), | ||
| 314 | catch: (error) => | ||
| 315 | badRequest( | ||
| 316 | "executions.resume", | ||
| 317 | "Invalid responseJson", | ||
| 318 | error instanceof Error ? error.message : String(error), | ||
| 319 | ), | ||
| 320 | }).pipe( | ||
| 321 | Effect.flatMap((decoded) => | ||
| 322 | decodeElicitationResponse(decoded).pipe( | ||
| 323 | Effect.mapError((error) => | ||
| 324 | badRequest( | ||
| 325 | "executions.resume", | ||
| 326 | "Invalid responseJson", | ||
| 327 | String(error), | ||
| 328 | ), | ||
| 329 | ), | ||
| 330 | ), | ||
| 331 | ), | ||
| 332 | ); | ||
| 333 | |||
| 334 | const nextState = yield* liveExecutionManager.registerStateWaiter(executionId); | ||
| 335 | const resumed = yield* liveExecutionManager.resolveInteraction({ | ||
| 336 | executionId, | ||
| 337 | response, | ||
| 338 | }); | ||
| 339 | |||
| 340 | if (!resumed) { | ||
| 341 | return yield* Effect.fail( | ||
| 342 | badRequest( | ||
| 343 | "executions.resume", | ||
| 344 | "Resume is unavailable for this execution", | ||
| 345 | `executionId=${executionId} mode=${new ResumeUnsupportedError({ executionId })._tag}`, | ||
| 346 | ), | ||
| 347 | ); | ||
| 348 | } | ||
| 349 | |||
| 350 | yield* Deferred.await(nextState); | ||
| 351 | |||
| 352 | return yield* fetchExecution(rows, { | ||
| 353 | workspaceId, | ||
| 354 | executionId, | ||
| 355 | operation: "executions.resume", | ||
| 356 | }); | ||
| 357 | }), | ||
| 358 | }); | ||
| @@ -0,0 +1,28 @@ | |||
| 1 | import type { | ||
| 2 | CodeExecutor, | ||
| 3 | OnElicitation, | ||
| 4 | ToolCatalog, | ||
| 5 | ToolInvoker, | ||
| 6 | } from "@executor-v3/codemode-core"; | ||
| 7 | import type { AccountId, ExecutionId, WorkspaceId } from "#schema"; | ||
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import type * as Effect from "effect/Effect"; | ||
| 10 | |||
| 11 | export type ExecutionEnvironment = { | ||
| 12 | executor: CodeExecutor; | ||
| 13 | toolInvoker: ToolInvoker; | ||
| 14 | catalog?: ToolCatalog; | ||
| 15 | }; | ||
| 16 | |||
| 17 | export type ResolveExecutionEnvironment = (input: { | ||
| 18 | workspaceId: WorkspaceId; | ||
| 19 | accountId: AccountId; | ||
| 20 | executionId: ExecutionId; | ||
| 21 | onElicitation?: OnElicitation; | ||
| 22 | }) => Effect.Effect<ExecutionEnvironment, unknown>; | ||
| 23 | |||
| 24 | export class ResumeUnsupportedError extends Data.TaggedError( | ||
| 25 | "ResumeUnsupportedError", | ||
| 26 | )<{ | ||
| 27 | executionId: ExecutionId; | ||
| 28 | }> {} | ||
| @@ -24,6 +24,16 @@ import { · 23 unmodified lines | |||
| 24 | makeSecretStore, | 24 | makeSecretStore, |
| 25 | type SecretStore, | 25 | type SecretStore, |
| 26 | } from "./secret-store"; | 26 | } from "./secret-store"; |
| 27 | import type { LocalInstallation } from "#schema"; | ||
| 28 | import { | ||
| 29 | getOrProvisionLocalInstallation, | ||
| 30 | } from "./local-installation"; | ||
| 31 | import { | ||
| 32 | type ResolveExecutionEnvironment, | ||
| 33 | } from "./execution-state"; | ||
| 34 | import { | ||
| 35 | makeLiveExecutionManager, | ||
| 36 | } from "./live-execution"; | ||
| 27 | import { makeRuntimeControlPlaneService } from "./services"; | 37 | import { makeRuntimeControlPlaneService } from "./services"; |
| 28 | 38 | ||
| 29 | export { | 39 | export { |
| @@ -35,12 +45,16 @@ export { · 5 unmodified lines | |||
| 35 | }; | 45 | }; |
| 36 | 46 | ||
| 37 | export type { SecretHandle, SecretProvider, SecretStore } from "./secret-store"; | 47 | export type { SecretHandle, SecretProvider, SecretStore } from "./secret-store"; |
| 48 | export * from "./execution-state"; | ||
| 49 | export * from "./live-execution"; | ||
| 50 | export * from "./local-installation"; | ||
| 38 | export * from "./source-runtime"; | 51 | export * from "./source-runtime"; |
| 39 | 52 | ||
| 40 | export type RuntimeControlPlaneInput = { | 53 | export type RuntimeControlPlaneInput = { |
| 41 | persistence: SqlControlPlanePersistence; | 54 | persistence: SqlControlPlanePersistence; |
| 42 | actorResolver?: ControlPlaneActorResolverShape; | 55 | actorResolver?: ControlPlaneActorResolverShape; |
| 43 | secretStore?: SecretStore; | 56 | secretStore?: SecretStore; |
| 57 | executionResolver?: ResolveExecutionEnvironment; | ||
| 44 | }; | 58 | }; |
| 45 | 59 | ||
| 46 | export const makeRuntimeControlPlane = ( | 60 | export const makeRuntimeControlPlane = ( |
| @@ -51,7 +65,11 @@ export const makeRuntimeControlPlane = ( · 4 unmodified lines | |||
| 51 | secretStore: SecretStore; | 65 | secretStore: SecretStore; |
| 52 | webHandler: ReturnType<typeof makeControlPlaneWebHandler>; | 66 | webHandler: ReturnType<typeof makeControlPlaneWebHandler>; |
| 53 | } => { | 67 | } => { |
| 54 | const service = makeRuntimeControlPlaneService(input.persistence.rows); | 68 | const liveExecutionManager = makeLiveExecutionManager(); |
| 69 | const service = makeRuntimeControlPlaneService(input.persistence.rows, { | ||
| 70 | executionResolver: input.executionResolver, | ||
| 71 | liveExecutionManager, | ||
| 72 | }); | ||
| 55 | const actorResolver = input.actorResolver ?? makeHeaderActorResolver(input.persistence.rows); | 73 | const actorResolver = input.actorResolver ?? makeHeaderActorResolver(input.persistence.rows); |
| 56 | const secretStore = | 74 | const secretStore = |
| 57 | input.secretStore | 75 | input.secretStore |
| @@ -75,6 +93,7 @@ export const makeRuntimeControlPlane = ( · 17 unmodified lines | |||
| 75 | 93 | ||
| 76 | export type SqlControlPlaneRuntime = { | 94 | export type SqlControlPlaneRuntime = { |
| 77 | persistence: SqlControlPlanePersistence; | 95 | persistence: SqlControlPlanePersistence; |
| 96 | localInstallation: LocalInstallation; | ||
| 78 | service: ControlPlaneServiceShape; | 97 | service: ControlPlaneServiceShape; |
| 79 | actorResolver: ControlPlaneActorResolverShape; | 98 | actorResolver: ControlPlaneActorResolverShape; |
| 80 | secretStore: SecretStore; | 99 | secretStore: SecretStore; |
| @@ -85,24 +104,39 @@ export type SqlControlPlaneRuntime = { · 4 unmodified lines | |||
| 85 | export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions & { | 104 | export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions & { |
| 86 | secretStore?: SecretStore; | 105 | secretStore?: SecretStore; |
| 87 | actorResolver?: ControlPlaneActorResolverShape; | 106 | actorResolver?: ControlPlaneActorResolverShape; |
| 107 | executionResolver?: ResolveExecutionEnvironment; | ||
| 88 | }; | 108 | }; |
| 89 | 109 | ||
| 90 | export const makeSqlControlPlaneRuntime = ( | 110 | export const makeSqlControlPlaneRuntime = ( |
| 91 | options: CreateSqlControlPlaneRuntimeOptions, | 111 | options: CreateSqlControlPlaneRuntimeOptions, |
| 92 | ): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> => | 112 | ): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> => |
| 93 | Effect.map(makeSqlControlPlanePersistence(options), (persistence) => { | 113 | Effect.flatMap(makeSqlControlPlanePersistence(options), (persistence) => |
| 94 | const runtime = makeRuntimeControlPlane({ | 114 | Effect.gen(function* () { |
| 95 | persistence, | 115 | const runtime = makeRuntimeControlPlane({ |
| 96 | actorResolver: options.actorResolver, | 116 | persistence, |
| 97 | secretStore: options.secretStore, | 117 | actorResolver: options.actorResolver, |
| 98 | }); | 118 | secretStore: options.secretStore, |
| 119 | executionResolver: options.executionResolver, | ||
| 120 | }); | ||
| 121 | const localInstallation = yield* getOrProvisionLocalInstallation(persistence.rows).pipe( | ||
| 122 | Effect.mapError((cause) => | ||
| 123 | new SqlPersistenceBootstrapError({ | ||
| 124 | message: `Failed provisioning local installation: ${ | ||
| 125 | cause instanceof Error ? cause.message : String(cause) | ||
| 126 | }`, | ||
| 127 | details: cause instanceof Error ? cause.message : String(cause), | ||
| 128 | }), | ||
| 129 | ), | ||
| 130 | ); | ||
| 99 | 131 | ||
| 100 | return { | 132 | return { |
| 101 | persistence, | 133 | persistence, |
| 102 | service: runtime.service, | 134 | localInstallation, |
| 103 | actorResolver: runtime.actorResolver, | 135 | service: runtime.service, |
| 104 | secretStore: runtime.secretStore, | 136 | actorResolver: runtime.actorResolver, |
| 105 | webHandler: runtime.webHandler, | 137 | secretStore: runtime.secretStore, |
| 106 | close: () => persistence.close(), | 138 | webHandler: runtime.webHandler, |
| 107 | }; | 139 | close: () => persistence.close(), |
| 108 | }); | 140 | }; |
| 141 | }) | ||
| 142 | ); | ||
| @@ -0,0 +1,177 @@ | |||
| 1 | import type { | ||
| 2 | ElicitationResponse, | ||
| 3 | OnElicitation, | ||
| 4 | } from "@executor-v3/codemode-core"; | ||
| 5 | import { type SqlControlPlaneRows } from "#persistence"; | ||
| 6 | import type { | ||
| 7 | Execution, | ||
| 8 | ExecutionInteraction, | ||
| 9 | } from "#schema"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | type VisibleExecutionState = | ||
| 14 | | "waiting_for_interaction" | ||
| 15 | | "completed" | ||
| 16 | | "failed"; | ||
| 17 | |||
| 18 | type LiveRunEntry = { | ||
| 19 | stateWaiters: Array<Deferred.Deferred<VisibleExecutionState>>; | ||
| 20 | currentInteraction: { | ||
| 21 | interactionId: ExecutionInteraction["id"]; | ||
| 22 | response: Deferred.Deferred<ElicitationResponse>; | ||
| 23 | } | null; | ||
| 24 | }; | ||
| 25 | |||
| 26 | export type LiveExecutionManager = { | ||
| 27 | registerStateWaiter: ( | ||
| 28 | executionId: Execution["id"], | ||
| 29 | ) => Effect.Effect<Deferred.Deferred<VisibleExecutionState>>; | ||
| 30 | createOnElicitation: (input: { | ||
| 31 | rows: SqlControlPlaneRows; | ||
| 32 | executionId: Execution["id"]; | ||
| 33 | }) => OnElicitation; | ||
| 34 | resolveInteraction: (input: { | ||
| 35 | executionId: Execution["id"]; | ||
| 36 | response: ElicitationResponse; | ||
| 37 | }) => Effect.Effect<boolean>; | ||
| 38 | finishRun: (input: { | ||
| 39 | executionId: Execution["id"]; | ||
| 40 | state: Extract<VisibleExecutionState, "completed" | "failed">; | ||
| 41 | }) => Effect.Effect<void>; | ||
| 42 | clearRun: (executionId: Execution["id"]) => Effect.Effect<void>; | ||
| 43 | }; | ||
| 44 | |||
| 45 | const createEmptyRun = (): LiveRunEntry => ({ | ||
| 46 | stateWaiters: [], | ||
| 47 | currentInteraction: null, | ||
| 48 | }); | ||
| 49 | |||
| 50 | const serializeJson = (value: unknown): string | null => { | ||
| 51 | if (value === undefined) { | ||
| 52 | return null; | ||
| 53 | } | ||
| 54 | |||
| 55 | return JSON.stringify(value); | ||
| 56 | }; | ||
| 57 | |||
| 58 | export const makeLiveExecutionManager = (): LiveExecutionManager => { | ||
| 59 | const runs = new Map<Execution["id"], LiveRunEntry>(); | ||
| 60 | |||
| 61 | const getOrCreateRun = (executionId: Execution["id"]): LiveRunEntry => { | ||
| 62 | const existing = runs.get(executionId); | ||
| 63 | if (existing) { | ||
| 64 | return existing; | ||
| 65 | } | ||
| 66 | |||
| 67 | const created = createEmptyRun(); | ||
| 68 | runs.set(executionId, created); | ||
| 69 | return created; | ||
| 70 | }; | ||
| 71 | |||
| 72 | const publishState = (input: { | ||
| 73 | executionId: Execution["id"]; | ||
| 74 | state: VisibleExecutionState; | ||
| 75 | }): Effect.Effect<void> => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const run = getOrCreateRun(input.executionId); | ||
| 78 | const waiters = [...run.stateWaiters]; | ||
| 79 | run.stateWaiters = []; | ||
| 80 | |||
| 81 | yield* Effect.forEach(waiters, (waiter) => Deferred.succeed(waiter, input.state), { | ||
| 82 | discard: true, | ||
| 83 | }); | ||
| 84 | }); | ||
| 85 | |||
| 86 | return { | ||
| 87 | registerStateWaiter: (executionId) => | ||
| 88 | Effect.gen(function* () { | ||
| 89 | const waiter = yield* Deferred.make<VisibleExecutionState>(); | ||
| 90 | const run = getOrCreateRun(executionId); | ||
| 91 | run.stateWaiters.push(waiter); | ||
| 92 | return waiter; | ||
| 93 | }), | ||
| 94 | |||
| 95 | createOnElicitation: | ||
| 96 | ({ rows, executionId }) => | ||
| 97 | (input) => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const run = getOrCreateRun(executionId); | ||
| 100 | const response = yield* Deferred.make<ElicitationResponse>(); | ||
| 101 | const now = Date.now(); | ||
| 102 | const interaction: ExecutionInteraction = { | ||
| 103 | id: input.interactionId as ExecutionInteraction["id"], | ||
| 104 | executionId, | ||
| 105 | status: "pending", | ||
| 106 | kind: input.elicitation.mode === "url" ? "url" : "form", | ||
| 107 | payloadJson: | ||
| 108 | serializeJson({ | ||
| 109 | path: input.path, | ||
| 110 | sourceKey: input.sourceKey, | ||
| 111 | args: input.args, | ||
| 112 | context: input.context, | ||
| 113 | elicitation: input.elicitation, | ||
| 114 | }) ?? "{}", | ||
| 115 | responseJson: null, | ||
| 116 | createdAt: now, | ||
| 117 | updatedAt: now, | ||
| 118 | }; | ||
| 119 | |||
| 120 | yield* rows.executionInteractions.insert(interaction); | ||
| 121 | yield* rows.executions.update(executionId, { | ||
| 122 | status: "waiting_for_interaction", | ||
| 123 | updatedAt: now, | ||
| 124 | }); | ||
| 125 | |||
| 126 | run.currentInteraction = { | ||
| 127 | interactionId: interaction.id, | ||
| 128 | response, | ||
| 129 | }; | ||
| 130 | |||
| 131 | yield* publishState({ | ||
| 132 | executionId, | ||
| 133 | state: "waiting_for_interaction", | ||
| 134 | }); | ||
| 135 | |||
| 136 | const resolved = yield* Deferred.await(response); | ||
| 137 | const resolvedAt = Date.now(); | ||
| 138 | |||
| 139 | yield* rows.executionInteractions.update(interaction.id, { | ||
| 140 | status: resolved.action === "cancel" ? "cancelled" : "resolved", | ||
| 141 | responseJson: serializeJson(resolved), | ||
| 142 | updatedAt: resolvedAt, | ||
| 143 | }); | ||
| 144 | yield* rows.executions.update(executionId, { | ||
| 145 | status: "running", | ||
| 146 | updatedAt: resolvedAt, | ||
| 147 | }); | ||
| 148 | |||
| 149 | run.currentInteraction = null; | ||
| 150 | return resolved; | ||
| 151 | }), | ||
| 152 | |||
| 153 | resolveInteraction: ({ executionId, response }) => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const run = runs.get(executionId); | ||
| 156 | const pending = run?.currentInteraction; | ||
| 157 | if (!pending) { | ||
| 158 | return false; | ||
| 159 | } | ||
| 160 | |||
| 161 | yield* Deferred.succeed(pending.response, response); | ||
| 162 | return true; | ||
| 163 | }), | ||
| 164 | |||
| 165 | finishRun: ({ executionId, state }) => | ||
| 166 | publishState({ executionId, state }).pipe( | ||
| 167 | Effect.zipRight(Effect.sync(() => { | ||
| 168 | runs.delete(executionId); | ||
| 169 | })), | ||
| 170 | ), | ||
| 171 | |||
| 172 | clearRun: (executionId) => | ||
| 173 | Effect.sync(() => { | ||
| 174 | runs.delete(executionId); | ||
| 175 | }), | ||
| 176 | }; | ||
| 177 | }; | ||
| @@ -0,0 +1,52 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | import { makeSqlControlPlaneRuntime } from "./index"; | ||
| 5 | import { getOrProvisionLocalInstallation } from "./local-installation"; | ||
| 6 | |||
| 7 | const makeRuntime = Effect.acquireRelease( | ||
| 8 | makeSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | ||
| 9 | (runtime) => | ||
| 10 | Effect.tryPromise({ | ||
| 11 | try: async () => { | ||
| 12 | await runtime.close(); | ||
| 13 | await runtime.webHandler.dispose(); | ||
| 14 | }, | ||
| 15 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | ||
| 16 | }).pipe(Effect.orDie), | ||
| 17 | ); | ||
| 18 | |||
| 19 | describe("local-installation", () => { | ||
| 20 | it.scoped("provisions a local account, organization, and workspace on first boot", () => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const runtime = yield* makeRuntime; | ||
| 23 | const installation = runtime.localInstallation; | ||
| 24 | |||
| 25 | const account = yield* runtime.persistence.rows.accounts.getById(installation.accountId); | ||
| 26 | const organization = yield* runtime.persistence.rows.organizations.getById( | ||
| 27 | installation.organizationId, | ||
| 28 | ); | ||
| 29 | const workspace = yield* runtime.persistence.rows.workspaces.getById( | ||
| 30 | installation.workspaceId, | ||
| 31 | ); | ||
| 32 | |||
| 33 | expect(account._tag).toBe("Some"); | ||
| 34 | expect(organization._tag).toBe("Some"); | ||
| 35 | expect(workspace._tag).toBe("Some"); | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | |||
| 39 | it.scoped("is idempotent when loading the default local installation", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const runtime = yield* makeRuntime; | ||
| 42 | |||
| 43 | const first = runtime.localInstallation; | ||
| 44 | const second = yield* getOrProvisionLocalInstallation(runtime.persistence.rows); | ||
| 45 | |||
| 46 | expect(second.id).toBe(first.id); | ||
| 47 | expect(second.accountId).toBe(first.accountId); | ||
| 48 | expect(second.organizationId).toBe(first.organizationId); | ||
| 49 | expect(second.workspaceId).toBe(first.workspaceId); | ||
| 50 | }), | ||
| 51 | ); | ||
| 52 | }); | ||
| @@ -0,0 +1,114 @@ | |||
| 1 | import { type SqlControlPlaneRows } from "#persistence"; | ||
| 2 | import type { | ||
| 3 | Account, | ||
| 4 | LocalInstallation, | ||
| 5 | Organization, | ||
| 6 | OrganizationMembership, | ||
| 7 | Workspace, | ||
| 8 | } from "#schema"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | |||
| 12 | const LOCAL_INSTALLATION_ID = "local_default" as LocalInstallation["id"]; | ||
| 13 | |||
| 14 | const buildAccount = (now: number): Account => { | ||
| 15 | const id = `acc_${crypto.randomUUID()}` as Account["id"]; | ||
| 16 | |||
| 17 | return { | ||
| 18 | id, | ||
| 19 | provider: "local", | ||
| 20 | subject: `local:${id}`, | ||
| 21 | email: null, | ||
| 22 | displayName: "Local User", | ||
| 23 | createdAt: now, | ||
| 24 | updatedAt: now, | ||
| 25 | }; | ||
| 26 | }; | ||
| 27 | |||
| 28 | const buildOrganization = ( | ||
| 29 | accountId: Account["id"], | ||
| 30 | now: number, | ||
| 31 | ): Organization => ({ | ||
| 32 | id: `org_${crypto.randomUUID()}` as Organization["id"], | ||
| 33 | slug: "personal", | ||
| 34 | name: "Personal", | ||
| 35 | status: "active", | ||
| 36 | createdByAccountId: accountId, | ||
| 37 | createdAt: now, | ||
| 38 | updatedAt: now, | ||
| 39 | }); | ||
| 40 | |||
| 41 | const buildOwnerMembership = ( | ||
| 42 | organizationId: Organization["id"], | ||
| 43 | accountId: Account["id"], | ||
| 44 | now: number, | ||
| 45 | ): OrganizationMembership => ({ | ||
| 46 | id: `org_mem_${crypto.randomUUID()}` as OrganizationMembership["id"], | ||
| 47 | organizationId, | ||
| 48 | accountId, | ||
| 49 | role: "owner", | ||
| 50 | status: "active", | ||
| 51 | billable: true, | ||
| 52 | invitedByAccountId: null, | ||
| 53 | joinedAt: now, | ||
| 54 | createdAt: now, | ||
| 55 | updatedAt: now, | ||
| 56 | }); | ||
| 57 | |||
| 58 | const buildWorkspace = ( | ||
| 59 | organizationId: Organization["id"], | ||
| 60 | accountId: Account["id"], | ||
| 61 | now: number, | ||
| 62 | ): Workspace => ({ | ||
| 63 | id: `ws_${crypto.randomUUID()}` as Workspace["id"], | ||
| 64 | organizationId, | ||
| 65 | name: "Default", | ||
| 66 | createdByAccountId: accountId, | ||
| 67 | createdAt: now, | ||
| 68 | updatedAt: now, | ||
| 69 | }); | ||
| 70 | |||
| 71 | export const loadLocalInstallation = ( | ||
| 72 | rows: SqlControlPlaneRows, | ||
| 73 | ): Effect.Effect<LocalInstallation | null, unknown> => | ||
| 74 | rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe( | ||
| 75 | Effect.map((result) => (Option.isSome(result) ? result.value : null)), | ||
| 76 | ); | ||
| 77 | |||
| 78 | export const provisionLocalInstallation = ( | ||
| 79 | rows: SqlControlPlaneRows, | ||
| 80 | ): Effect.Effect<LocalInstallation, unknown> => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const existing = yield* rows.localInstallations.getById(LOCAL_INSTALLATION_ID); | ||
| 83 | if (Option.isSome(existing)) { | ||
| 84 | return existing.value; | ||
| 85 | } | ||
| 86 | |||
| 87 | const now = Date.now(); | ||
| 88 | const account = buildAccount(now); | ||
| 89 | const organization = buildOrganization(account.id, now); | ||
| 90 | const membership = buildOwnerMembership(organization.id, account.id, now); | ||
| 91 | const workspace = buildWorkspace(organization.id, account.id, now); | ||
| 92 | const installation: LocalInstallation = { | ||
| 93 | id: LOCAL_INSTALLATION_ID, | ||
| 94 | accountId: account.id, | ||
| 95 | organizationId: organization.id, | ||
| 96 | workspaceId: workspace.id, | ||
| 97 | createdAt: now, | ||
| 98 | updatedAt: now, | ||
| 99 | }; | ||
| 100 | |||
| 101 | yield* rows.accounts.upsert(account); | ||
| 102 | yield* rows.organizations.insertWithOwnerMembership(organization, membership); | ||
| 103 | yield* rows.workspaces.insert(workspace); | ||
| 104 | yield* rows.localInstallations.upsert(installation); | ||
| 105 | |||
| 106 | return installation; | ||
| 107 | }); | ||
| 108 | |||
| 109 | export const getOrProvisionLocalInstallation = ( | ||
| 110 | rows: SqlControlPlaneRows, | ||
| 111 | ): Effect.Effect<LocalInstallation, unknown> => | ||
| 112 | Effect.flatMap(loadLocalInstallation(rows), (existing) => | ||
| 113 | existing ? Effect.succeed(existing) : provisionLocalInstallation(rows) | ||
| 114 | ); | ||
| @@ -18,6 +18,9 @@ import type { · 17 unmodified lines | |||
| 18 | import * as Effect from "effect/Effect"; | 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| 20 | 20 | ||
| 21 | import { type ResolveExecutionEnvironment } from "./execution-state"; | ||
| 22 | import { type LiveExecutionManager } from "./live-execution"; | ||
| 23 | import { makeRuntimeExecutionsService } from "./execution-service"; | ||
| 21 | import { slugify } from "./slug"; | 24 | import { slugify } from "./slug"; |
| 22 | 25 | ||
| 23 | const badRequest = ( | 26 | const badRequest = ( |
| @@ -857,10 +860,19 @@ export const makeRuntimePoliciesService = ( · 833 unmodified lines | |||
| 857 | 860 | ||
| 858 | export const makeRuntimeControlPlaneService = ( | 861 | export const makeRuntimeControlPlaneService = ( |
| 859 | rows: SqlControlPlaneRows, | 862 | rows: SqlControlPlaneRows, |
| 863 | options: { | ||
| 864 | executionResolver?: ResolveExecutionEnvironment; | ||
| 865 | liveExecutionManager?: LiveExecutionManager; | ||
| 866 | } = {}, | ||
| 860 | ): ControlPlaneServiceShape => ({ | 867 | ): ControlPlaneServiceShape => ({ |
| 861 | ...makeRuntimeOrganizationsService(rows), | 868 | ...makeRuntimeOrganizationsService(rows), |
| 862 | ...makeRuntimeMembershipsService(rows), | 869 | ...makeRuntimeMembershipsService(rows), |
| 863 | ...makeRuntimeWorkspacesService(rows), | 870 | ...makeRuntimeWorkspacesService(rows), |
| 864 | ...makeRuntimeSourcesService(rows), | 871 | ...makeRuntimeSourcesService(rows), |
| 865 | ...makeRuntimePoliciesService(rows), | 872 | ...makeRuntimePoliciesService(rows), |
| 873 | ...makeRuntimeExecutionsService( | ||
| 874 | rows, | ||
| 875 | options.executionResolver, | ||
| 876 | options.liveExecutionManager, | ||
| 877 | ), | ||
| 866 | }); | 878 | }); |
| @@ -0,0 +1,64 @@ | |||
| 1 | import { | ||
| 2 | HttpApiBuilder, | ||
| 3 | HttpApiClient, | ||
| 4 | HttpClient, | ||
| 5 | HttpClientRequest, | ||
| 6 | } from "@effect/platform"; | ||
| 7 | import { NodeHttpServer } from "@effect/platform-node"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | ControlPlaneActorResolver, | ||
| 13 | ControlPlaneApi, | ||
| 14 | ControlPlaneApiLive, | ||
| 15 | ControlPlaneService, | ||
| 16 | } from "#api"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | ControlPlaneAuthHeaders, | ||
| 20 | type SqlControlPlaneRuntime, | ||
| 21 | } from "./index"; | ||
| 22 | |||
| 23 | const makeClientLayer = (runtime: SqlControlPlaneRuntime) => { | ||
| 24 | const serviceLayer = Layer.succeed(ControlPlaneService, runtime.service); | ||
| 25 | const actorResolverLayer = Layer.succeed( | ||
| 26 | ControlPlaneActorResolver, | ||
| 27 | runtime.actorResolver, | ||
| 28 | ); | ||
| 29 | const apiLayer = ControlPlaneApiLive.pipe( | ||
| 30 | Layer.provide(serviceLayer), | ||
| 31 | Layer.provide(actorResolverLayer), | ||
| 32 | ); | ||
| 33 | |||
| 34 | return HttpApiBuilder.serve().pipe( | ||
| 35 | Layer.provide(apiLayer), | ||
| 36 | Layer.provideMerge(NodeHttpServer.layerTest), | ||
| 37 | ); | ||
| 38 | }; | ||
| 39 | |||
| 40 | export const withControlPlaneClient = <A, E>( | ||
| 41 | input: { | ||
| 42 | runtime: SqlControlPlaneRuntime; | ||
| 43 | accountId?: string; | ||
| 44 | }, | ||
| 45 | f: (client: any) => Effect.Effect<A, E, never>, | ||
| 46 | ): Effect.Effect<A, E | unknown, never> => | ||
| 47 | Effect.gen(function* () { | ||
| 48 | const accountId = input.accountId; | ||
| 49 | const client = yield* HttpApiClient.make(ControlPlaneApi, { | ||
| 50 | transformClient: accountId | ||
| 51 | ? (client) => | ||
| 52 | client.pipe( | ||
| 53 | HttpClient.mapRequest( | ||
| 54 | HttpClientRequest.setHeader( | ||
| 55 | ControlPlaneAuthHeaders.accountId, | ||
| 56 | accountId, | ||
| 57 | ), | ||
| 58 | ), | ||
| 59 | ) | ||
| 60 | : undefined, | ||
| 61 | }); | ||
| 62 | |||
| 63 | return yield* f(client); | ||
| 64 | }).pipe(Effect.provide(makeClientLayer(input.runtime))); | ||
| @@ -8,6 +8,11 @@ export const OrganizationMemberIdSchema = Schema.String.pipe( · 7 unmodified lines | |||
| 8 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); | 8 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); |
| 9 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); | 9 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); |
| 10 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); | 10 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); |
| 11 | export const InstallationIdSchema = Schema.String.pipe(Schema.brand("InstallationId")); | ||
| 12 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); | ||
| 13 | export const ExecutionInteractionIdSchema = Schema.String.pipe( | ||
| 14 | Schema.brand("ExecutionInteractionId"), | ||
| 15 | ); | ||
| 11 | 16 | ||
| 12 | export type AccountId = typeof AccountIdSchema.Type; | 17 | export type AccountId = typeof AccountIdSchema.Type; |
| 13 | export type OrganizationId = typeof OrganizationIdSchema.Type; | 18 | export type OrganizationId = typeof OrganizationIdSchema.Type; |
| @@ -15,3 +20,6 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 1 unmodified line | |||
| 15 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; | 20 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 16 | export type SourceId = typeof SourceIdSchema.Type; | 21 | export type SourceId = typeof SourceIdSchema.Type; |
| 17 | export type PolicyId = typeof PolicyIdSchema.Type; | 22 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 23 | export type InstallationId = typeof InstallationIdSchema.Type; | ||
| 24 | export type ExecutionId = typeof ExecutionIdSchema.Type; | ||
| 25 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; | ||
| @@ -6,3 +6,5 @@ export * from "./models/auth"; · 5 unmodified lines | |||
| 6 | export * from "./models/workspace"; | 6 | export * from "./models/workspace"; |
| 7 | export * from "./models/source"; | 7 | export * from "./models/source"; |
| 8 | export * from "./models/policy"; | 8 | export * from "./models/policy"; |
| 9 | export * from "./models/local-installation"; | ||
| 10 | export * from "./models/execution"; | ||
| @@ -0,0 +1,34 @@ | |||
| 1 | import { | ||
| 2 | createInsertSchema, | ||
| 3 | createSelectSchema, | ||
| 4 | createUpdateSchema, | ||
| 5 | } from "drizzle-orm/effect-schema"; | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | |||
| 8 | import { accountsTable } from "../../../persistence/schema"; | ||
| 9 | import { TimestampMsSchema } from "../../common"; | ||
| 10 | import { AccountIdSchema } from "../../ids"; | ||
| 11 | import { PrincipalProviderSchema } from "./principal"; | ||
| 12 | |||
| 13 | const accountSchemaOverrides = { | ||
| 14 | id: AccountIdSchema, | ||
| 15 | provider: PrincipalProviderSchema, | ||
| 16 | email: Schema.NullOr(Schema.String), | ||
| 17 | displayName: Schema.NullOr(Schema.String), | ||
| 18 | createdAt: TimestampMsSchema, | ||
| 19 | updatedAt: TimestampMsSchema, | ||
| 20 | } as const; | ||
| 21 | |||
| 22 | export const AccountSchema = createSelectSchema(accountsTable, accountSchemaOverrides); | ||
| 23 | |||
| 24 | export const AccountInsertSchema = createInsertSchema( | ||
| 25 | accountsTable, | ||
| 26 | accountSchemaOverrides, | ||
| 27 | ); | ||
| 28 | |||
| 29 | export const AccountUpdateSchema = createUpdateSchema( | ||
| 30 | accountsTable, | ||
| 31 | accountSchemaOverrides, | ||
| 32 | ); | ||
| 33 | |||
| 34 | export type Account = typeof AccountSchema.Type; | ||
| @@ -1,3 +1,4 @@ | |||
| 1 | export * from "./account"; | ||
| 1 | export * from "./membership"; | 2 | export * from "./membership"; |
| 2 | export * from "./organization"; | 3 | export * from "./organization"; |
| 3 | export * from "./organization-membership"; | 4 | export * from "./organization-membership"; |
| @@ -0,0 +1,91 @@ | |||
| 1 | import { | ||
| 2 | createInsertSchema, | ||
| 3 | createSelectSchema, | ||
| 4 | createUpdateSchema, | ||
| 5 | } from "drizzle-orm/effect-schema"; | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | executionInteractionsTable, | ||
| 10 | executionsTable, | ||
| 11 | } from "../../persistence/schema"; | ||
| 12 | import { TimestampMsSchema } from "../common"; | ||
| 13 | import { | ||
| 14 | AccountIdSchema, | ||
| 15 | ExecutionIdSchema, | ||
| 16 | ExecutionInteractionIdSchema, | ||
| 17 | WorkspaceIdSchema, | ||
| 18 | } from "../ids"; | ||
| 19 | |||
| 20 | export const ExecutionStatusSchema = Schema.Literal( | ||
| 21 | "pending", | ||
| 22 | "running", | ||
| 23 | "waiting_for_interaction", | ||
| 24 | "completed", | ||
| 25 | "failed", | ||
| 26 | "cancelled", | ||
| 27 | ); | ||
| 28 | |||
| 29 | const executionSchemaOverrides = { | ||
| 30 | id: ExecutionIdSchema, | ||
| 31 | workspaceId: WorkspaceIdSchema, | ||
| 32 | createdByAccountId: AccountIdSchema, | ||
| 33 | status: ExecutionStatusSchema, | ||
| 34 | resultJson: Schema.NullOr(Schema.String), | ||
| 35 | errorText: Schema.NullOr(Schema.String), | ||
| 36 | logsJson: Schema.NullOr(Schema.String), | ||
| 37 | startedAt: Schema.NullOr(TimestampMsSchema), | ||
| 38 | completedAt: Schema.NullOr(TimestampMsSchema), | ||
| 39 | createdAt: TimestampMsSchema, | ||
| 40 | updatedAt: TimestampMsSchema, | ||
| 41 | } as const; | ||
| 42 | |||
| 43 | export const ExecutionSchema = createSelectSchema( | ||
| 44 | executionsTable, | ||
| 45 | executionSchemaOverrides, | ||
| 46 | ); | ||
| 47 | |||
| 48 | export const ExecutionInsertSchema = createInsertSchema( | ||
| 49 | executionsTable, | ||
| 50 | executionSchemaOverrides, | ||
| 51 | ); | ||
| 52 | |||
| 53 | export const ExecutionUpdateSchema = createUpdateSchema( | ||
| 54 | executionsTable, | ||
| 55 | executionSchemaOverrides, | ||
| 56 | ); | ||
| 57 | |||
| 58 | export const ExecutionInteractionStatusSchema = Schema.Literal( | ||
| 59 | "pending", | ||
| 60 | "resolved", | ||
| 61 | "cancelled", | ||
| 62 | ); | ||
| 63 | |||
| 64 | const executionInteractionSchemaOverrides = { | ||
| 65 | id: ExecutionInteractionIdSchema, | ||
| 66 | executionId: ExecutionIdSchema, | ||
| 67 | status: ExecutionInteractionStatusSchema, | ||
| 68 | responseJson: Schema.NullOr(Schema.String), | ||
| 69 | createdAt: TimestampMsSchema, | ||
| 70 | updatedAt: TimestampMsSchema, | ||
| 71 | } as const; | ||
| 72 | |||
| 73 | export const ExecutionInteractionSchema = createSelectSchema( | ||
| 74 | executionInteractionsTable, | ||
| 75 | executionInteractionSchemaOverrides, | ||
| 76 | ); | ||
| 77 | |||
| 78 | export const ExecutionInteractionInsertSchema = createInsertSchema( | ||
| 79 | executionInteractionsTable, | ||
| 80 | executionInteractionSchemaOverrides, | ||
| 81 | ); | ||
| 82 | |||
| 83 | export const ExecutionInteractionUpdateSchema = createUpdateSchema( | ||
| 84 | executionInteractionsTable, | ||
| 85 | executionInteractionSchemaOverrides, | ||
| 86 | ); | ||
| 87 | |||
| 88 | export type ExecutionStatus = typeof ExecutionStatusSchema.Type; | ||
| 89 | export type Execution = typeof ExecutionSchema.Type; | ||
| 90 | export type ExecutionInteractionStatus = typeof ExecutionInteractionStatusSchema.Type; | ||
| 91 | export type ExecutionInteraction = typeof ExecutionInteractionSchema.Type; | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | import { | ||
| 2 | createInsertSchema, | ||
| 3 | createSelectSchema, | ||
| 4 | createUpdateSchema, | ||
| 5 | } from "drizzle-orm/effect-schema"; | ||
| 6 | |||
| 7 | import { localInstallationsTable } from "../../persistence/schema"; | ||
| 8 | import { TimestampMsSchema } from "../common"; | ||
| 9 | import { | ||
| 10 | AccountIdSchema, | ||
| 11 | InstallationIdSchema, | ||
| 12 | OrganizationIdSchema, | ||
| 13 | WorkspaceIdSchema, | ||
| 14 | } from "../ids"; | ||
| 15 | |||
| 16 | const localInstallationSchemaOverrides = { | ||
| 17 | id: InstallationIdSchema, | ||
| 18 | accountId: AccountIdSchema, | ||
| 19 | organizationId: OrganizationIdSchema, | ||
| 20 | workspaceId: WorkspaceIdSchema, | ||
| 21 | createdAt: TimestampMsSchema, | ||
| 22 | updatedAt: TimestampMsSchema, | ||
| 23 | } as const; | ||
| 24 | |||
| 25 | export const LocalInstallationSchema = createSelectSchema( | ||
| 26 | localInstallationsTable, | ||
| 27 | localInstallationSchemaOverrides, | ||
| 28 | ); | ||
| 29 | |||
| 30 | export const LocalInstallationInsertSchema = createInsertSchema( | ||
| 31 | localInstallationsTable, | ||
| 32 | localInstallationSchemaOverrides, | ||
| 33 | ); | ||
| 34 | |||
| 35 | export const LocalInstallationUpdateSchema = createUpdateSchema( | ||
| 36 | localInstallationsTable, | ||
| 37 | localInstallationSchemaOverrides, | ||
| 38 | ); | ||
| 39 | |||
| 40 | export type LocalInstallation = typeof LocalInstallationSchema.Type; | ||