History

Add control plane local execution and MCP resume flow

Rhys Sullivan committed 8 months ago

Commit 1deb9ce1 parent 83b91a729 files · +2142 −25Browse files

Unified · Split

29 files
  1. bun.lock +5 −0
  2. packages/codemode-core/src/types.ts +4 −4
  3. packages/codemode-mcp/src/mcp-connection.ts +7 −4
  4. packages/control-plane/package.json +6 −1
  5. packages/control-plane/src/api/api.ts +9 −0
  6. packages/control-plane/src/api/executions/api.ts +62 −0
  7. packages/control-plane/src/api/executions/http.ts +125 −0
  8. packages/control-plane/src/api/http.ts +2 −0
  9. packages/control-plane/src/api/index.ts +7 −0
  10. packages/control-plane/src/api/service.ts +40 −0
  11. packages/control-plane/src/persistence/control-plane-rows.ts +231 −0
  12. packages/control-plane/src/persistence/schema.ts +88 −0
  13. packages/control-plane/src/persistence/sql-runtime.ts +56 −0
  14. packages/control-plane/src/runtime/execution-http.test.ts +104 −0
  15. packages/control-plane/src/runtime/execution-mcp-resume.test.ts +365 −0
  16. packages/control-plane/src/runtime/execution-service.ts +358 −0
  17. packages/control-plane/src/runtime/execution-state.ts +28 −0
  18. packages/control-plane/src/runtime/index.ts +50 −16
  19. packages/control-plane/src/runtime/live-execution.ts +177 −0
  20. packages/control-plane/src/runtime/local-installation.test.ts +52 −0
  21. packages/control-plane/src/runtime/local-installation.ts +114 −0
  22. packages/control-plane/src/runtime/services.ts +12 −0
  23. packages/control-plane/src/runtime/test-http-client.ts +64 −0
  24. packages/control-plane/src/schema/ids.ts +8 −0
  25. packages/control-plane/src/schema/index.ts +2 −0
  26. packages/control-plane/src/schema/models/auth/account.ts +34 −0
  27. packages/control-plane/src/schema/models/auth/index.ts +1 −0
  28. packages/control-plane/src/schema/models/execution.ts +91 −0
  29. packages/control-plane/src/schema/models/local-installation.ts +40 −0
