Unified · Split
| @@ -79,10 +79,15 @@ · 78 unmodified lines | ||
| 79 | 79 | "postgres": "^3.4.7", |
| 80 | 80 | }, |
| 81 | 81 | "devDependencies": { |
| 82 | + "@effect/platform-node": "^0.104.1", | |
| 82 | 83 | "@effect/vitest": "catalog:", |
| 84 | + "@executor-v3/codemode-mcp": "workspace:*", | |
| 85 | + "@executor-v3/runtime-local-inproc": "workspace:*", | |
| 86 | + "@modelcontextprotocol/sdk": "^1.26.0", | |
| 83 | 87 | "@types/node": "catalog:", |
| 84 | 88 | "bun-types": "catalog:", |
| 85 | 89 | "vitest": "catalog:", |
| 90 | + "zod": "catalog:", | |
| 86 | 91 | }, |
| 87 | 92 | }, |
| 88 | 93 | "packages/kitchen-sink": { |
| @@ -51,10 +51,10 @@ export type ToolInteractionDecision = · 50 unmodified lines | ||
| 51 | 51 | | { kind: "execute" } |
| 52 | 52 | | { kind: "decline"; reason: string } |
| 53 | 53 | | { |
| 54 | − kind: "elicit"; | |
| 55 | − elicitation: ElicitationRequest; | |
| 56 | − interactionId?: string; | |
| 57 | − }; | |
| 54 | + kind: "elicit"; | |
| 55 | + elicitation: ElicitationRequest; | |
| 56 | + interactionId?: string; | |
| 57 | + }; | |
| 58 | 58 | |
| 59 | 59 | export type ToolElicitationRequest = { |
| 60 | 60 | interactionId: string; |
| @@ -30,9 +30,12 @@ const createEndpoint = ( · 29 unmodified lines | ||
| 30 | 30 | return url; |
| 31 | 31 | }; |
| 32 | 32 | |
| 33 | +type FetchInput = Parameters<typeof fetch>[0]; | |
| 34 | +type FetchInit = Parameters<typeof fetch>[1]; | |
| 35 | + | |
| 33 | 36 | const mergeHeadersForFetch = ( |
| 34 | − input: RequestInfo | URL, | |
| 35 | − init: RequestInit | undefined, | |
| 37 | + input: FetchInput, | |
| 38 | + init: FetchInit, | |
| 36 | 39 | headers: Record<string, string>, |
| 37 | 40 | ): Promise<Response> => { |
| 38 | 41 | const mergedHeaders = new Headers(init?.headers ?? {}); |
| @@ -84,7 +87,7 @@ export const createSdkMcpConnector = ( · 45 unmodified lines | ||
| 84 | 87 | requestInit, |
| 85 | 88 | eventSourceInit: requestInit |
| 86 | 89 | ? { |
| 87 | − fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) => | |
| 90 | + fetch: (requestInput: FetchInput, requestOptions: FetchInit) => | |
| 88 | 91 | mergeHeadersForFetch(requestInput, requestOptions, headers), |
| 89 | 92 | } |
| 90 | 93 | : undefined, |
| @@ -100,7 +103,7 @@ export const createSdkMcpConnector = ( · 9 unmodified lines | ||
| 100 | 103 | requestInit, |
| 101 | 104 | eventSourceInit: requestInit |
| 102 | 105 | ? { |
| 103 | − fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) => | |
| 106 | + fetch: (requestInput: FetchInput, requestOptions: FetchInit) => | |
| 104 | 107 | mergeHeadersForFetch(requestInput, requestOptions, headers), |
| 105 | 108 | } |
| 106 | 109 | : undefined, |
| @@ -28,7 +28,12 @@ · 27 unmodified lines | ||
| 28 | 28 | "devDependencies": { |
| 29 | 29 | "@effect/vitest": "catalog:", |
| 30 | 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 | 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 | 1 | import { HttpApi, OpenApi } from "@effect/platform"; |
| 2 | 2 | |
| 3 | +import { ExecutionsApi } from "./executions/api"; | |
| 3 | 4 | import { MembershipsApi } from "./memberships/api"; |
| 4 | 5 | import { OrganizationsApi } from "./organizations/api"; |
| 5 | 6 | import { PoliciesApi } from "./policies/api"; |
| @@ -14,6 +15,13 @@ export { · 8 unmodified lines | ||
| 14 | 15 | ControlPlaneUnauthorizedError, |
| 15 | 16 | } from "./errors"; |
| 16 | 17 | |
| 18 | +export { | |
| 19 | + CreateExecutionPayloadSchema, | |
| 20 | + ResumeExecutionPayloadSchema, | |
| 21 | + type CreateExecutionPayload, | |
| 22 | + type ResumeExecutionPayload, | |
| 23 | +} from "./executions/api"; | |
| 24 | + | |
| 17 | 25 | export { |
| 18 | 26 | CreateOrganizationPayloadSchema, |
| 19 | 27 | UpdateOrganizationPayloadSchema, |
| @@ -55,6 +63,7 @@ export class ControlPlaneApi extends HttpApi.make("controlPlane") · 35 unmodified lines | ||
| 55 | 63 | .add(WorkspacesApi) |
| 56 | 64 | .add(SourcesApi) |
| 57 | 65 | .add(PoliciesApi) |
| 66 | + .add(ExecutionsApi) | |
| 58 | 67 | .annotateContext( |
| 59 | 68 | OpenApi.annotations({ |
| 60 | 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 | 6 | ControlPlaneActorResolver, |
| 7 | 7 | type ControlPlaneActorResolverShape, |
| 8 | 8 | } from "./auth/actor-resolver"; |
| 9 | +import { ControlPlaneExecutionsLive } from "./executions/http"; | |
| 9 | 10 | import { ControlPlaneMembershipsLive } from "./memberships/http"; |
| 10 | 11 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; |
| 11 | 12 | import { ControlPlanePoliciesLive } from "./policies/http"; |
| @@ -19,6 +20,7 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines | ||
| 19 | 20 | Layer.provide(ControlPlaneWorkspacesLive), |
| 20 | 21 | Layer.provide(ControlPlaneSourcesLive), |
| 21 | 22 | Layer.provide(ControlPlanePoliciesLive), |
| 23 | + Layer.provide(ControlPlaneExecutionsLive), | |
| 22 | 24 | ); |
| 23 | 25 | |
| 24 | 26 | export const makeControlPlaneWebHandler = <EService, EResolver>( |
| @@ -49,6 +49,13 @@ export { · 48 unmodified lines | ||
| 49 | 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 | 59 | export { |
| 53 | 60 | CreateOrganizationPayloadSchema, |
| 54 | 61 | UpdateOrganizationPayloadSchema, |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | AccountId, |
| 3 | + Execution, | |
| 4 | + ExecutionId, | |
| 3 | 5 | Organization, |
| 4 | 6 | OrganizationId, |
| 5 | 7 | OrganizationMembership, |
| @@ -29,6 +31,10 @@ import type { · 23 unmodified lines | ||
| 29 | 31 | CreateSourcePayload, |
| 30 | 32 | UpdateSourcePayload, |
| 31 | 33 | } from "./sources/api"; |
| 34 | +import type { | |
| 35 | + CreateExecutionPayload, | |
| 36 | + ResumeExecutionPayload, | |
| 37 | +} from "./executions/api"; | |
| 32 | 38 | import type { |
| 33 | 39 | CreateWorkspacePayload, |
| 34 | 40 | UpdateWorkspacePayload, |
| @@ -110,6 +116,24 @@ export type CreatePolicyInput = { · 75 unmodified lines | ||
| 110 | 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 | 137 | export type GetPolicyInput = { |
| 114 | 138 | workspaceId: WorkspaceId; |
| 115 | 139 | policyId: PolicyId; |
| @@ -210,6 +234,22 @@ export type ControlPlaneServiceShape = { · 94 unmodified lines | ||
| 210 | 234 | input: RemoveSourceInput, |
| 211 | 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 | 253 | listPolicies: ( |
| 214 | 254 | workspaceId: WorkspaceId, |
| 215 | 255 | ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>; |
| @@ -1,4 +1,8 @@ | ||
| 1 | 1 | import { |
| 2 | + type Account, | |
| 3 | + type Execution, | |
| 4 | + type ExecutionInteraction, | |
| 5 | + type LocalInstallation, | |
| 2 | 6 | type Organization, |
| 3 | 7 | type OrganizationMembership, |
| 4 | 8 | type Policy, |
| @@ -49,6 +53,60 @@ export const createControlPlaneRows = ({ · 44 unmodified lines | ||
| 49 | 53 | db, |
| 50 | 54 | tables, |
| 51 | 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 | 110 | organizations: { |
| 53 | 111 | list: () => |
| 54 | 112 | rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => { |
| @@ -569,6 +627,179 @@ export const createControlPlaneRows = ({ · 514 unmodified lines | ||
| 569 | 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 | 805 | export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>; |
| @@ -11,13 +11,38 @@ import { · 10 unmodified lines | ||
| 11 | 11 | import { sql } from "drizzle-orm"; |
| 12 | 12 | |
| 13 | 13 | export const tableNames = { |
| 14 | + accounts: "accounts", | |
| 14 | 15 | organizations: "organizations", |
| 15 | 16 | organizationMemberships: "organization_memberships", |
| 16 | 17 | workspaces: "workspaces", |
| 17 | 18 | sources: "sources", |
| 18 | 19 | policies: "policies", |
| 20 | + localInstallations: "local_installations", | |
| 21 | + executions: "executions", | |
| 22 | + executionInteractions: "execution_interactions", | |
| 19 | 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 | 46 | export const organizationsTable = pgTable(tableNames.organizations, { |
| 22 | 47 | id: text("id").notNull().primaryKey(), |
| 23 | 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 | 241 | export const drizzleSchema = { |
| 242 | + accountsTable, | |
| 158 | 243 | organizationsTable, |
| 159 | 244 | organizationMembershipsTable, |
| 160 | 245 | workspacesTable, |
| 161 | 246 | sourcesTable, |
| 162 | 247 | policiesTable, |
| 248 | + localInstallationsTable, | |
| 249 | + executionsTable, | |
| 250 | + executionInteractionsTable, | |
| 163 | 251 | }; |
| 164 | 252 | |
| 165 | 253 | export type DrizzleTables = typeof drizzleSchema; |
| @@ -85,6 +85,20 @@ const createPostgresRuntime = async ( · 84 unmodified lines | ||
| 85 | 85 | |
| 86 | 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 | 102 | create table if not exists organizations ( |
| 89 | 103 | id text primary key, |
| 90 | 104 | slug text not null, |
| @@ -172,6 +186,48 @@ create table if not exists policies ( · 81 unmodified lines | ||
| 172 | 186 | ); |
| 173 | 187 | `, |
| 174 | 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 | 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 | 24 | makeSecretStore, |
| 25 | 25 | type SecretStore, |
| 26 | 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 | 37 | import { makeRuntimeControlPlaneService } from "./services"; |
| 28 | 38 | |
| 29 | 39 | export { |
| @@ -35,12 +45,16 @@ export { · 5 unmodified lines | ||
| 35 | 45 | }; |
| 36 | 46 | |
| 37 | 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 | 51 | export * from "./source-runtime"; |
| 39 | 52 | |
| 40 | 53 | export type RuntimeControlPlaneInput = { |
| 41 | 54 | persistence: SqlControlPlanePersistence; |
| 42 | 55 | actorResolver?: ControlPlaneActorResolverShape; |
| 43 | 56 | secretStore?: SecretStore; |
| 57 | + executionResolver?: ResolveExecutionEnvironment; | |
| 44 | 58 | }; |
| 45 | 59 | |
| 46 | 60 | export const makeRuntimeControlPlane = ( |
| @@ -51,7 +65,11 @@ export const makeRuntimeControlPlane = ( · 4 unmodified lines | ||
| 51 | 65 | secretStore: SecretStore; |
| 52 | 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 | 73 | const actorResolver = input.actorResolver ?? makeHeaderActorResolver(input.persistence.rows); |
| 56 | 74 | const secretStore = |
| 57 | 75 | input.secretStore |
| @@ -75,6 +93,7 @@ export const makeRuntimeControlPlane = ( · 17 unmodified lines | ||
| 75 | 93 | |
| 76 | 94 | export type SqlControlPlaneRuntime = { |
| 77 | 95 | persistence: SqlControlPlanePersistence; |
| 96 | + localInstallation: LocalInstallation; | |
| 78 | 97 | service: ControlPlaneServiceShape; |
| 79 | 98 | actorResolver: ControlPlaneActorResolverShape; |
| 80 | 99 | secretStore: SecretStore; |
| @@ -85,24 +104,39 @@ export type SqlControlPlaneRuntime = { · 4 unmodified lines | ||
| 85 | 104 | export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions & { |
| 86 | 105 | secretStore?: SecretStore; |
| 87 | 106 | actorResolver?: ControlPlaneActorResolverShape; |
| 107 | + executionResolver?: ResolveExecutionEnvironment; | |
| 88 | 108 | }; |
| 89 | 109 | |
| 90 | 110 | export const makeSqlControlPlaneRuntime = ( |
| 91 | 111 | options: CreateSqlControlPlaneRuntimeOptions, |
| 92 | 112 | ): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> => |
| 93 | − Effect.map(makeSqlControlPlanePersistence(options), (persistence) => { | |
| 94 | − const runtime = makeRuntimeControlPlane({ | |
| 95 | − persistence, | |
| 96 | − actorResolver: options.actorResolver, | |
| 97 | − secretStore: options.secretStore, | |
| 98 | − }); | |
| 113 | + Effect.flatMap(makeSqlControlPlanePersistence(options), (persistence) => | |
| 114 | + Effect.gen(function* () { | |
| 115 | + const runtime = makeRuntimeControlPlane({ | |
| 116 | + persistence, | |
| 117 | + actorResolver: options.actorResolver, | |
| 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 { | |
| 101 | − persistence, | |
| 102 | − service: runtime.service, | |
| 103 | − actorResolver: runtime.actorResolver, | |
| 104 | − secretStore: runtime.secretStore, | |
| 105 | − webHandler: runtime.webHandler, | |
| 106 | − close: () => persistence.close(), | |
| 107 | − }; | |
| 108 | − }); | |
| 132 | + return { | |
| 133 | + persistence, | |
| 134 | + localInstallation, | |
| 135 | + service: runtime.service, | |
| 136 | + actorResolver: runtime.actorResolver, | |
| 137 | + secretStore: runtime.secretStore, | |
| 138 | + webHandler: runtime.webHandler, | |
| 139 | + close: () => persistence.close(), | |
| 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 | 18 | import * as Effect from "effect/Effect"; |
| 19 | 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 | 24 | import { slugify } from "./slug"; |
| 22 | 25 | |
| 23 | 26 | const badRequest = ( |
| @@ -857,10 +860,19 @@ export const makeRuntimePoliciesService = ( · 833 unmodified lines | ||
| 857 | 860 | |
| 858 | 861 | export const makeRuntimeControlPlaneService = ( |
| 859 | 862 | rows: SqlControlPlaneRows, |
| 863 | + options: { | |
| 864 | + executionResolver?: ResolveExecutionEnvironment; | |
| 865 | + liveExecutionManager?: LiveExecutionManager; | |
| 866 | + } = {}, | |
| 860 | 867 | ): ControlPlaneServiceShape => ({ |
| 861 | 868 | ...makeRuntimeOrganizationsService(rows), |
| 862 | 869 | ...makeRuntimeMembershipsService(rows), |
| 863 | 870 | ...makeRuntimeWorkspacesService(rows), |
| 864 | 871 | ...makeRuntimeSourcesService(rows), |
| 865 | 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 | 8 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); |
| 9 | 9 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); |
| 10 | 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 | 17 | export type AccountId = typeof AccountIdSchema.Type; |
| 13 | 18 | export type OrganizationId = typeof OrganizationIdSchema.Type; |
| @@ -15,3 +20,6 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 1 unmodified line | ||
| 15 | 20 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 16 | 21 | export type SourceId = typeof SourceIdSchema.Type; |
| 17 | 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 | 6 | export * from "./models/workspace"; |
| 7 | 7 | export * from "./models/source"; |
| 8 | 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 | 2 | export * from "./membership"; |
| 2 | 3 | export * from "./organization"; |
| 3 | 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; | |