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
79 "postgres": "^3.4.7",79 "postgres": "^3.4.7",
80 },80 },
81 "devDependencies": {81 "devDependencies": {
82 "@effect/platform-node": "^0.104.1",
82 "@effect/vitest": "catalog:",83 "@effect/vitest": "catalog:",
84 "@executor-v3/codemode-mcp": "workspace:*",
85 "@executor-v3/runtime-local-inproc": "workspace:*",
86 "@modelcontextprotocol/sdk": "^1.26.0",
83 "@types/node": "catalog:",87 "@types/node": "catalog:",
84 "bun-types": "catalog:",88 "bun-types": "catalog:",
85 "vitest": "catalog:",89 "vitest": "catalog:",
90 "zod": "catalog:",
86 },91 },
87 },92 },
88 "packages/kitchen-sink": {93 "packages/kitchen-sink": {
packages/codemode-core/src/types.ts+4−4
@@ -51,10 +51,10 @@ export type ToolInteractionDecision = · 50 unmodified lines
51 | { kind: "execute" }51 | { kind: "execute" }
52 | { kind: "decline"; reason: string }52 | { kind: "decline"; reason: string }
53 | {53 | {
54 kind: "elicit";54 kind: "elicit";
55 elicitation: ElicitationRequest;55 elicitation: ElicitationRequest;
56 interactionId?: string;56 interactionId?: string;
57 };57 };
58 58
59export type ToolElicitationRequest = {59export type ToolElicitationRequest = {
60 interactionId: string;60 interactionId: string;
packages/codemode-mcp/src/mcp-connection.ts+7−4
@@ -30,9 +30,12 @@ const createEndpoint = ( · 29 unmodified lines
30 return url;30 return url;
31};31};
32 32
33type FetchInput = Parameters<typeof fetch>[0];
34type FetchInit = Parameters<typeof fetch>[1];
35
33const mergeHeadersForFetch = (36const mergeHeadersForFetch = (
34 input: RequestInfo | URL,37 input: FetchInput,
35 init: RequestInit | undefined,38 init: FetchInit,
36 headers: Record<string, string>,39 headers: Record<string, string>,
37): Promise<Response> => {40): Promise<Response> => {
38 const mergedHeaders = new Headers(init?.headers ?? {});41 const mergedHeaders = new Headers(init?.headers ?? {});
@@ -84,7 +87,7 @@ export const createSdkMcpConnector = ( · 45 unmodified lines
84 requestInit,87 requestInit,
85 eventSourceInit: requestInit88 eventSourceInit: requestInit
86 ? {89 ? {
87 fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) =>90 fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
88 mergeHeadersForFetch(requestInput, requestOptions, headers),91 mergeHeadersForFetch(requestInput, requestOptions, headers),
89 }92 }
90 : undefined,93 : undefined,
@@ -100,7 +103,7 @@ export const createSdkMcpConnector = ( · 9 unmodified lines
100 requestInit,103 requestInit,
101 eventSourceInit: requestInit104 eventSourceInit: requestInit
102 ? {105 ? {
103 fetch: (requestInput: RequestInfo | URL, requestOptions: RequestInit | undefined) =>106 fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
104 mergeHeadersForFetch(requestInput, requestOptions, headers),107 mergeHeadersForFetch(requestInput, requestOptions, headers),
105 }108 }
106 : undefined,109 : undefined,
packages/control-plane/package.json+6−1
@@ -28,7 +28,12 @@ · 27 unmodified lines
28 "devDependencies": {28 "devDependencies": {
29 "@effect/vitest": "catalog:",29 "@effect/vitest": "catalog:",
30 "@types/node": "catalog:",30 "@types/node": "catalog:",
31 "@effect/platform-node": "^0.104.1",
32 "@executor-v3/runtime-local-inproc": "workspace:*",
33 "@modelcontextprotocol/sdk": "^1.26.0",
31 "bun-types": "catalog:",34 "bun-types": "catalog:",
32 "vitest": "catalog:"35 "vitest": "catalog:",
36 "zod": "catalog:",
37 "@executor-v3/codemode-mcp": "workspace:*"
33 }38 }
34}39}
packages/control-plane/src/api/api.ts+9−0
@@ -1,5 +1,6 @@
1import { HttpApi, OpenApi } from "@effect/platform";1import { HttpApi, OpenApi } from "@effect/platform";
2 2
3import { ExecutionsApi } from "./executions/api";
3import { MembershipsApi } from "./memberships/api";4import { MembershipsApi } from "./memberships/api";
4import { OrganizationsApi } from "./organizations/api";5import { OrganizationsApi } from "./organizations/api";
5import { PoliciesApi } from "./policies/api";6import { PoliciesApi } from "./policies/api";
@@ -14,6 +15,13 @@ export { · 8 unmodified lines
14 ControlPlaneUnauthorizedError,15 ControlPlaneUnauthorizedError,
15} from "./errors";16} from "./errors";
16 17
18export {
19 CreateExecutionPayloadSchema,
20 ResumeExecutionPayloadSchema,
21 type CreateExecutionPayload,
22 type ResumeExecutionPayload,
23} from "./executions/api";
24
17export {25export {
18 CreateOrganizationPayloadSchema,26 CreateOrganizationPayloadSchema,
19 UpdateOrganizationPayloadSchema,27 UpdateOrganizationPayloadSchema,
@@ -55,6 +63,7 @@ export class ControlPlaneApi extends HttpApi.make("controlPlane") · 35 unmodified lines
55 .add(WorkspacesApi)63 .add(WorkspacesApi)
56 .add(SourcesApi)64 .add(SourcesApi)
57 .add(PoliciesApi)65 .add(PoliciesApi)
66 .add(ExecutionsApi)
58 .annotateContext(67 .annotateContext(
59 OpenApi.annotations({68 OpenApi.annotations({
60 title: "Executor v3 Control Plane API",69 title: "Executor v3 Control Plane API",
packages/control-plane/src/api/executions/api.ts+62−0
@@ -0,0 +1,62 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 ExecutionIdSchema,
4 ExecutionSchema,
5 WorkspaceIdSchema,
6} from "#schema";
7import * as Schema from "effect/Schema";
8
9import {
10 ControlPlaneBadRequestError,
11 ControlPlaneForbiddenError,
12 ControlPlaneNotFoundError,
13 ControlPlaneStorageError,
14 ControlPlaneUnauthorizedError,
15} from "../errors";
16
17export const CreateExecutionPayloadSchema = Schema.Struct({
18 code: Schema.String,
19});
20
21export type CreateExecutionPayload = typeof CreateExecutionPayloadSchema.Type;
22
23export const ResumeExecutionPayloadSchema = Schema.Struct({
24 responseJson: Schema.optional(Schema.String),
25});
26
27export type ResumeExecutionPayload = typeof ResumeExecutionPayloadSchema.Type;
28
29const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
30const executionIdParam = HttpApiSchema.param("executionId", ExecutionIdSchema);
31
32export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import * as Effect from "effect/Effect";
3
4import {
5 Actor,
6 ActorForbiddenError,
7 ActorUnauthenticatedError,
8 requirePermission,
9 withPolicy,
10} from "#domain";
11
12import { ControlPlaneApi } from "../api";
13import { ControlPlaneActorResolver } from "../auth/actor-resolver";
14import {
15 ControlPlaneForbiddenError,
16 ControlPlaneUnauthorizedError,
17} from "../errors";
18import { ControlPlaneService } from "../service";
19
20const 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
30const toUnauthorizedError = (
31 operation: string,
32 cause: ActorUnauthenticatedError,
33): ControlPlaneUnauthorizedError =>
34 new ControlPlaneUnauthorizedError({
35 operation,
36 message: cause.message,
37 details: "Authentication required",
38 });
39
40const 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
51const requireExecuteWorkspace = (workspaceId: string) =>
52 requirePermission({
53 permission: "workspace:read",
54 workspaceId: workspaceId as never,
55 });
56
57export 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
6 ControlPlaneActorResolver,6 ControlPlaneActorResolver,
7 type ControlPlaneActorResolverShape,7 type ControlPlaneActorResolverShape,
8} from "./auth/actor-resolver";8} from "./auth/actor-resolver";
9import { ControlPlaneExecutionsLive } from "./executions/http";
9import { ControlPlaneMembershipsLive } from "./memberships/http";10import { ControlPlaneMembershipsLive } from "./memberships/http";
10import { ControlPlaneOrganizationsLive } from "./organizations/http";11import { ControlPlaneOrganizationsLive } from "./organizations/http";
11import { ControlPlanePoliciesLive } from "./policies/http";12import { ControlPlanePoliciesLive } from "./policies/http";
@@ -19,6 +20,7 @@ export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( · 7 unmodified lines
19 Layer.provide(ControlPlaneWorkspacesLive),20 Layer.provide(ControlPlaneWorkspacesLive),
20 Layer.provide(ControlPlaneSourcesLive),21 Layer.provide(ControlPlaneSourcesLive),
21 Layer.provide(ControlPlanePoliciesLive),22 Layer.provide(ControlPlanePoliciesLive),
23 Layer.provide(ControlPlaneExecutionsLive),
22);24);
23 25
24export const makeControlPlaneWebHandler = <EService, EResolver>(26export const makeControlPlaneWebHandler = <EService, EResolver>(
packages/control-plane/src/api/index.ts+7−0
@@ -49,6 +49,13 @@ export { · 48 unmodified lines
49export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership";49export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership";
50 50
51 51
52export {
53 CreateExecutionPayloadSchema,
54 ResumeExecutionPayloadSchema,
55 type CreateExecutionPayload,
56 type ResumeExecutionPayload,
57} from "./executions/api";
58
52export {59export {
53 CreateOrganizationPayloadSchema,60 CreateOrganizationPayloadSchema,
54 UpdateOrganizationPayloadSchema,61 UpdateOrganizationPayloadSchema,
packages/control-plane/src/api/service.ts+40−0
@@ -1,5 +1,7 @@
1import type {1import type {
2 AccountId,2 AccountId,
3 Execution,
4 ExecutionId,
3 Organization,5 Organization,
4 OrganizationId,6 OrganizationId,
5 OrganizationMembership,7 OrganizationMembership,
@@ -29,6 +31,10 @@ import type { · 23 unmodified lines
29 CreateSourcePayload,31 CreateSourcePayload,
30 UpdateSourcePayload,32 UpdateSourcePayload,
31} from "./sources/api";33} from "./sources/api";
34import type {
35 CreateExecutionPayload,
36 ResumeExecutionPayload,
37} from "./executions/api";
32import type {38import type {
33 CreateWorkspacePayload,39 CreateWorkspacePayload,
34 UpdateWorkspacePayload,40 UpdateWorkspacePayload,
@@ -110,6 +116,24 @@ export type CreatePolicyInput = { · 75 unmodified lines
110 payload: CreatePolicyPayload;116 payload: CreatePolicyPayload;
111};117};
112 118
119export type CreateExecutionInput = {
120 workspaceId: WorkspaceId;
121 payload: CreateExecutionPayload;
122 createdByAccountId: AccountId;
123};
124
125export type GetExecutionInput = {
126 workspaceId: WorkspaceId;
127 executionId: ExecutionId;
128};
129
130export type ResumeExecutionInput = {
131 workspaceId: WorkspaceId;
132 executionId: ExecutionId;
133 payload: ResumeExecutionPayload;
134 resumedByAccountId: AccountId;
135};
136
113export type GetPolicyInput = {137export type GetPolicyInput = {
114 workspaceId: WorkspaceId;138 workspaceId: WorkspaceId;
115 policyId: PolicyId;139 policyId: PolicyId;
@@ -210,6 +234,22 @@ export type ControlPlaneServiceShape = { · 94 unmodified lines
210 input: RemoveSourceInput,234 input: RemoveSourceInput,
211 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;235 ) => Effect.Effect<{ removed: boolean }, ControlPlaneStorageError>;
212 236
237 createExecution: (
238 input: CreateExecutionInput,
239 ) => Effect.Effect<
240 Execution,
241 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
242 >;
243 getExecution: (
244 input: GetExecutionInput,
245 ) => Effect.Effect<Execution, ControlPlaneNotFoundError | ControlPlaneStorageError>;
246 resumeExecution: (
247 input: ResumeExecutionInput,
248 ) => Effect.Effect<
249 Execution,
250 ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError
251 >;
252
213 listPolicies: (253 listPolicies: (
214 workspaceId: WorkspaceId,254 workspaceId: WorkspaceId,
215 ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>;255 ) => Effect.Effect<ReadonlyArray<Policy>, ControlPlaneStorageError>;
packages/control-plane/src/persistence/control-plane-rows.ts+231−0
@@ -1,4 +1,8 @@
1import {1import {
2 type Account,
3 type Execution,
4 type ExecutionInteraction,
5 type LocalInstallation,
2 type Organization,6 type Organization,
3 type OrganizationMembership,7 type OrganizationMembership,
4 type Policy,8 type Policy,
@@ -49,6 +53,60 @@ export const createControlPlaneRows = ({ · 44 unmodified lines
49 db,53 db,
50 tables,54 tables,
51}: CreateControlPlaneRowsInput) => ({55}: CreateControlPlaneRowsInput) => ({
56 accounts: {
57 getById: (accountId: Account["id"]) =>
58 rowEffect(backend, "rows.accounts.get_by_id", tableNames.accounts, async () => {
59 const row = await db
60 .select()
61 .from(tables.accountsTable)
62 .where(eq(tables.accountsTable.id, accountId))
63 .limit(1);
64
65 return row[0] ? Option.some(asDomain<Account>(row[0])) : Option.none<Account>();
66 }),
67
68 getByProviderAndSubject: (provider: Account["provider"], subject: Account["subject"]) =>
69 rowEffect(
70 backend,
71 "rows.accounts.get_by_provider_and_subject",
72 tableNames.accounts,
73 async () => {
74 const row = await db
75 .select()
76 .from(tables.accountsTable)
77 .where(
78 and(
79 eq(tables.accountsTable.provider, provider),
80 eq(tables.accountsTable.subject, subject),
81 ),
82 )
83 .limit(1);
84
85 return row[0] ? Option.some(asDomain<Account>(row[0])) : Option.none<Account>();
86 },
87 ),
88
89 insert: (account: Account) =>
90 rowEffect(backend, "rows.accounts.insert", tableNames.accounts, async () => {
91 await db.insert(tables.accountsTable).values(account);
92 }),
93
94 upsert: (account: Account) =>
95 rowEffect(backend, "rows.accounts.upsert", tableNames.accounts, async () => {
96 await db
97 .insert(tables.accountsTable)
98 .values(account)
99 .onConflictDoUpdate({
100 target: [tables.accountsTable.provider, tables.accountsTable.subject],
101 set: {
102 email: account.email,
103 displayName: account.displayName,
104 updatedAt: account.updatedAt,
105 },
106 });
107 }),
108 },
109
52 organizations: {110 organizations: {
53 list: () =>111 list: () =>
54 rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => {112 rowEffect(backend, "rows.organizations.list", tableNames.organizations, async () => {
@@ -569,6 +627,179 @@ export const createControlPlaneRows = ({ · 514 unmodified lines
569 return deleted.length > 0;627 return deleted.length > 0;
570 }),628 }),
571 },629 },
630
631 localInstallations: {
632 getById: (installationId: LocalInstallation["id"]) =>
633 rowEffect(
634 backend,
635 "rows.local_installations.get_by_id",
636 tableNames.localInstallations,
637 async () => {
638 const row = await db
639 .select()
640 .from(tables.localInstallationsTable)
641 .where(eq(tables.localInstallationsTable.id, installationId))
642 .limit(1);
643
644 return row[0]
645 ? Option.some(asDomain<LocalInstallation>(row[0]))
646 : Option.none<LocalInstallation>();
647 },
648 ),
649
650 upsert: (installation: LocalInstallation) =>
651 rowEffect(
652 backend,
653 "rows.local_installations.upsert",
654 tableNames.localInstallations,
655 async () => {
656 await db
657 .insert(tables.localInstallationsTable)
658 .values(installation)
659 .onConflictDoUpdate({
660 target: tables.localInstallationsTable.id,
661 set: {
662 accountId: installation.accountId,
663 organizationId: installation.organizationId,
664 workspaceId: installation.workspaceId,
665 updatedAt: installation.updatedAt,
666 },
667 });
668 },
669 ),
670 },
671
672 executions: {
673 getById: (executionId: Execution["id"]) =>
674 rowEffect(backend, "rows.executions.get_by_id", tableNames.executions, async () => {
675 const row = await db
676 .select()
677 .from(tables.executionsTable)
678 .where(eq(tables.executionsTable.id, executionId))
679 .limit(1);
680
681 return row[0] ? Option.some(asDomain<Execution>(row[0])) : Option.none<Execution>();
682 }),
683
684 getByWorkspaceAndId: (workspaceId: Execution["workspaceId"], executionId: Execution["id"]) =>
685 rowEffect(
686 backend,
687 "rows.executions.get_by_workspace_and_id",
688 tableNames.executions,
689 async () => {
690 const row = await db
691 .select()
692 .from(tables.executionsTable)
693 .where(
694 and(
695 eq(tables.executionsTable.workspaceId, workspaceId),
696 eq(tables.executionsTable.id, executionId),
697 ),
698 )
699 .limit(1);
700
701 return row[0] ? Option.some(asDomain<Execution>(row[0])) : Option.none<Execution>();
702 },
703 ),
704
705 insert: (execution: Execution) =>
706 rowEffect(backend, "rows.executions.insert", tableNames.executions, async () => {
707 await db.insert(tables.executionsTable).values(execution);
708 }),
709
710 update: (
711 executionId: Execution["id"],
712 patch: Partial<Omit<Execution, "id" | "workspaceId" | "createdByAccountId" | "createdAt">>,
713 ) =>
714 rowEffect(backend, "rows.executions.update", tableNames.executions, async () => {
715 const rows = await db
716 .update(tables.executionsTable)
717 .set(patch)
718 .where(eq(tables.executionsTable.id, executionId))
719 .returning();
720
721 return rows[0] ? Option.some(asDomain<Execution>(rows[0])) : Option.none<Execution>();
722 }),
723 },
724
725 executionInteractions: {
726 listByExecutionId: (executionId: ExecutionInteraction["executionId"]) =>
727 rowEffect(
728 backend,
729 "rows.execution_interactions.list_by_execution_id",
730 tableNames.executionInteractions,
731 async () => {
732 const rows = await db
733 .select()
734 .from(tables.executionInteractionsTable)
735 .where(eq(tables.executionInteractionsTable.executionId, executionId))
736 .orderBy(
737 desc(tables.executionInteractionsTable.updatedAt),
738 desc(tables.executionInteractionsTable.id),
739 );
740
741 return asDomainArray<ExecutionInteraction>(rows);
742 },
743 ),
744
745 getPendingByExecutionId: (executionId: ExecutionInteraction["executionId"]) =>
746 rowEffect(
747 backend,
748 "rows.execution_interactions.get_pending_by_execution_id",
749 tableNames.executionInteractions,
750 async () => {
751 const row = await db
752 .select()
753 .from(tables.executionInteractionsTable)
754 .where(
755 and(
756 eq(tables.executionInteractionsTable.executionId, executionId),
757 eq(tables.executionInteractionsTable.status, "pending"),
758 ),
759 )
760 .orderBy(
761 desc(tables.executionInteractionsTable.updatedAt),
762 desc(tables.executionInteractionsTable.id),
763 )
764 .limit(1);
765
766 return row[0]
767 ? Option.some(asDomain<ExecutionInteraction>(row[0]))
768 : Option.none<ExecutionInteraction>();
769 },
770 ),
771
772 insert: (interaction: ExecutionInteraction) =>
773 rowEffect(
774 backend,
775 "rows.execution_interactions.insert",
776 tableNames.executionInteractions,
777 async () => {
778 await db.insert(tables.executionInteractionsTable).values(interaction);
779 },
780 ),
781
782 update: (
783 interactionId: ExecutionInteraction["id"],
784 patch: Partial<Omit<ExecutionInteraction, "id" | "executionId" | "createdAt">>,
785 ) =>
786 rowEffect(
787 backend,
788 "rows.execution_interactions.update",
789 tableNames.executionInteractions,
790 async () => {
791 const rows = await db
792 .update(tables.executionInteractionsTable)
793 .set(patch)
794 .where(eq(tables.executionInteractionsTable.id, interactionId))
795 .returning();
796
797 return rows[0]
798 ? Option.some(asDomain<ExecutionInteraction>(rows[0]))
799 : Option.none<ExecutionInteraction>();
800 },
801 ),
802 },
572});803});
573 804
574export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>;805export type SqlControlPlaneRows = ReturnType<typeof createControlPlaneRows>;
packages/control-plane/src/persistence/schema.ts+88−0
@@ -11,13 +11,38 @@ import { · 10 unmodified lines
11import { sql } from "drizzle-orm";11import { sql } from "drizzle-orm";
12 12
13export const tableNames = {13export const tableNames = {
14 accounts: "accounts",
14 organizations: "organizations",15 organizations: "organizations",
15 organizationMemberships: "organization_memberships",16 organizationMemberships: "organization_memberships",
16 workspaces: "workspaces",17 workspaces: "workspaces",
17 sources: "sources",18 sources: "sources",
18 policies: "policies",19 policies: "policies",
20 localInstallations: "local_installations",
21 executions: "executions",
22 executionInteractions: "execution_interactions",
19} as const;23} as const;
20 24
25export 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
21export const organizationsTable = pgTable(tableNames.organizations, {46export const organizationsTable = pgTable(tableNames.organizations, {
22 id: text("id").notNull().primaryKey(),47 id: text("id").notNull().primaryKey(),
23 slug: text("slug").notNull(),48 slug: text("slug").notNull(),
@@ -154,12 +179,75 @@ export const policiesTable = pgTable( · 130 unmodified lines
154 ],179 ],
155);180);
156 181
182export 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
191export 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
216export 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
157export const drizzleSchema = {241export const drizzleSchema = {
242 accountsTable,
158 organizationsTable,243 organizationsTable,
159 organizationMembershipsTable,244 organizationMembershipsTable,
160 workspacesTable,245 workspacesTable,
161 sourcesTable,246 sourcesTable,
162 policiesTable,247 policiesTable,
248 localInstallationsTable,
249 executionsTable,
250 executionInteractionsTable,
163};251};
164 252
165export type DrizzleTables = typeof drizzleSchema;253export type DrizzleTables = typeof drizzleSchema;
packages/control-plane/src/persistence/sql-runtime.ts+56−0
@@ -85,6 +85,20 @@ const createPostgresRuntime = async ( · 84 unmodified lines
85 85
86const ddlStatements = [86const ddlStatements = [
87 `87 `
88create 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 `
88create table if not exists organizations (102create table if not exists organizations (
89 id text primary key,103 id text primary key,
90 slug text not null,104 slug text not null,
@@ -172,6 +186,48 @@ create table if not exists policies ( · 81 unmodified lines
172);186);
173`,187`,
174 "create index if not exists policies_workspace_idx on policies (workspace_id, updated_at, id);",188 "create index if not exists policies_workspace_idx on policies (workspace_id, updated_at, id);",
189 `
190create 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 `
200create 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 `
218create 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
177export const ensureSchema = async (db: any): Promise<void> => {233export const ensureSchema = async (db: any): Promise<void> => {
packages/control-plane/src/runtime/execution-http.test.ts+104−0
@@ -0,0 +1,104 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
6import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
7
8import {
9 makeSqlControlPlaneRuntime,
10} from "./index";
11import { withControlPlaneClient } from "./test-http-client";
12
13const 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
39const 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
54describe("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 @@
1import { randomUUID } from "node:crypto";
2
3import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8import { describe, expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import { z } from "zod/v4";
11
12import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
13import { discoverMcpToolsFromConnector } from "@executor-v3/codemode-mcp";
14import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
15
16import {
17 makeSqlControlPlaneRuntime,
18 type ResolveExecutionEnvironment,
19} from "./index";
20import { withControlPlaneClient } from "./test-http-client";
21
22type McpFormServer = {
23 endpoint: string;
24 close: () => Promise<void>;
25};
26
27const 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
69const 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
235const 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
278describe("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 @@
1import type { ControlPlaneServiceShape } from "#api";
2import {
3 ControlPlaneBadRequestError,
4 ControlPlaneNotFoundError,
5 ControlPlaneStorageError,
6} from "#api";
7import {
8 ControlPlanePersistenceError,
9 type SqlControlPlaneRows,
10} from "#persistence";
11import type { Execution } from "#schema";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16
17import {
18 type ResolveExecutionEnvironment,
19 ResumeUnsupportedError,
20} from "./execution-state";
21import {
22 makeLiveExecutionManager,
23 type LiveExecutionManager,
24} from "./live-execution";
25
26const badRequest = (
27 operation: string,
28 message: string,
29 details: string,
30): ControlPlaneBadRequestError =>
31 new ControlPlaneBadRequestError({
32 operation,
33 message,
34 details,
35 });
36
37const notFound = (
38 operation: string,
39 message: string,
40 details: string,
41): ControlPlaneNotFoundError =>
42 new ControlPlaneNotFoundError({
43 operation,
44 message,
45 details,
46 });
47
48const 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
58const 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
64const 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
83const serializeJson = (value: unknown): string | null => {
84 if (value === undefined) {
85 return null;
86 }
87
88 return JSON.stringify(value);
89};
90
91const ElicitationActionSchema = Schema.Literal("accept", "decline", "cancel");
92
93const 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
103const decodeElicitationResponse = Schema.decodeUnknown(ElicitationResponseSchema);
104
105const defaultExecutionResolver: ResolveExecutionEnvironment = () =>
106 Effect.fail(
107 new Error("Execution environment resolver is not configured"),
108 );
109
110const defaultLiveExecutionManager = makeLiveExecutionManager();
111
112const 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
139export 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 @@
1import type {
2 CodeExecutor,
3 OnElicitation,
4 ToolCatalog,
5 ToolInvoker,
6} from "@executor-v3/codemode-core";
7import type { AccountId, ExecutionId, WorkspaceId } from "#schema";
8import * as Data from "effect/Data";
9import type * as Effect from "effect/Effect";
10
11export type ExecutionEnvironment = {
12 executor: CodeExecutor;
13 toolInvoker: ToolInvoker;
14 catalog?: ToolCatalog;
15};
16
17export type ResolveExecutionEnvironment = (input: {
18 workspaceId: WorkspaceId;
19 accountId: AccountId;
20 executionId: ExecutionId;
21 onElicitation?: OnElicitation;
22}) => Effect.Effect<ExecutionEnvironment, unknown>;
23
24export 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
24 makeSecretStore,24 makeSecretStore,
25 type SecretStore,25 type SecretStore,
26} from "./secret-store";26} from "./secret-store";
27import type { LocalInstallation } from "#schema";
28import {
29 getOrProvisionLocalInstallation,
30} from "./local-installation";
31import {
32 type ResolveExecutionEnvironment,
33} from "./execution-state";
34import {
35 makeLiveExecutionManager,
36} from "./live-execution";
27import { makeRuntimeControlPlaneService } from "./services";37import { makeRuntimeControlPlaneService } from "./services";
28 38
29export {39export {
@@ -35,12 +45,16 @@ export { · 5 unmodified lines
35};45};
36 46
37export type { SecretHandle, SecretProvider, SecretStore } from "./secret-store";47export type { SecretHandle, SecretProvider, SecretStore } from "./secret-store";
48export * from "./execution-state";
49export * from "./live-execution";
50export * from "./local-installation";
38export * from "./source-runtime";51export * from "./source-runtime";
39 52
40export type RuntimeControlPlaneInput = {53export type RuntimeControlPlaneInput = {
41 persistence: SqlControlPlanePersistence;54 persistence: SqlControlPlanePersistence;
42 actorResolver?: ControlPlaneActorResolverShape;55 actorResolver?: ControlPlaneActorResolverShape;
43 secretStore?: SecretStore;56 secretStore?: SecretStore;
57 executionResolver?: ResolveExecutionEnvironment;
44};58};
45 59
46export const makeRuntimeControlPlane = (60export const makeRuntimeControlPlane = (
@@ -51,7 +65,11 @@ export const makeRuntimeControlPlane = ( · 4 unmodified lines
51 secretStore: SecretStore;65 secretStore: SecretStore;
52 webHandler: ReturnType<typeof makeControlPlaneWebHandler>;66 webHandler: ReturnType<typeof makeControlPlaneWebHandler>;
53} => {67} => {
54 const service = makeRuntimeControlPlaneService(input.persistence.rows);68 const liveExecutionManager = makeLiveExecutionManager();
69 const service = makeRuntimeControlPlaneService(input.persistence.rows, {
70 executionResolver: input.executionResolver,
71 liveExecutionManager,
72 });
55 const actorResolver = input.actorResolver ?? makeHeaderActorResolver(input.persistence.rows);73 const actorResolver = input.actorResolver ?? makeHeaderActorResolver(input.persistence.rows);
56 const secretStore =74 const secretStore =
57 input.secretStore75 input.secretStore
@@ -75,6 +93,7 @@ export const makeRuntimeControlPlane = ( · 17 unmodified lines
75 93
76export type SqlControlPlaneRuntime = {94export type SqlControlPlaneRuntime = {
77 persistence: SqlControlPlanePersistence;95 persistence: SqlControlPlanePersistence;
96 localInstallation: LocalInstallation;
78 service: ControlPlaneServiceShape;97 service: ControlPlaneServiceShape;
79 actorResolver: ControlPlaneActorResolverShape;98 actorResolver: ControlPlaneActorResolverShape;
80 secretStore: SecretStore;99 secretStore: SecretStore;
@@ -85,24 +104,39 @@ export type SqlControlPlaneRuntime = { · 4 unmodified lines
85export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions & {104export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions & {
86 secretStore?: SecretStore;105 secretStore?: SecretStore;
87 actorResolver?: ControlPlaneActorResolverShape;106 actorResolver?: ControlPlaneActorResolverShape;
107 executionResolver?: ResolveExecutionEnvironment;
88};108};
89 109
90export const makeSqlControlPlaneRuntime = (110export const makeSqlControlPlaneRuntime = (
91 options: CreateSqlControlPlaneRuntimeOptions,111 options: CreateSqlControlPlaneRuntimeOptions,
92): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> =>112): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> =>
93 Effect.map(makeSqlControlPlanePersistence(options), (persistence) => {113 Effect.flatMap(makeSqlControlPlanePersistence(options), (persistence) =>
94 const runtime = makeRuntimeControlPlane({114 Effect.gen(function* () {
95 persistence,115 const runtime = makeRuntimeControlPlane({
96 actorResolver: options.actorResolver,116 persistence,
97 secretStore: options.secretStore,117 actorResolver: options.actorResolver,
98 });118 secretStore: options.secretStore,
119 executionResolver: options.executionResolver,
120 });
121 const localInstallation = yield* getOrProvisionLocalInstallation(persistence.rows).pipe(
122 Effect.mapError((cause) =>
123 new SqlPersistenceBootstrapError({
124 message: `Failed provisioning local installation: ${
125 cause instanceof Error ? cause.message : String(cause)
126 }`,
127 details: cause instanceof Error ? cause.message : String(cause),
128 }),
129 ),
130 );
99 131
100 return {132 return {
101 persistence,133 persistence,
102 service: runtime.service,134 localInstallation,
103 actorResolver: runtime.actorResolver,135 service: runtime.service,
104 secretStore: runtime.secretStore,136 actorResolver: runtime.actorResolver,
105 webHandler: runtime.webHandler,137 secretStore: runtime.secretStore,
106 close: () => persistence.close(),138 webHandler: runtime.webHandler,
107 };139 close: () => persistence.close(),
108 });140 };
141 })
142 );
packages/control-plane/src/runtime/live-execution.ts+177−0
@@ -0,0 +1,177 @@
1import type {
2 ElicitationResponse,
3 OnElicitation,
4} from "@executor-v3/codemode-core";
5import { type SqlControlPlaneRows } from "#persistence";
6import type {
7 Execution,
8 ExecutionInteraction,
9} from "#schema";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12
13type VisibleExecutionState =
14 | "waiting_for_interaction"
15 | "completed"
16 | "failed";
17
18type LiveRunEntry = {
19 stateWaiters: Array<Deferred.Deferred<VisibleExecutionState>>;
20 currentInteraction: {
21 interactionId: ExecutionInteraction["id"];
22 response: Deferred.Deferred<ElicitationResponse>;
23 } | null;
24};
25
26export 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
45const createEmptyRun = (): LiveRunEntry => ({
46 stateWaiters: [],
47 currentInteraction: null,
48});
49
50const serializeJson = (value: unknown): string | null => {
51 if (value === undefined) {
52 return null;
53 }
54
55 return JSON.stringify(value);
56};
57
58export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3
4import { makeSqlControlPlaneRuntime } from "./index";
5import { getOrProvisionLocalInstallation } from "./local-installation";
6
7const 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
19describe("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 @@
1import { type SqlControlPlaneRows } from "#persistence";
2import type {
3 Account,
4 LocalInstallation,
5 Organization,
6 OrganizationMembership,
7 Workspace,
8} from "#schema";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11
12const LOCAL_INSTALLATION_ID = "local_default" as LocalInstallation["id"];
13
14const 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
28const 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
41const 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
58const 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
71export 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
78export 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
109export 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
18import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
19import * as Option from "effect/Option";19import * as Option from "effect/Option";
20 20
21import { type ResolveExecutionEnvironment } from "./execution-state";
22import { type LiveExecutionManager } from "./live-execution";
23import { makeRuntimeExecutionsService } from "./execution-service";
21import { slugify } from "./slug";24import { slugify } from "./slug";
22 25
23const badRequest = (26const badRequest = (
@@ -857,10 +860,19 @@ export const makeRuntimePoliciesService = ( · 833 unmodified lines
857 860
858export const makeRuntimeControlPlaneService = (861export const makeRuntimeControlPlaneService = (
859 rows: SqlControlPlaneRows,862 rows: SqlControlPlaneRows,
863 options: {
864 executionResolver?: ResolveExecutionEnvironment;
865 liveExecutionManager?: LiveExecutionManager;
866 } = {},
860): ControlPlaneServiceShape => ({867): ControlPlaneServiceShape => ({
861 ...makeRuntimeOrganizationsService(rows),868 ...makeRuntimeOrganizationsService(rows),
862 ...makeRuntimeMembershipsService(rows),869 ...makeRuntimeMembershipsService(rows),
863 ...makeRuntimeWorkspacesService(rows),870 ...makeRuntimeWorkspacesService(rows),
864 ...makeRuntimeSourcesService(rows),871 ...makeRuntimeSourcesService(rows),
865 ...makeRuntimePoliciesService(rows),872 ...makeRuntimePoliciesService(rows),
873 ...makeRuntimeExecutionsService(
874 rows,
875 options.executionResolver,
876 options.liveExecutionManager,
877 ),
866});878});
packages/control-plane/src/runtime/test-http-client.ts+64−0
@@ -0,0 +1,64 @@
1import {
2 HttpApiBuilder,
3 HttpApiClient,
4 HttpClient,
5 HttpClientRequest,
6} from "@effect/platform";
7import { NodeHttpServer } from "@effect/platform-node";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import {
12 ControlPlaneActorResolver,
13 ControlPlaneApi,
14 ControlPlaneApiLive,
15 ControlPlaneService,
16} from "#api";
17
18import {
19 ControlPlaneAuthHeaders,
20 type SqlControlPlaneRuntime,
21} from "./index";
22
23const 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
40export 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
8export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));8export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));
9export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));9export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));
10export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));10export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));
11export const InstallationIdSchema = Schema.String.pipe(Schema.brand("InstallationId"));
12export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));
13export const ExecutionInteractionIdSchema = Schema.String.pipe(
14 Schema.brand("ExecutionInteractionId"),
15);
11 16
12export type AccountId = typeof AccountIdSchema.Type;17export type AccountId = typeof AccountIdSchema.Type;
13export type OrganizationId = typeof OrganizationIdSchema.Type;18export type OrganizationId = typeof OrganizationIdSchema.Type;
@@ -15,3 +20,6 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 1 unmodified line
15export type WorkspaceId = typeof WorkspaceIdSchema.Type;20export type WorkspaceId = typeof WorkspaceIdSchema.Type;
16export type SourceId = typeof SourceIdSchema.Type;21export type SourceId = typeof SourceIdSchema.Type;
17export type PolicyId = typeof PolicyIdSchema.Type;22export type PolicyId = typeof PolicyIdSchema.Type;
23export type InstallationId = typeof InstallationIdSchema.Type;
24export type ExecutionId = typeof ExecutionIdSchema.Type;
25export 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
6export * from "./models/workspace";6export * from "./models/workspace";
7export * from "./models/source";7export * from "./models/source";
8export * from "./models/policy";8export * from "./models/policy";
9export * from "./models/local-installation";
10export * from "./models/execution";
packages/control-plane/src/schema/models/auth/account.ts+34−0
@@ -0,0 +1,34 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";
7
8import { accountsTable } from "../../../persistence/schema";
9import { TimestampMsSchema } from "../../common";
10import { AccountIdSchema } from "../../ids";
11import { PrincipalProviderSchema } from "./principal";
12
13const 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
22export const AccountSchema = createSelectSchema(accountsTable, accountSchemaOverrides);
23
24export const AccountInsertSchema = createInsertSchema(
25 accountsTable,
26 accountSchemaOverrides,
27);
28
29export const AccountUpdateSchema = createUpdateSchema(
30 accountsTable,
31 accountSchemaOverrides,
32);
33
34export type Account = typeof AccountSchema.Type;
packages/control-plane/src/schema/models/auth/index.ts+1−0
@@ -1,3 +1,4 @@
1export * from "./account";
1export * from "./membership";2export * from "./membership";
2export * from "./organization";3export * from "./organization";
3export * from "./organization-membership";4export * from "./organization-membership";
packages/control-plane/src/schema/models/execution.ts+91−0
@@ -0,0 +1,91 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";
7
8import {
9 executionInteractionsTable,
10 executionsTable,
11} from "../../persistence/schema";
12import { TimestampMsSchema } from "../common";
13import {
14 AccountIdSchema,
15 ExecutionIdSchema,
16 ExecutionInteractionIdSchema,
17 WorkspaceIdSchema,
18} from "../ids";
19
20export const ExecutionStatusSchema = Schema.Literal(
21 "pending",
22 "running",
23 "waiting_for_interaction",
24 "completed",
25 "failed",
26 "cancelled",
27);
28
29const 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
43export const ExecutionSchema = createSelectSchema(
44 executionsTable,
45 executionSchemaOverrides,
46);
47
48export const ExecutionInsertSchema = createInsertSchema(
49 executionsTable,
50 executionSchemaOverrides,
51);
52
53export const ExecutionUpdateSchema = createUpdateSchema(
54 executionsTable,
55 executionSchemaOverrides,
56);
57
58export const ExecutionInteractionStatusSchema = Schema.Literal(
59 "pending",
60 "resolved",
61 "cancelled",
62);
63
64const 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
73export const ExecutionInteractionSchema = createSelectSchema(
74 executionInteractionsTable,
75 executionInteractionSchemaOverrides,
76);
77
78export const ExecutionInteractionInsertSchema = createInsertSchema(
79 executionInteractionsTable,
80 executionInteractionSchemaOverrides,
81);
82
83export const ExecutionInteractionUpdateSchema = createUpdateSchema(
84 executionInteractionsTable,
85 executionInteractionSchemaOverrides,
86);
87
88export type ExecutionStatus = typeof ExecutionStatusSchema.Type;
89export type Execution = typeof ExecutionSchema.Type;
90export type ExecutionInteractionStatus = typeof ExecutionInteractionStatusSchema.Type;
91export type ExecutionInteraction = typeof ExecutionInteractionSchema.Type;
packages/control-plane/src/schema/models/local-installation.ts+40−0
@@ -0,0 +1,40 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6
7import { localInstallationsTable } from "../../persistence/schema";
8import { TimestampMsSchema } from "../common";
9import {
10 AccountIdSchema,
11 InstallationIdSchema,
12 OrganizationIdSchema,
13 WorkspaceIdSchema,
14} from "../ids";
15
16const localInstallationSchemaOverrides = {
17 id: InstallationIdSchema,
18 accountId: AccountIdSchema,
19 organizationId: OrganizationIdSchema,
20 workspaceId: WorkspaceIdSchema,
21 createdAt: TimestampMsSchema,
22 updatedAt: TimestampMsSchema,
23} as const;
24
25export const LocalInstallationSchema = createSelectSchema(
26 localInstallationsTable,
27 localInstallationSchemaOverrides,
28);
29
30export const LocalInstallationInsertSchema = createInsertSchema(
31 localInstallationsTable,
32 localInstallationSchemaOverrides,
33);
34
35export const LocalInstallationUpdateSchema = createUpdateSchema(
36 localInstallationsTable,
37 localInstallationSchemaOverrides,
38);
39
40export type LocalInstallation = typeof LocalInstallationSchema.Type;