bun.lock+5−0
@@ -79,10 +79,15 @@ · 78 unmodified lines
7979 "postgres": "^3.4.7",
8080 },
8181 "devDependencies": {
82+ "@effect/platform-node": "^0.104.1",
8283 "@effect/vitest": "catalog:",
84+ "@executor-v3/codemode-mcp": "workspace:*",
85+ "@executor-v3/runtime-local-inproc": "workspace:*",
86+ "@modelcontextprotocol/sdk": "^1.26.0",
8387 "@types/node": "catalog:",
8488 "bun-types": "catalog:",
8589 "vitest": "catalog:",
90+ "zod": "catalog:",
8691 },
8792 },
8893 "packages/kitchen-sink": {
packages/codemode-core/src/types.ts+4−4
@@ -51,10 +51,10 @@ export type ToolInteractionDecision = · 50 unmodified lines
5151 | { kind: "execute" }
5252 | { kind: "decline"; reason: string }
5353 | {
54− kind: "elicit";
55− elicitation: ElicitationRequest;
56− interactionId?: string;
57− };
54+ kind: "elicit";
55+ elicitation: ElicitationRequest;
56+ interactionId?: string;
57+ };
5858
5959 export type ToolElicitationRequest = {
6060 interactionId: string;
packages/codemode-mcp/src/mcp-connection.ts+7−4
@@ -30,9 +30,12 @@ const createEndpoint = ( · 29 unmodified lines
3030 return url;
3131 };
3232
33+type FetchInput = Parameters<typeof fetch>[0];
34+type FetchInit = Parameters<typeof fetch>[1];
35+
3336 const mergeHeadersForFetch = (
34− input: RequestInfo | URL,
35− init: RequestInit | undefined,
37+ input: FetchInput,
38+ init: FetchInit,
3639 headers: Record<string, string>,
3740 ): Promise<Response> => {
3841 const mergedHeaders = new Headers(init?.headers ?? {});
@@ -84,7 +87,7 @@ export const createSdkMcpConnector = ( · 45 unmodified lines
8487 requestInit,
8588 eventSourceInit: requestInit
8689 ? {
87− fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) =>
90+ fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
8891 mergeHeadersForFetch(requestInput, requestOptions, headers),
8992 }
9093 : undefined,
@@ -100,7 +103,7 @@ export const createSdkMcpConnector = ( · 9 unmodified lines
100103 requestInit,
101104 eventSourceInit: requestInit
102105 ? {
103− fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) =>
106+ fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
104107 mergeHeadersForFetch(requestInput, requestOptions, headers),
105108 }
106109 : undefined,
packages/control-plane/package.json+6−1
@@ -28,7 +28,12 @@ · 27 unmodified lines
2828 "devDependencies": {
2929 "@effect/vitest": "catalog:",
3030 "@types/node": "catalog:",
31+ "@effect/platform-node": "^0.104.1",
32+ "@executor-v3/runtime-local-inproc": "workspace:*",
33+ "@modelcontextprotocol/sdk": "^1.26.0",
3134 "bun-types": "catalog:",
32− "vitest": "catalog:"
35+ "vitest": "catalog:",
36+ "zod": "catalog:",
37+ "@executor-v3/codemode-mcp": "workspace:*"
3338 }
3439 }
packages/control-plane/src/api/api.ts+9−0
@@ -1,5 +1,6 @@
11 import { HttpApi, OpenApi } from "@effect/platform";
22
3+import { ExecutionsApi } from "./executions/api";
34 import { MembershipsApi } from "./memberships/api";
45 import { OrganizationsApi } from "./organizations/api";
56 import { PoliciesApi } from "./policies/api";
@@ -14,6 +15,13 @@ export { · 8 unmodified lines
1415 ControlPlaneUnauthorizedError,
1516 } from "./errors";
1617
18+export {
19+ CreateExecutionPayloadSchema,
20+ ResumeExecutionPayloadSchema,
21+ type CreateExecutionPayload,
22+ type ResumeExecutionPayload,
23+} from "./executions/api";
24+
1725 export {
1826 CreateOrganizationPayloadSchema,
1927 UpdateOrganizationPayloadSchema,
@@ -55,6 +63,7 @@ export class ControlPlaneApi extends HttpApi.make("controlPlane") · 35 unmodified lines
5563 .add(WorkspacesApi)
5664 .add(SourcesApi)
5765 .add(PoliciesApi)
66+ .add(ExecutionsApi)
5867 .annotateContext(
5968 OpenApi.annotations({
6069 title: "Executor v3 Control Plane API",
packages/control-plane/src/api/executions/api.ts+62−0
@@ -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") {}
packages/control-plane/src/api/executions/http.ts+125−0
@@ -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+);
packages/control-plane/src/api/http.ts+2−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 ControlPlaneActorResolver,
77 type ControlPlaneActorResolverShape,
88 } from "./auth/actor-resolver";
9+import { ControlPlaneExecutionsLive } from "./executions/http";
910 import { ControlPlaneMembershipsLive } from "./memberships/http";
1011 import { ControlPlaneOrganizationsLive } from "./organizations/http";
1112 import { ControlPlanePoliciesLive } from "./policies/http";
@@ -19,6 +20,7 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines
1920 Layer.provide(ControlPlaneWorkspacesLive),
2021 Layer.provide(ControlPlaneSourcesLive),
2122 Layer.provide(ControlPlanePoliciesLive),
23+ Layer.provide(ControlPlaneExecutionsLive),
2224 );
2325
2426 export const makeControlPlaneWebHandler = <EService, EResolver>(
packages/control-plane/src/api/index.ts+7−0
@@ -49,6 +49,13 @@ export { · 48 unmodified lines
4949 export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership";
5050
5151
52+export {
53+ CreateExecutionPayloadSchema,
54+ ResumeExecutionPayloadSchema,
55+ type CreateExecutionPayload,
56+ type ResumeExecutionPayload,
57+} from "./executions/api";
58+
5259 export {
5360 CreateOrganizationPayloadSchema,
5461 UpdateOrganizationPayloadSchema,
packages/control-plane/src/api/service.ts+40−0
@@ -1,5 +1,7 @@
11 import type {
22 AccountId,
3+ Execution,
4+ ExecutionId,
35 Organization,
46 OrganizationId,
57 OrganizationMembership,
@@ -29,6 +31,10 @@ import type { · 23 unmodified lines
2931 CreateSourcePayload,
3032 UpdateSourcePayload,
3133 } from "./sources/api";
34+import type {
35+ CreateExecutionPayload,
36+ ResumeExecutionPayload,
37+} from "./executions/api";
3238 import type {
3339 CreateWorkspacePayload,
3440 UpdateWorkspacePayload,
@@ -110,6 +116,24 @@ export type CreatePolicyInput = { · 75 unmodified lines
110116 payload: CreatePolicyPayload;
111117 };
112118
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+
113137 export type GetPolicyInput = {
114138 workspaceId: WorkspaceId;
115139 policyId: PolicyId;
@@ -210,6 +234,22 @@ export type ControlPlaneServiceShape = { · 94 unmodified lines
210234 input: RemoveSourceInput,
211235 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;
212236
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+
213253 listPolicies: (
214254 workspaceId: WorkspaceId,
215255 ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>;
packages/control-plane/src/persistence/control-plane-rows.ts+231−0
@@ -1,4 +1,8 @@
11 import {
2+ type Account,
3+ type Execution,
4+ type ExecutionInteraction,
5+ type LocalInstallation,
26 type Organization,
37 type OrganizationMembership,
48 type Policy,
@@ -49,6 +53,60 @@ export const createControlPlaneRows = ({ · 44 unmodified lines
4953 db,
5054 tables,
5155 }: 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+
52110 organizations: {
53111 list: () =>
54112 rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => {
@@ -569,6 +627,179 @@ export const createControlPlaneRows = ({ · 514 unmodified lines
569627 return deleted.length > 0;
570628 }),
571629 },
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+ },
572803 });
573804
574805 export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>;
packages/control-plane/src/persistence/schema.ts+88−0
@@ -11,13 +11,38 @@ import { · 10 unmodified lines
1111 import { sql } from "drizzle-orm";
1212
1313 export const tableNames = {
14+ accounts: "accounts",
1415 organizations: "organizations",
1516 organizationMemberships: "organization_memberships",
1617 workspaces: "workspaces",
1718 sources: "sources",
1819 policies: "policies",
20+ localInstallations: "local_installations",
21+ executions: "executions",
22+ executionInteractions: "execution_interactions",
1923 } as const;
2024
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+
2146 export const organizationsTable = pgTable(tableNames.organizations, {
2247 id: text("id").notNull().primaryKey(),
2348 slug: text("slug").notNull(),
@@ -154,12 +179,75 @@ export const policiesTable = pgTable( · 130 unmodified lines
154179 ],
155180 );
156181
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+
157241 export const drizzleSchema = {
242+ accountsTable,
158243 organizationsTable,
159244 organizationMembershipsTable,
160245 workspacesTable,
161246 sourcesTable,
162247 policiesTable,
248+ localInstallationsTable,
249+ executionsTable,
250+ executionInteractionsTable,
163251 };
164252
165253 export type DrizzleTables = typeof drizzleSchema;
packages/control-plane/src/persistence/sql-runtime.ts+56−0
@@ -85,6 +85,20 @@ const createPostgresRuntime = async ( · 84 unmodified lines
8585
8686 const ddlStatements = [
8787 `
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+ `
88102 create table if not exists organizations (
89103 id text primary key,
90104 slug text not null,
@@ -172,6 +186,48 @@ create table if not exists policies ( · 81 unmodified lines
172186 );
173187 `,
174188 "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);",
175231 ];
176232
177233 export const ensureSchema = async (db: any): Promise<void> => {
packages/control-plane/src/runtime/execution-http.test.ts+104−0
@@ -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+});
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+365−0
@@ -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+});
packages/control-plane/src/runtime/execution-service.ts+358−0
@@ -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+ });
packages/control-plane/src/runtime/execution-state.ts+28−0
@@ -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+}> {}
packages/control-plane/src/runtime/index.ts+50−16
@@ -24,6 +24,16 @@ import { · 23 unmodified lines
2424 makeSecretStore,
2525 type SecretStore,
2626 } 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";
2737 import { makeRuntimeControlPlaneService } from "./services";
2838
2939 export {
@@ -35,12 +45,16 @@ export { · 5 unmodified lines
3545 };
3646
3747 export type { SecretHandle, SecretProvider, SecretStore } from "./secret-store";
48+export * from "./execution-state";
49+export * from "./live-execution";
50+export * from "./local-installation";
3851 export * from "./source-runtime";
3952
4053 export type RuntimeControlPlaneInput = {
4154 persistence: SqlControlPlanePersistence;
4255 actorResolver?: ControlPlaneActorResolverShape;
4356 secretStore?: SecretStore;
57+ executionResolver?: ResolveExecutionEnvironment;
4458 };
4559
4660 export const makeRuntimeControlPlane = (
@@ -51,7 +65,11 @@ export const makeRuntimeControlPlane = ( · 4 unmodified lines
5165 secretStore: SecretStore;
5266 webHandler: ReturnType<typeof makeControlPlaneWebHandler>;
5367 } => {
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+ });
5573 const actorResolver = input.actorResolver ?? makeHeaderActorResolver(input.persistence.rows);
5674 const secretStore =
5775 input.secretStore
@@ -75,6 +93,7 @@ export const makeRuntimeControlPlane = ( · 17 unmodified lines
7593
7694 export type SqlControlPlaneRuntime = {
7795 persistence: SqlControlPlanePersistence;
96+ localInstallation: LocalInstallation;
7897 service: ControlPlaneServiceShape;
7998 actorResolver: ControlPlaneActorResolverShape;
8099 secretStore: SecretStore;
@@ -85,24 +104,39 @@ export type SqlControlPlaneRuntime = { · 4 unmodified lines
85104 export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions & {
86105 secretStore?: SecretStore;
87106 actorResolver?: ControlPlaneActorResolverShape;
107+ executionResolver?: ResolveExecutionEnvironment;
88108 };
89109
90110 export const makeSqlControlPlaneRuntime = (
91111 options: CreateSqlControlPlaneRuntimeOptions,
92112 ): 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+ );
99131
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+ );
packages/control-plane/src/runtime/live-execution.ts+177−0
@@ -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+};
packages/control-plane/src/runtime/local-installation.test.ts+52−0
@@ -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+});
packages/control-plane/src/runtime/local-installation.ts+114−0
@@ -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+ );
packages/control-plane/src/runtime/services.ts+12−0
@@ -18,6 +18,9 @@ import type { · 17 unmodified lines
1818 import * as Effect from "effect/Effect";
1919 import * as Option from "effect/Option";
2020
21+import { type ResolveExecutionEnvironment } from "./execution-state";
22+import { type LiveExecutionManager } from "./live-execution";
23+import { makeRuntimeExecutionsService } from "./execution-service";
2124 import { slugify } from "./slug";
2225
2326 const badRequest = (
@@ -857,10 +860,19 @@ export const makeRuntimePoliciesService = ( · 833 unmodified lines
857860
858861 export const makeRuntimeControlPlaneService = (
859862 rows: SqlControlPlaneRows,
863+ options: {
864+ executionResolver?: ResolveExecutionEnvironment;
865+ liveExecutionManager?: LiveExecutionManager;
866+ } = {},
860867 ): ControlPlaneServiceShape => ({
861868 ...makeRuntimeOrganizationsService(rows),
862869 ...makeRuntimeMembershipsService(rows),
863870 ...makeRuntimeWorkspacesService(rows),
864871 ...makeRuntimeSourcesService(rows),
865872 ...makeRuntimePoliciesService(rows),
873+ ...makeRuntimeExecutionsService(
874+ rows,
875+ options.executionResolver,
876+ options.liveExecutionManager,
877+ ),
866878 });
packages/control-plane/src/runtime/test-http-client.ts+64−0
@@ -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)));
packages/control-plane/src/schema/ids.ts+8−0
@@ -8,6 +8,11 @@ export const OrganizationMemberIdSchema = Schema.String.pipe( · 7 unmodified lines
88 export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));
99 export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));
1010 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+);
1116
1217 export type AccountId = typeof AccountIdSchema.Type;
1318 export type OrganizationId = typeof OrganizationIdSchema.Type;
@@ -15,3 +20,6 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 1 unmodified line
1520 export type WorkspaceId = typeof WorkspaceIdSchema.Type;
1621 export type SourceId = typeof SourceIdSchema.Type;
1722 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;
packages/control-plane/src/schema/index.ts+2−0
@@ -6,3 +6,5 @@ export * from "./models/auth"; · 5 unmodified lines
66 export * from "./models/workspace";
77 export * from "./models/source";
88 export * from "./models/policy";
9+export * from "./models/local-installation";
10+export * from "./models/execution";
packages/control-plane/src/schema/models/auth/account.ts+34−0
@@ -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;
packages/control-plane/src/schema/models/auth/index.ts+1−0
@@ -1,3 +1,4 @@
1+export * from "./account";
12 export * from "./membership";
23 export * from "./organization";
34 export * from "./organization-membership";
packages/control-plane/src/schema/models/execution.ts+91−0
@@ -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;
packages/control-plane/src/schema/models/local-installation.ts+40−0
@@ -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;