History

Merge pull request #21 from RhysSullivan/codex/local-first-config

Rhys Sullivan committed 8 months ago

Commit e5076902 parents fba2511 f858ab8106 files · +4916 −7687Browse files

Unified · Split

106 files
  1. apps/executor/src/cli/interaction-handling.test.ts +1 −1
  2. apps/executor/src/cli/main.ts +5 −5
  3. apps/executor/src/distribution/portable.test.ts +8 −3
  4. apps/executor/src/server/server.test.ts +158 −175
  5. bun.lock +5 −4
  6. packages/control-plane/package.json +1 −0
  7. packages/control-plane/src/api/api.ts +1 −7
  8. packages/control-plane/src/api/auth/actor-resolver.ts +0 −31
  9. packages/control-plane/src/api/auth/workspace-membership.test.ts +0 −59
  10. packages/control-plane/src/api/auth/workspace-membership.ts +0 −118
  11. packages/control-plane/src/api/executions/http.ts +12 −20
  12. packages/control-plane/src/api/http-auth.ts +0 −98
  13. packages/control-plane/src/api/http.ts +0 −14
  14. packages/control-plane/src/api/index.ts +0 −32
  15. packages/control-plane/src/api/local-context.ts +25 −0
  16. packages/control-plane/src/api/memberships/api.ts +0 −84
  17. packages/control-plane/src/api/memberships/http.ts +0 −67
  18. packages/control-plane/src/api/oauth/http.ts +5 −12
  19. packages/control-plane/src/api/organizations/api.ts +0 −82
  20. packages/control-plane/src/api/organizations/http.ts +0 −75
  21. packages/control-plane/src/api/payload-schemas.test.ts +6 −35
  22. packages/control-plane/src/api/policies/api.ts +13 −79
  23. packages/control-plane/src/api/policies/http.ts +12 −91
  24. packages/control-plane/src/api/sources/http.ts +44 −54
  25. packages/control-plane/src/api/workspaces/api.ts +0 −83
  26. packages/control-plane/src/api/workspaces/http.ts +0 −89
  27. packages/control-plane/src/auth-headers.ts +0 −7
  28. packages/control-plane/src/client.ts +2 −19
  29. packages/control-plane/src/domain/auth/actor.test.ts +0 −98
  30. packages/control-plane/src/domain/auth/actor.ts +0 −239
  31. packages/control-plane/src/domain/auth/index.ts +0 −21
  32. packages/control-plane/src/domain/auth/policy.test.ts +0 −34
  33. packages/control-plane/src/domain/auth/policy.ts +0 −55
  34. packages/control-plane/src/domain/index.ts +0 −1
  35. packages/control-plane/src/index.ts +0 −2
  36. packages/control-plane/src/persistence/control-plane-rows.test.ts +0 −947
  37. packages/control-plane/src/persistence/index.ts +0 −10
  38. packages/control-plane/src/persistence/post-migrations.test.ts +86 −108
  39. packages/control-plane/src/persistence/repos/index.ts +0 −5
  40. packages/control-plane/src/persistence/repos/local-installations-repo.ts +0 −48
  41. packages/control-plane/src/persistence/repos/organization-memberships-repo.ts +0 −110
  42. packages/control-plane/src/persistence/repos/organizations-repo.ts +0 −233
  43. packages/control-plane/src/persistence/repos/policies-repo.ts +0 −118
  44. packages/control-plane/src/persistence/repos/workspaces-repo.ts +0 −158
  45. packages/control-plane/src/persistence/schema.ts +0 −167
  46. packages/control-plane/src/runtime/actor-resolver.ts +0 −160
  47. packages/control-plane/src/runtime/control-plane-runtime.test.ts +97 −428
  48. packages/control-plane/src/runtime/execution-http.test.ts +7 −2
  49. packages/control-plane/src/runtime/execution-mcp-resume.test.ts +7 −2
  50. packages/control-plane/src/runtime/execution-service.ts +1 −1
  51. packages/control-plane/src/runtime/executor-tools.ts +29 −5
  52. packages/control-plane/src/runtime/index.ts +117 −34
  53. packages/control-plane/src/runtime/invocation-policy-engine.test.ts +25 −58
  54. packages/control-plane/src/runtime/invocation-policy-engine.ts +18 −195
  55. packages/control-plane/src/runtime/local-config-secrets.ts +9 −0
  56. packages/control-plane/src/runtime/local-config.test.ts +128 −0
  57. packages/control-plane/src/runtime/local-config.ts +450 −0
  58. packages/control-plane/src/runtime/local-errors.ts +54 −0
  59. packages/control-plane/src/runtime/local-installation.test.ts +22 −18
  60. packages/control-plane/src/runtime/local-installation.ts +25 −104
  61. packages/control-plane/src/runtime/local-operations.ts +13 −22
  62. packages/control-plane/src/runtime/local-runtime-context.ts +71 −0
  63. packages/control-plane/src/runtime/local-source-artifacts.ts +230 −0
  64. packages/control-plane/src/runtime/local-workspace-state.ts +124 −0
  65. packages/control-plane/src/runtime/local-workspace-sync.ts +93 −0
  66. packages/control-plane/src/runtime/operation-errors.ts +1 −1
  67. packages/control-plane/src/runtime/operations-shared.ts +1 −56
  68. packages/control-plane/src/runtime/organizations-operations.ts +0 −434
  69. packages/control-plane/src/runtime/policies-operations.ts +297 −306
  70. packages/control-plane/src/runtime/secret-material-providers.ts +178 −1
  71. packages/control-plane/src/runtime/source-adapters/mcp.ts +1 −1
  72. packages/control-plane/src/runtime/source-auth-service.test.ts +67 −60
  73. packages/control-plane/src/runtime/source-auth-service.ts +105 −62
  74. packages/control-plane/src/runtime/source-inspection.test.ts +150 −118
  75. packages/control-plane/src/runtime/source-inspection.ts +40 −51
  76. packages/control-plane/src/runtime/source-materialization.ts +82 −7
  77. packages/control-plane/src/runtime/source-recipes-runtime.test.ts +362 −244
  78. packages/control-plane/src/runtime/source-recipes-runtime.ts +116 −265
  79. packages/control-plane/src/runtime/source-store.test.ts +113 −348
  80. packages/control-plane/src/runtime/source-store.ts +508 −164
  81. packages/control-plane/src/runtime/sources-operations.ts +90 −85
  82. packages/control-plane/src/runtime/test-http-client.ts +5 −18
  83. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +316 −197
  84. packages/control-plane/src/runtime/workspace-execution-environment.ts +92 −81
  85. packages/control-plane/src/schema/enums.ts +4 −18
  86. packages/control-plane/src/schema/ids.ts +0 −8
  87. packages/control-plane/src/schema/index.ts +1 −1
  88. packages/control-plane/src/schema/models/auth/index.ts +0 −5
  89. packages/control-plane/src/schema/models/auth/membership.ts +0 −24
  90. packages/control-plane/src/schema/models/auth/organization-membership.ts +0 −55
  91. packages/control-plane/src/schema/models/auth/organization.ts +0 −42
  92. packages/control-plane/src/schema/models/auth/permission.ts +0 −36
  93. packages/control-plane/src/schema/models/auth/role.ts +0 −32
  94. packages/control-plane/src/schema/models/local-config.ts +185 −0
  95. packages/control-plane/src/schema/models/local-installation.ts +5 −29
  96. packages/control-plane/src/schema/models/policy.ts +18 −69
  97. packages/control-plane/src/schema/models/workspace.ts +0 −32
  98. packages/control-plane/src/schema/schema.test.ts +0 −9
  99. packages/executor-mcp/src/index.ts +14 −63
  100. packages/executor-mcp/src/paused-result.test.ts +97 −0
  101. packages/executor-mcp/src/paused-result.ts +95 −0
  102. packages/react/src/index.test.tsx +14 −4
  103. packages/react/src/index.ts +37 −25
  104. packages/runtime-ses/src/index.ts +0 −1
  105. packages/runtime-ses/src/sandbox-worker.mjs +9 −1
  106. packages/server/src/index.ts +9 −7
apps/executor/src/cli/interaction-handling.test.ts+1−1
@@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; · 2 unmodified lines
33 import { decideInteractionHandling } from "./interaction-handling";
44
55 describe("interaction-handling", () => {
6− it("keeps URL interactions distinct when the terminal is non-interactive", () => {
6+ it("keeps URL interactions distinct even when the terminal is non-interactive", () => {
77 expect(decideInteractionHandling({
88 parsed: {
99 mode: "url",
apps/executor/src/cli/main.ts+5−5
@@ -15,11 +15,11 @@ import { · 14 unmodified lines
1515 ExecutionIdSchema,
1616 RuntimeExecutionResolverService,
1717 createControlPlaneClient,
18− createSqlControlPlaneRuntime,
18+ createControlPlaneRuntime,
1919 type ControlPlaneClient,
2020 type ExecutionEnvelope,
2121 type ExecutionInteraction,
22− type SqlControlPlaneRuntime,
22+ type ControlPlaneRuntime,
2323 } from "@executor/control-plane";
2424 import type { ToolCatalog } from "@executor/codemode-core";
2525
@@ -247,7 +247,7 @@ const formatCatalogUnavailableMessage = (cause: Cause.Cause<unknown>): string => · 221 unmodified lines
247247 : `Current workspace catalog unavailable: ${message}`;
248248 };
249249
250−const closeSqlRuntime = (runtime: SqlControlPlaneRuntime) =>
250+const closeRuntime = (runtime: ControlPlaneRuntime) =>
251251 Effect.tryPromise({
252252 try: () => runtime.close(),
253253 catch: toError,
@@ -274,7 +274,7 @@ const buildRunWorkflowText = ( · 20 unmodified lines
274274
275275 const loadRunWorkflowText = (): Effect.Effect<string, Error, never> =>
276276 Effect.acquireUseRelease(
277− createSqlControlPlaneRuntime({
277+ createControlPlaneRuntime({
278278 localDataDir: DEFAULT_LOCAL_DATA_DIR,
279279 }).pipe(Effect.mapError(toError)),
280280 (runtime) =>
@@ -293,7 +293,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines
293293
294294 return yield* buildRunWorkflowText(environment.catalog);
295295 }),
296− closeSqlRuntime,
296+ closeRuntime,
297297 ).pipe(
298298 Effect.catchAllCause((cause) =>
299299 Effect.succeed(
apps/executor/src/distribution/portable.test.ts+8−3
@@ -100,7 +100,12 @@ describe("portable distribution flow", () => { · 99 unmodified lines
100100 const distDir = join(tempRoot, "dist");
101101 const homeDir = join(tempRoot, "home");
102102 const binDir = join(homeDir, ".local", "bin");
103− const installHome = join(homeDir, ".local", "share", "executor");
103+ const installHome = process.platform === "darwin"
104+ ? join(homeDir, "Library", "Application Support", "Executor")
105+ : join(homeDir, ".local", "share", "executor");
106+ const runHome = process.platform === "darwin"
107+ ? join(installHome, "run")
108+ : join(homeDir, ".local", "state", "executor", "run");
104109 const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor");
105110 const baseUrl = `http://127.0.0.1:${await allocatePort()}`;
106111
@@ -176,9 +181,9 @@ describe("portable distribution flow", () => { · 69 unmodified lines
176181 expect(status.reachable).toBe(true);
177182 expect(status.pidRunning).toBe(true);
178183 expect(status.installation).not.toBeNull();
179− expect(status.logFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true);
184+ expect(status.logFile.startsWith(runHome)).toBe(true);
180185 expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true);
181− expect(status.pidFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true);
186+ expect(status.pidFile.startsWith(runHome)).toBe(true);
182187
183188 const html = await fetch(new URL("/", baseUrl));
184189 expect(html.status).toBe(200);
apps/executor/src/server/server.test.ts+158−175
@@ -7,7 +7,9 @@ import { · 6 unmodified lines
77 HttpApiGroup,
88 HttpApiSchema,
99 OpenApi,
10+ FileSystem,
1011 } from "@effect/platform";
12+import { NodeFileSystem } from "@effect/platform-node";
1113 import { describe, expect, it } from "@effect/vitest";
1214 import { assertTrue } from "@effect/vitest/utils";
1315 import * as Effect from "effect/Effect";
@@ -19,10 +21,15 @@ import { · 5 unmodified lines
1921 createControlPlaneClient,
2022 controlPlaneOpenApiSpec,
2123 type ControlPlaneClient,
24+ buildLocalSourceArtifact,
25+ deriveLocalInstallation,
26+ materializationFromMcpManifestEntries,
2227 type ResolveExecutionEnvironment,
28+ resolveLocalWorkspaceContext,
2329 SourceIdSchema,
24− SourceRecipeIdSchema,
2530 SourceRecipeRevisionIdSchema,
31+ writeLocalSourceArtifact,
32+ writeProjectLocalExecutorConfig,
2633 } from "@executor/control-plane";
2734 import { makeSesExecutor } from "@executor/runtime-ses";
2835 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -58,7 +65,115 @@ const executionResolver: ResolveExecutionEnvironment = () => · 29 unmodified lines
5865 }),
5966 });
6067
61−const makeServer = createLocalExecutorServer({
68+const makeTempWorkspaceRoot = () =>
69+ FileSystem.FileSystem.pipe(
70+ Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "executor-server-test-" })),
71+ Effect.provide(NodeFileSystem.layer),
72+ );
73+
74+const createIsolatedLocalExecutorServer = (
75+ options: Parameters<typeof createLocalExecutorServer>[0] = {},
76+) =>
77+ Effect.gen(function* () {
78+ const workspaceRoot = yield* makeTempWorkspaceRoot();
79+ const server = yield* createLocalExecutorServer({
80+ ...options,
81+ workspaceRoot,
82+ });
83+ return {
84+ ...server,
85+ workspaceRoot,
86+ };
87+ });
88+
89+const writeConfiguredLocalMcpSource = (input: {
90+ workspaceRoot: string;
91+ sourceId: string;
92+ endpoint: string;
93+ name?: string;
94+ namespace?: string;
95+}) =>
96+ Effect.gen(function* () {
97+ const sourceId = SourceIdSchema.make(input.sourceId);
98+ const context = yield* resolveLocalWorkspaceContext({
99+ workspaceRoot: input.workspaceRoot,
100+ });
101+ const installation = deriveLocalInstallation(context);
102+
103+ yield* writeProjectLocalExecutorConfig({
104+ context,
105+ config: {
106+ sources: {
107+ [input.sourceId]: {
108+ kind: "mcp",
109+ name: input.name ?? "Demo",
110+ namespace: input.namespace ?? input.sourceId,
111+ connection: {
112+ endpoint: input.endpoint,
113+ },
114+ binding: {
115+ transport: "streamable-http",
116+ },
117+ },
118+ },
119+ },
120+ });
121+
122+ const source = {
123+ id: sourceId,
124+ workspaceId: installation.workspaceId,
125+ name: input.name ?? "Demo",
126+ kind: "mcp" as const,
127+ endpoint: input.endpoint,
128+ status: "connected" as const,
129+ enabled: true,
130+ namespace: input.namespace ?? input.sourceId,
131+ bindingVersion: 1,
132+ binding: {
133+ transport: "streamable-http",
134+ queryParams: null,
135+ headers: null,
136+ },
137+ importAuthPolicy: "reuse_runtime" as const,
138+ importAuth: { kind: "none" as const },
139+ auth: { kind: "none" as const },
140+ sourceHash: null,
141+ lastError: null,
142+ createdAt: Date.now(),
143+ updatedAt: Date.now(),
144+ };
145+
146+ const materialization = materializationFromMcpManifestEntries({
147+ recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"),
148+ endpoint: input.endpoint,
149+ manifestEntries: [{
150+ toolId: "gated_echo",
151+ toolName: "gated_echo",
152+ description: "Asks for approval before echoing a value",
153+ inputSchemaJson: JSON.stringify({
154+ type: "object",
155+ properties: {
156+ value: {
157+ type: "string",
158+ },
159+ },
160+ required: ["value"],
161+ additionalProperties: false,
162+ }),
163+ }],
164+ });
165+
166+ yield* writeLocalSourceArtifact({
167+ context,
168+ sourceId,
169+ artifact: buildLocalSourceArtifact({
170+ source,
171+ materialization,
172+ }),
173+ });
174+ });
175+
176+const makeServer = createIsolatedLocalExecutorServer({
62177 port: 0,
63178 localDataDir: ":memory:",
64179 executionResolver,
@@ -579,28 +694,6 @@ const extractUrlInteractionUrl = (payloadJson: string): string => { · 514 unmodified lines
579694 return url;
580695 };
581696
582−const waitForExecutionCompletion = (input: {
583− client: ControlPlaneClient;
584− workspaceId: string;
585− executionId: string;
586−}) =>
587− Effect.gen(function* () {
588− while (true) {
589− const next = yield* input.client.executions.get({
590− path: {
591− workspaceId: input.workspaceId as never,
592− executionId: input.executionId as never,
593− },
594− });
595−
596− if (next.execution.status !== "waiting_for_interaction") {
597− return next;
598− }
599−
600− yield* Effect.sleep("100 millis");
601− }
602− });
603−
604697 describe("local-executor-server", () => {
605698 it.scoped("serves the control-plane OpenAPI spec at /v1/openapi.json", () =>
606699 Effect.gen(function* () {
@@ -644,6 +737,7 @@ describe("local-executor-server", () => { · 37 unmodified lines
644737 expect(execution.execution.status).toBe("completed");
645738 expect(execution.execution.resultJson).toBe(JSON.stringify({ sum: 42 }));
646739 }),
740+ 15_000,
647741 );
648742
649743 it.scoped("includes completed MCP return values in text content", () =>
@@ -681,7 +775,7 @@ describe("local-executor-server", () => { · 31 unmodified lines
681775
682776 it.scoped("serves only execute over MCP when elicitation is supported", () =>
683777 Effect.gen(function* () {
684− const server = yield* createLocalExecutorServer({
778+ const server = yield* createIsolatedLocalExecutorServer({
685779 port: 0,
686780 localDataDir: ":memory:",
687781 executionResolver: gatedExecutionResolver,
@@ -738,7 +832,7 @@ describe("local-executor-server", () => { · 50 unmodified lines
738832
739833 it.scoped("serves execute and resume over MCP when elicitation is unavailable", () =>
740834 Effect.gen(function* () {
741− const server = yield* createLocalExecutorServer({
835+ const server = yield* createIsolatedLocalExecutorServer({
742836 port: 0,
743837 localDataDir: ":memory:",
744838 executionResolver: gatedExecutionResolver,
@@ -808,6 +902,7 @@ describe("local-executor-server", () => { · 63 unmodified lines
808902 expect(resumed.structuredContent?.result).toBe("approved:manual-mcp");
809903 expect(resumed.content?.find((item) => item.type === "text")?.text).toContain("approved:manual-mcp");
810904 }),
905+ 15_000,
811906 );
812907
813908 it.scoped("loads MCP sources from control-plane state and resumes elicitation", () =>
@@ -817,7 +912,7 @@ describe("local-executor-server", () => { · 3 unmodified lines
817912 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
818913 );
819914
820− const server = yield* createLocalExecutorServer({
915+ const server = yield* createIsolatedLocalExecutorServer({
821916 port: 0,
822917 localDataDir: ":memory:",
823918 });
@@ -898,7 +993,7 @@ describe("local-executor-server", () => { · 74 unmodified lines
898993 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
899994 );
900995
901− const server = yield* createLocalExecutorServer({
996+ const server = yield* createIsolatedLocalExecutorServer({
902997 port: 0,
903998 localDataDir: ":memory:",
904999 });
@@ -976,7 +1071,7 @@ describe("local-executor-server", () => { · 71 unmodified lines
9761071 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
9771072 );
9781073
979− const server = yield* createLocalExecutorServer({
1074+ const server = yield* createIsolatedLocalExecutorServer({
9801075 port: 0,
9811076 localDataDir: ":memory:",
9821077 });
@@ -1065,7 +1160,7 @@ describe("local-executor-server", () => { · 82 unmodified lines
10651160 }),
10661161 );
10671162
1068− const server = yield* createLocalExecutorServer({
1163+ const server = yield* createIsolatedLocalExecutorServer({
10691164 port: 0,
10701165 localDataDir: ":memory:",
10711166 });
@@ -1111,7 +1206,7 @@ describe("local-executor-server", () => { · 39 unmodified lines
11111206 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
11121207 );
11131208
1114− const server = yield* createLocalExecutorServer({
1209+ const server = yield* createIsolatedLocalExecutorServer({
11151210 port: 0,
11161211 localDataDir: ":memory:",
11171212 });
@@ -1148,29 +1243,28 @@ describe("local-executor-server", () => { · 30 unmodified lines
11481243 }),
11491244 );
11501245 assertTrue(callbackResponse.ok);
1151− expect(yield* Effect.promise(() => callbackResponse.text())).toContain("Source connected:");
1152−
1153− const completed = yield* waitForExecutionCompletion({
1154− client,
1155− workspaceId: installation.workspaceId,
1156− executionId: added.execution.id,
1157− });
1158−
1159− expect(completed.execution.status).toBe("completed");
1160− expect(completed.pendingInteraction).toBeNull();
1161− expect(completed.execution.resultJson).toContain('"name":"Axiom"');
1162− expect(completed.execution.resultJson).toContain('"status":"connected"');
1246+ const callbackText = yield* Effect.promise(() => callbackResponse.text());
1247+ expect(callbackText).toContain("Source connected:");
1248+
1249+ const connectedSource = yield* Effect.gen(function* () {
1250+ while (true) {
1251+ const sources = yield* client.sources.list({
1252+ path: {
1253+ workspaceId: installation.workspaceId,
1254+ },
1255+ });
1256+ const source = sources.find((entry) => entry.namespace === "axiom");
1257+ if (source?.status === "connected" && source.auth.kind === "oauth2") {
1258+ return source;
1259+ }
11631260
1164− const sources = yield* client.sources.list({
1165− path: {
1166− workspaceId: installation.workspaceId,
1167− },
1261+ yield* Effect.sleep("100 millis");
1262+ }
11681263 });
11691264
1170− expect(sources).toHaveLength(1);
1171− expect(sources[0]?.name).toBe("Axiom");
1172− expect(sources[0]?.status).toBe("connected");
1173− expect(sources[0]?.auth.kind).toBe("oauth2");
1265+ expect(connectedSource.name).toBe("Axiom");
1266+ expect(connectedSource.status).toBe("connected");
1267+ expect(connectedSource.auth.kind).toBe("oauth2");
11741268
11751269 const toolCall = yield* client.executions.create({
11761270 path: {
@@ -1188,13 +1282,13 @@ describe("local-executor-server", () => { · 11 unmodified lines
11881282 15_000,
11891283 );
11901284
1191− it.scoped("gates mutating OpenAPI tools by default and allows them via organization policy", () =>
1285+ it.scoped("gates mutating OpenAPI tools by default and allows them via workspace policy", () =>
11921286 Effect.gen(function* () {
11931287 const openApiServer = yield* Effect.acquireRelease(
11941288 Effect.promise(() => startMutatingOpenApiDemoServer()),
11951289 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
11961290 );
1197− const server = yield* createLocalExecutorServer({
1291+ const server = yield* createIsolatedLocalExecutorServer({
11981292 port: 0,
11991293 localDataDir: ":memory:",
12001294 });
@@ -1260,19 +1354,17 @@ describe("local-executor-server", () => { · 59 unmodified lines
12601354 expect(approved.execution.status).toBe("completed");
12611355 expect(openApiServer.createdBodies).toHaveLength(1);
12621356
1263− const policy = yield* client.policies.createOrganization({
1357+ const policy = yield* client.policies.create({
12641358 path: {
1265− organizationId: installation.organizationId,
1359+ workspaceId: installation.workspaceId,
12661360 },
12671361 payload: {
1268− resourceType: "tool_path",
12691362 resourcePattern: "dns.records.createRecord",
1270− matchType: "exact",
12711363 effect: "allow",
12721364 approvalMode: "auto",
12731365 },
12741366 });
1275− expect(policy.scopeType).toBe("organization");
1367+ expect(policy.key).toBe("dns.records.createRecord");
12761368
12771369 const automatic = yield* client.executions.create({
12781370 path: {
@@ -1287,6 +1379,7 @@ describe("local-executor-server", () => { · 8 unmodified lines
12871379 expect(automatic.pendingInteraction).toBeNull();
12881380 expect(openApiServer.createdBodies).toHaveLength(2);
12891381 }),
1382+ 15_000,
12901383 );
12911384
12921385 it.scoped("starts source OAuth without creating a source and stores secrets on callback", () =>
@@ -1296,7 +1389,7 @@ describe("local-executor-server", () => { · 3 unmodified lines
12961389 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
12971390 );
12981391
1299− const server = yield* createLocalExecutorServer({
1392+ const server = yield* createIsolatedLocalExecutorServer({
13001393 port: 0,
13011394 localDataDir: ":memory:",
13021395 });
@@ -1360,11 +1453,12 @@ describe("local-executor-server", () => { · 57 unmodified lines
13601453 expect(secrets.some((secret) => secret.purpose === "oauth_access_token")).toBe(true);
13611454 expect(secrets.some((secret) => secret.purpose === "oauth_refresh_token")).toBe(true);
13621455 }),
1456+ 15_000,
13631457 );
13641458
13651459 it.scoped("marks execution failed when a configured MCP endpoint is invalid", () =>
13661460 Effect.gen(function* () {
1367− const server = yield* createLocalExecutorServer({
1461+ const server = yield* createIsolatedLocalExecutorServer({
13681462 port: 0,
13691463 localDataDir: ":memory:",
13701464 });
@@ -1377,123 +1471,12 @@ describe("local-executor-server", () => { · 6 unmodified lines
13771471 baseUrl: server.baseUrl,
13781472 accountId: installation.accountId,
13791473 });
1380−
1381− const sourceId = SourceIdSchema.make("src_invalid_mcp_endpoint");
1382− const recipeId = SourceRecipeIdSchema.make(`src_recipe_${sourceId}`);
1383− const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
1384− const now = Date.now();
1385− const manifest = {
1386− version: 1 as const,
1387− tools: [{
1388− toolId: "gated_echo",
1389− toolName: "gated_echo",
1390− description: "Asks for approval before echoing a value",
1391− inputSchemaJson: JSON.stringify({
1392− type: "object",
1393− properties: {
1394− value: {
1395− type: "string",
1396− },
1397− },
1398− required: ["value"],
1399− additionalProperties: false,
1400− }),
1401− }],
1402− };
1403− const manifestJson = JSON.stringify(manifest);
1404− yield* server.runtime.persistence.rows.sourceRecipes.upsert({
1405− id: recipeId,
1406− kind: "mcp",
1407− adapterKey: "mcp",
1408− providerKey: "generic_mcp",
1409− name: "Demo",
1410− summary: null,
1411− visibility: "workspace",
1412− latestRevisionId: recipeRevisionId,
1413− createdAt: now,
1414− updatedAt: now,
1415− });
1416− yield* server.runtime.persistence.rows.sourceRecipeRevisions.upsert({
1417− id: recipeRevisionId,
1418− recipeId,
1419− revisionNumber: 1,
1420− sourceConfigJson: JSON.stringify({
1421− kind: "mcp",
1422− endpoint: "http://127.0.0.1:PORT/mcp",
1423− transport: "streamable-http",
1424− queryParams: null,
1425− headers: null,
1426− }),
1427− manifestJson,
1428− manifestHash: null,
1429− materializationHash: null,
1430− createdAt: now,
1431− updatedAt: now,
1432− });
1433− yield* server.runtime.persistence.rows.sources.insert({
1434− id: sourceId,
1435− workspaceId: installation.workspaceId,
1436− recipeId,
1437− recipeRevisionId,
1438− name: "Demo",
1439− kind: "mcp",
1474+ yield* writeConfiguredLocalMcpSource({
1475+ workspaceRoot: server.workspaceRoot,
1476+ sourceId: "demo",
14401477 endpoint: "http://127.0.0.1:PORT/mcp",
1441− status: "connected",
1442− enabled: true,
1478+ name: "Demo",
14431479 namespace: "demo",
1444− importAuthPolicy: "reuse_runtime",
1445− bindingConfigJson: JSON.stringify({
1446− adapterKey: "mcp",
1447− version: 1,
1448− payload: {
1449− transport: "streamable-http",
1450− queryParams: null,
1451− headers: null,
1452− },
1453− }),
1454− sourceHash: null,
1455− lastError: null,
1456− createdAt: now,
1457− updatedAt: now,
1458− });
1459− yield* server.runtime.persistence.rows.sourceRecipeDocuments.replaceForRevision({
1460− recipeRevisionId,
1461− documents: [{
1462− id: `src_recipe_doc_${sourceId}`,
1463− recipeRevisionId,
1464− documentKind: "mcp_manifest",
1465− documentKey: "manifest",
1466− contentText: manifestJson,
1467− contentHash: `manifest_${sourceId}`,
1468− fetchedAt: now,
1469− createdAt: now,
1470− updatedAt: now,
1471− }],
1472− });
1473− yield* server.runtime.persistence.rows.sourceRecipeOperations.replaceForRevision({
1474− recipeRevisionId,
1475− operations: [{
1476− id: `src_recipe_op_${sourceId}`,
1477− recipeRevisionId,
1478− operationKey: "gated_echo",
1479− transportKind: "mcp",
1480− toolId: "gated_echo",
1481− title: "gated_echo",
1482− description: "Asks for approval before echoing a value",
1483− operationKind: "unknown",
1484− searchText: "demo.gated_echo gated echo approval",
1485− inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null,
1486− outputSchemaJson: null,
1487− providerKind: "mcp",
1488− providerDataJson: JSON.stringify({
1489− kind: "mcp",
1490− toolId: "gated_echo",
1491− toolName: "gated_echo",
1492− description: "Asks for approval before echoing a value",
1493− }),
1494− createdAt: now,
1495− updatedAt: now,
1496− }],
14971480 });
14981481
14991482 const execution = yield* client.executions.create({
bun.lock+5−4
@@ -10,6 +10,9 @@ · 9 unmodified lines
1010 "typescript": "^5.9.3",
1111 },
1212 },
13+ "apps/docs": {
14+ "name": "@executor/docs",
15+ },
1316 "apps/executor": {
1417 "name": "executor",
1518 "version": "1.1.9",
@@ -79,9 +82,6 @@ · 63 unmodified lines
7982 "vite": "^7.3.1",
8083 },
8184 },
82− "docs": {
83− "name": "@executor/docs",
84− },
8585 "packages/ai-sdk-adapter": {
8686 "name": "@executor/ai-sdk-adapter",
8787 "dependencies": {
@@ -171,6 +171,7 @@ · 83 unmodified lines
171171 "drizzle-orm": "1.0.0-beta.15-859cf75",
172172 "effect": "catalog:",
173173 "graphql": "^16.12.0",
174+ "jsonc-parser": "3.3.1",
174175 "postgres": "^3.4.7",
175176 "prettier": "^3.8.1",
176177 },
@@ -496,7 +497,7 @@ · 319 unmodified lines
496497
497498 "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"],
498499
499− "@executor/docs": ["@executor/docs@workspace:docs"],
500+ "@executor/docs": ["@executor/docs@workspace:apps/docs"],
500501
501502 "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"],
502503
packages/control-plane/package.json+1−0
@@ -34,6 +34,7 @@ · 33 unmodified lines
3434 "drizzle-orm": "1.0.0-beta.15-859cf75",
3535 "effect": "catalog:",
3636 "graphql": "^16.12.0",
37+ "jsonc-parser": "3.3.1",
3738 "postgres": "^3.4.7",
3839 "prettier": "^3.8.1"
3940 },
packages/control-plane/src/api/api.ts+1−7
@@ -2,26 +2,20 @@ import { HttpApi, OpenApi } from "@effect/platform"; · 1 unmodified line
22
33 import { ExecutionsApi } from "./executions/api";
44 import { LocalApi } from "./local/api";
5−import { MembershipsApi } from "./memberships/api";
65 import { OAuthApi } from "./oauth/api";
7−import { OrganizationsApi } from "./organizations/api";
86 import { PoliciesApi } from "./policies/api";
97 import { SourcesApi } from "./sources/api";
10−import { WorkspacesApi } from "./workspaces/api";
118
129 export class ControlPlaneApi extends HttpApi.make("controlPlane")
1310 .add(LocalApi)
1411 .add(OAuthApi)
15− .add(OrganizationsApi)
16− .add(MembershipsApi)
17− .add(WorkspacesApi)
1812 .add(SourcesApi)
1913 .add(PoliciesApi)
2014 .add(ExecutionsApi)
2115 .annotateContext(
2216 OpenApi.annotations({
2317 title: "Executor Control Plane API",
24− description: "CRUD control plane for organizations, workspaces, sources, and policies",
18+ description: "Local-first control plane for workspace sources, policies, auth, and execution",
2519 }),
2620 ) {}
2721
packages/control-plane/src/api/auth/actor-resolver.ts+0−31
@@ -1,31 +0,0 @@
1−import { type WorkspaceId } from "#schema";
2−import * as PlatformHeaders from "@effect/platform/Headers";
3−import * as Context from "effect/Context";
4−import * as Effect from "effect/Effect";
5−
6−import {
7− type ActorShape,
8− type ActorUnauthenticatedError,
9−} from "#domain";
10−
11−export type ResolveActorInput = {
12− headers: PlatformHeaders.Headers;
13−};
14−
15−export type ResolveWorkspaceActorInput = {
16− workspaceId: WorkspaceId;
17− headers: PlatformHeaders.Headers;
18−};
19−
20−export type ControlPlaneActorResolverShape = {
21− resolveActor: (
22− input: ResolveActorInput,
23− ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
24− resolveWorkspaceActor: (
25− input: ResolveWorkspaceActorInput,
26− ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
27−};
28−
29−export class ControlPlaneActorResolver extends Context.Tag(
30− "#api/ControlPlaneActorResolver",
31−)<ControlPlaneActorResolver, ControlPlaneActorResolverShape>() {}
packages/control-plane/src/api/auth/workspace-membership.test.ts+0−59
@@ -1,59 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import {
3− AccountIdSchema,
4− OrganizationIdSchema,
5− OrganizationMemberIdSchema,
6− WorkspaceIdSchema,
7−} from "#schema";
8−
9−import { deriveWorkspaceMembershipsForPrincipal } from "./workspace-membership";
10−
11−describe("workspace membership derivation", () => {
12− it("prefers highest active role in matching organization", () => {
13− const principalAccountId = AccountIdSchema.make("acc_1");
14− const workspaceId = WorkspaceIdSchema.make("ws_1");
15− const organizationId = OrganizationIdSchema.make("org_1");
16−
17− const memberships = deriveWorkspaceMembershipsForPrincipal({
18− principalAccountId,
19− workspaceId,
20− workspace: {
21− id: workspaceId,
22− organizationId,
23− name: "Main",
24− createdByAccountId: null,
25− createdAt: 1,
26− updatedAt: 1,
27− },
28− organizationMemberships: [
29− {
30− id: OrganizationMemberIdSchema.make("mem_1"),
31− organizationId,
32− accountId: principalAccountId,
33− role: "viewer",
34− status: "active",
35− billable: true,
36− invitedByAccountId: null,
37− joinedAt: 1,
38− createdAt: 1,
39− updatedAt: 1,
40− },
41− {
42− id: OrganizationMemberIdSchema.make("mem_2"),
43− organizationId,
44− accountId: principalAccountId,
45− role: "admin",
46− status: "active",
47− billable: true,
48− invitedByAccountId: null,
49− joinedAt: 2,
50− createdAt: 2,
51− updatedAt: 2,
52− },
53− ],
54− });
55−
56− expect(memberships).toHaveLength(1);
57− expect(memberships[0]?.role).toBe("admin");
58− });
59−});
packages/control-plane/src/api/auth/workspace-membership.ts+0−118
@@ -1,118 +0,0 @@
1−import {
2− type AccountId,
3− type OrganizationMembership,
4− type OrganizationMemberStatus,
5− type Role,
6− type Workspace,
7− type WorkspaceMembership,
8−} from "#schema";
9−
10−type WorkspaceMembershipCandidate = Pick<
11− WorkspaceMembership,
12− "role" | "status" | "grantedAt" | "updatedAt"
13−>;
14−
15−const roleRank: Readonly<Record<Role, number>> = {
16− viewer: 0,
17− editor: 1,
18− admin: 2,
19− owner: 3,
20−};
21−
22−const toWorkspaceMembershipStatus = (
23− status: OrganizationMemberStatus,
24−): WorkspaceMembership["status"] => {
25− switch (status) {
26− case "active":
27− return "active";
28− case "suspended":
29− return "suspended";
30− default:
31− return "revoked";
32− }
33−};
34−
35−const compareMembershipPriority = (
36− left: WorkspaceMembershipCandidate,
37− right: WorkspaceMembershipCandidate,
38−): number => {
39− const rankDelta = roleRank[left.role] - roleRank[right.role];
40− if (rankDelta !== 0) {
41− return rankDelta;
42− }
43−
44− return left.updatedAt - right.updatedAt;
45−};
46−
47−export const deriveWorkspaceMembershipsForPrincipal = (input: {
48− principalAccountId: AccountId;
49− workspaceId: Workspace["id"];
50− workspace: Workspace | null;
51− organizationMemberships: ReadonlyArray<OrganizationMembership>;
52−}): ReadonlyArray<WorkspaceMembership> => {
53− if (input.workspace === null) {
54− return [];
55− }
56−
57− const workspace = input.workspace;
58− const candidates: Array<WorkspaceMembershipCandidate> = [];
59−
60− const hasActiveOrganizationMembership = input.organizationMemberships.some(
61− (membership) =>
62− membership.accountId === input.principalAccountId
63− && membership.organizationId === workspace.organizationId
64− && membership.status === "active",
65− );
66−
67− if (
68− workspace.createdByAccountId === input.principalAccountId
69− && hasActiveOrganizationMembership
70− ) {
71− candidates.push({
72− role: "owner",
73− status: "active",
74− grantedAt: workspace.createdAt,
75− updatedAt: workspace.updatedAt,
76− });
77− }
78−
79− for (const membership of input.organizationMemberships) {
80− if (membership.accountId !== input.principalAccountId) {
81− continue;
82− }
83−
84− if (membership.organizationId !== workspace.organizationId) {
85− continue;
86− }
87−
88− candidates.push({
89− role: membership.role,
90− status: toWorkspaceMembershipStatus(membership.status),
91− grantedAt: membership.joinedAt ?? membership.createdAt,
92− updatedAt: membership.updatedAt,
93− });
94− }
95−
96− const activeCandidates = candidates.filter(
97− (candidate) => candidate.status === "active",
98− );
99−
100− if (activeCandidates.length === 0) {
101− return [];
102− }
103−
104− const selected = activeCandidates.reduce((best, candidate) =>
105− compareMembershipPriority(candidate, best) > 0 ? candidate : best,
106− );
107−
108− return [
109− {
110− accountId: input.principalAccountId,
111− workspaceId: input.workspaceId,
112− role: selected.role,
113− status: selected.status,
114− grantedAt: selected.grantedAt,
115− updatedAt: selected.updatedAt,
116− },
117− ];
118−};
packages/control-plane/src/api/executions/http.ts+12−20
@@ -1,7 +1,5 @@
11 import { HttpApiBuilder } from "@effect/platform";
2−import type { WorkspaceId } from "#schema";
3−
4−import { requirePermission, withPolicy } from "#domain";
2+import * as Effect from "effect/Effect";
53 import {
64 createExecution,
75 getExecution,
@@ -9,13 +7,7 @@ import { · 1 unmodified line
97 } from "../../runtime/execution-service";
108
119 import { ControlPlaneApi } from "../api";
12−import { withWorkspaceRequestActor } from "../http-auth";
13−
14−const requireExecuteWorkspace = (workspaceId: WorkspaceId) =>
15− requirePermission({
16− permission: "workspace:read",
17− workspaceId,
18− });
10+import { resolveRequestedLocalWorkspace } from "../local-context";
1911
2012 export const ControlPlaneExecutionsLive = HttpApiBuilder.group(
2113 ControlPlaneApi,
@@ -23,19 +15,19 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 1 unmodified line
2315 (handlers) =>
2416 handlers
2517 .handle("create", ({ path, payload }) =>
26− withWorkspaceRequestActor("executions.create", path.workspaceId, (actor) =>
27− withPolicy(requireExecuteWorkspace(path.workspaceId))(
18+ resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe(
19+ Effect.flatMap((runtimeLocalWorkspace) =>
2820 createExecution({
2921 workspaceId: path.workspaceId,
3022 payload,
31− createdByAccountId: actor.principal.accountId,
32− }),
23+ createdByAccountId: runtimeLocalWorkspace.installation.accountId,
24+ })
3325 ),
3426 ),
3527 )
3628 .handle("get", ({ path }) =>
37− withWorkspaceRequestActor("executions.get", path.workspaceId, () =>
38− withPolicy(requireExecuteWorkspace(path.workspaceId))(
29+ resolveRequestedLocalWorkspace("executions.get", path.workspaceId).pipe(
30+ Effect.zipRight(
3931 getExecution({
4032 workspaceId: path.workspaceId,
4133 executionId: path.executionId,
@@ -44,14 +36,14 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 2 unmodified lines
4436 ),
4537 )
4638 .handle("resume", ({ path, payload }) =>
47− withWorkspaceRequestActor("executions.resume", path.workspaceId, (actor) =>
48− withPolicy(requireExecuteWorkspace(path.workspaceId))(
39+ resolveRequestedLocalWorkspace("executions.resume", path.workspaceId).pipe(
40+ Effect.flatMap((runtimeLocalWorkspace) =>
4941 resumeExecution({
5042 workspaceId: path.workspaceId,
5143 executionId: path.executionId,
5244 payload,
53− resumedByAccountId: actor.principal.accountId,
54− }),
45+ resumedByAccountId: runtimeLocalWorkspace.installation.accountId,
46+ })
5547 ),
5648 ),
5749 ),
packages/control-plane/src/api/http-auth.ts+0−98
@@ -1,98 +0,0 @@
1−import { HttpServerRequest } from "@effect/platform";
2−import type { WorkspaceId } from "#schema";
3−import {
4− Actor,
5− ActorForbiddenError,
6− type ActorShape,
7− ActorUnauthenticatedError,
8−} from "#domain";
9−import * as Effect from "effect/Effect";
10−
11−import { ControlPlaneActorResolver } from "./auth/actor-resolver";
12−import {
13− ControlPlaneForbiddenError,
14− ControlPlaneUnauthorizedError,
15−} from "./errors";
16−
17−const requestHeaders = Effect.map(
18− HttpServerRequest.HttpServerRequest,
19− (request) => request.headers,
20−);
21−
22−const readString = (value: unknown, fallback: string): string =>
23− typeof value === "string" && value.length > 0 ? value : fallback;
24−
25−const toForbiddenError = (
26− operation: string,
27− cause: unknown,
28−): ControlPlaneForbiddenError =>
29− new ControlPlaneForbiddenError({
30− operation,
31− message: "Access denied",
32− details: `${readString((cause as { permission?: unknown })?.permission, "unknown")} on ${readString((cause as { scope?: unknown })?.scope, "unknown")}`,
33− });
34−
35−const toUnauthorizedError = (
36− operation: string,
37− cause: unknown,
38−): ControlPlaneUnauthorizedError =>
39− new ControlPlaneUnauthorizedError({
40− operation,
41− message: readString((cause as { message?: unknown })?.message, "Authentication required"),
42− details: "Authentication required",
43− });
44−
45−export const resolveRequestActor = Effect.flatMap(
46− ControlPlaneActorResolver,
47− (actorResolver) =>
48− Effect.flatMap(requestHeaders, (headers) => actorResolver.resolveActor({ headers })),
49−);
50−
51−export const resolveWorkspaceRequestActor = (workspaceId: WorkspaceId) =>
52− Effect.flatMap(ControlPlaneActorResolver, (actorResolver) =>
53− Effect.flatMap(requestHeaders, (headers) =>
54− actorResolver.resolveWorkspaceActor({ workspaceId, headers })
55− ));
56−
57−const provideActor = <A, E, R>(
58− actor: ActorShape,
59− effect: Effect.Effect<A, E, R>,
60−) => effect.pipe(Effect.provideService(Actor, actor));
61−
62−export const mapActorHttpErrors = <A, E, R>(
63− operation: string,
64− effect: Effect.Effect<A, E | ActorUnauthenticatedError | ActorForbiddenError, R>,
65−): Effect.Effect<A, E | ControlPlaneUnauthorizedError | ControlPlaneForbiddenError, R> =>
66− effect.pipe(
67− Effect.catchTag("ActorUnauthenticatedError", (cause) =>
68− Effect.fail(toUnauthorizedError(operation, cause)),
69− ),
70− Effect.catchTag("ActorForbiddenError", (cause) =>
71− Effect.fail(toForbiddenError(operation, cause)),
72− ),
73− );
74−
75−export const withRequestActor = <A, E, R>(
76− operation: string,
77− run: (actor: ActorShape) => Effect.Effect<A, E | ActorForbiddenError, R | Actor>,
78−) =>
79− mapActorHttpErrors(
80− operation,
81− Effect.gen(function* () {
82− const actor = yield* resolveRequestActor;
83− return yield* provideActor(actor, run(actor));
84− }),
85− );
86−
87−export const withWorkspaceRequestActor = <A, E, R>(
88− operation: string,
89− workspaceId: WorkspaceId,
90− run: (actor: ActorShape) => Effect.Effect<A, E | ActorForbiddenError, R | Actor>,
91−) =>
92− mapActorHttpErrors(
93− operation,
94− Effect.gen(function* () {
95− const actor = yield* resolveWorkspaceRequestActor(workspaceId);
96− return yield* provideActor(actor, run(actor));
97− }),
98− );
packages/control-plane/src/api/http.ts+0−14
@@ -2,25 +2,15 @@ import { HttpApiBuilder } from "@effect/platform"; · 1 unmodified line
22 import * as Layer from "effect/Layer";
33
44 import { ControlPlaneApi } from "./api";
5−import {
6− ControlPlaneActorResolver,
7− type ControlPlaneActorResolverShape,
8−} from "./auth/actor-resolver";
95 import { ControlPlaneExecutionsLive } from "./executions/http";
106 import { ControlPlaneLocalLive } from "./local/http";
11−import { ControlPlaneMembershipsLive } from "./memberships/http";
127 import { ControlPlaneOAuthLive } from "./oauth/http";
13−import { ControlPlaneOrganizationsLive } from "./organizations/http";
148 import { ControlPlanePoliciesLive } from "./policies/http";
159 import { ControlPlaneSourcesLive } from "./sources/http";
16−import { ControlPlaneWorkspacesLive } from "./workspaces/http";
1710
1811 export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(
1912 Layer.provide(ControlPlaneLocalLive),
2013 Layer.provide(ControlPlaneOAuthLive),
21− Layer.provide(ControlPlaneOrganizationsLive),
22− Layer.provide(ControlPlaneMembershipsLive),
23− Layer.provide(ControlPlaneWorkspacesLive),
2414 Layer.provide(ControlPlaneSourcesLive),
2515 Layer.provide(ControlPlanePoliciesLive),
2616 Layer.provide(ControlPlaneExecutionsLive),
@@ -40,7 +30,3 @@ export const createControlPlaneApiLayer = <ERuntime>( · 13 unmodified lines
4030 ControlPlaneApiLive.pipe(
4131 Layer.provide(runtimeLayer),
4232 );
43−
44−export const ControlPlaneActorResolverLive = (
45− resolver: ControlPlaneActorResolverShape,
46−) => Layer.succeed(ControlPlaneActorResolver, resolver);
packages/control-plane/src/api/index.ts+0−32
@@ -17,20 +17,9 @@ export { · 16 unmodified lines
1717 ControlPlaneApiLive,
1818 type ControlPlaneApiRuntimeContext,
1919 type BuiltControlPlaneApiLayer,
20− ControlPlaneActorResolverLive,
2120 createControlPlaneApiLayer,
2221 } from "./http";
2322
24−export {
25− ControlPlaneActorResolver,
26− type ControlPlaneActorResolverShape,
27− type ResolveActorInput,
28− type ResolveWorkspaceActorInput,
29−} from "./auth/actor-resolver";
30−
31−export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership";
32−
33−
3423 export {
3524 CreateExecutionPayloadSchema,
3625 ResumeExecutionPayloadSchema,
@@ -60,27 +49,6 @@ export { · 23 unmodified lines
6049 type CompleteSourceOAuthResult,
6150 } from "./oauth/api";
6251
63−export {
64− CreateOrganizationPayloadSchema,
65− UpdateOrganizationPayloadSchema,
66− type CreateOrganizationPayload,
67− type UpdateOrganizationPayload,
68−} from "./organizations/api";
69−
70−export {
71− CreateMembershipPayloadSchema,
72− UpdateMembershipPayloadSchema,
73− type CreateMembershipPayload,
74− type UpdateMembershipPayload,
75−} from "./memberships/api";
76−
77−export {
78− CreateWorkspacePayloadSchema,
79− UpdateWorkspacePayloadSchema,
80− type CreateWorkspacePayload,
81− type UpdateWorkspacePayload,
82−} from "./workspaces/api";
83−
8452 export {
8553 ConnectSourcePayloadSchema,
8654 ConnectSourceResultSchema,
packages/control-plane/src/api/local-context.ts+25−0
@@ -0,0 +1,25 @@
1+import type { WorkspaceId } from "#schema";
2+import * as Effect from "effect/Effect";
3+
4+import {
5+ RuntimeLocalWorkspaceMismatchError,
6+} from "../runtime/local-errors";
7+import { requireRuntimeLocalWorkspace } from "../runtime/local-runtime-context";
8+import { ControlPlaneForbiddenError } from "./errors";
9+
10+export const resolveRequestedLocalWorkspace = (
11+ operation: string,
12+ workspaceId: WorkspaceId,
13+) =>
14+ requireRuntimeLocalWorkspace(workspaceId).pipe(
15+ Effect.mapError((cause) =>
16+ new ControlPlaneForbiddenError({
17+ operation,
18+ message: "Requested workspace is not the active local workspace",
19+ details:
20+ cause instanceof RuntimeLocalWorkspaceMismatchError
21+ ? `requestedWorkspaceId=${cause.requestedWorkspaceId} activeWorkspaceId=${cause.activeWorkspaceId}`
22+ : "Runtime local workspace is unavailable",
23+ })
24+ ),
25+ );
packages/control-plane/src/api/memberships/api.ts+0−84
@@ -1,84 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2−import {
3− AccountIdSchema,
4− OrganizationIdSchema,
5− OrganizationMembershipInsertSchema,
6− OrganizationMembershipSchema,
7− OrganizationMembershipUpdateSchema,
8−} from "#schema";
9−import * as Schema from "effect/Schema";
10−
11−import {
12− ControlPlaneBadRequestError,
13− ControlPlaneForbiddenError,
14− ControlPlaneNotFoundError,
15− ControlPlaneStorageError,
16− ControlPlaneUnauthorizedError,
17−} from "../errors";
18−
19−const createMembershipPayloadRequiredSchema = OrganizationMembershipInsertSchema.pipe(
20− Schema.pick("accountId", "role"),
21−);
22−
23−const createMembershipPayloadOptionalSchema = OrganizationMembershipInsertSchema.pipe(
24− Schema.pick("status", "billable", "invitedByAccountId"),
25− Schema.partialWith({ exact: true }),
26−);
27−
28−export const CreateMembershipPayloadSchema = Schema.extend(
29− createMembershipPayloadRequiredSchema,
30− createMembershipPayloadOptionalSchema,
31−);
32−
33−export type CreateMembershipPayload = typeof CreateMembershipPayloadSchema.Type;
34−
35−export const UpdateMembershipPayloadSchema = OrganizationMembershipUpdateSchema.pipe(
36− Schema.pick("role", "status", "billable"),
37− Schema.partialWith({ exact: true }),
38−);
39−
40−export type UpdateMembershipPayload = typeof UpdateMembershipPayloadSchema.Type;
41−
42−const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
43−const accountIdParam = HttpApiSchema.param("accountId", AccountIdSchema);
44−
45−export class MembershipsApi extends HttpApiGroup.make("memberships")
46− .add(
47− HttpApiEndpoint.get("list")`/organizations/${organizationIdParam}/memberships`
48− .addSuccess(Schema.Array(OrganizationMembershipSchema))
49− .addError(ControlPlaneBadRequestError)
50− .addError(ControlPlaneUnauthorizedError)
51− .addError(ControlPlaneForbiddenError)
52− .addError(ControlPlaneNotFoundError)
53− .addError(ControlPlaneStorageError),
54− )
55− .add(
56− HttpApiEndpoint.post("create")`/organizations/${organizationIdParam}/memberships`
57− .setPayload(CreateMembershipPayloadSchema)
58− .addSuccess(OrganizationMembershipSchema)
59− .addError(ControlPlaneBadRequestError)
60− .addError(ControlPlaneUnauthorizedError)
61− .addError(ControlPlaneForbiddenError)
62− .addError(ControlPlaneNotFoundError)
63− .addError(ControlPlaneStorageError),
64− )
65− .add(
66− HttpApiEndpoint.patch("update")`/organizations/${organizationIdParam}/memberships/${accountIdParam}`
67− .setPayload(UpdateMembershipPayloadSchema)
68− .addSuccess(OrganizationMembershipSchema)
69− .addError(ControlPlaneBadRequestError)
70− .addError(ControlPlaneUnauthorizedError)
71− .addError(ControlPlaneForbiddenError)
72− .addError(ControlPlaneNotFoundError)
73− .addError(ControlPlaneStorageError),
74− )
75− .add(
76− HttpApiEndpoint.del("remove")`/organizations/${organizationIdParam}/memberships/${accountIdParam}`
77− .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
78− .addError(ControlPlaneBadRequestError)
79− .addError(ControlPlaneUnauthorizedError)
80− .addError(ControlPlaneForbiddenError)
81− .addError(ControlPlaneNotFoundError)
82− .addError(ControlPlaneStorageError),
83− )
84− .prefix("/v1") {}
packages/control-plane/src/api/memberships/http.ts+0−67
@@ -1,67 +0,0 @@
1−import { HttpApiBuilder } from "@effect/platform";
2−import type { OrganizationId } from "#schema";
3−
4−import { requirePermission, withPolicy } from "#domain";
5−import {
6− createMembership,
7− listMemberships,
8− removeMembership,
9− updateMembership,
10−} from "../../runtime/organizations-operations";
11−
12−import { ControlPlaneApi } from "../api";
13−import { withRequestActor } from "../http-auth";
14−
15−const requireReadMemberships = (organizationId: OrganizationId) =>
16− requirePermission({
17− permission: "memberships:read",
18− organizationId,
19− });
20−
21−const requireWriteMemberships = (organizationId: OrganizationId) =>
22− requirePermission({
23− permission: "memberships:write",
24− organizationId,
25− });
26−
27−export const ControlPlaneMembershipsLive = HttpApiBuilder.group(
28− ControlPlaneApi,
29− "memberships",
30− (handlers) =>
31− handlers
32− .handle("list", ({ path }) =>
33− withRequestActor("memberships.list", () =>
34− withPolicy(requireReadMemberships(path.organizationId))(
35− listMemberships(path.organizationId),
36− ),
37− ),
38− )
39− .handle("create", ({ path, payload }) =>
40− withRequestActor("memberships.create", () =>
41− withPolicy(requireWriteMemberships(path.organizationId))(
42− createMembership({ organizationId: path.organizationId, payload }),
43− ),
44− ),
45− )
46− .handle("update", ({ path, payload }) =>
47− withRequestActor("memberships.update", () =>
48− withPolicy(requireWriteMemberships(path.organizationId))(
49− updateMembership({
50− organizationId: path.organizationId,
51− accountId: path.accountId,
52− payload,
53− }),
54− ),
55− ),
56− )
57− .handle("remove", ({ path }) =>
58− withRequestActor("memberships.remove", () =>
59− withPolicy(requireWriteMemberships(path.organizationId))(
60− removeMembership({
61− organizationId: path.organizationId,
62− accountId: path.accountId,
63− }),
64− ),
65− ),
66− ),
67−);
packages/control-plane/src/api/oauth/http.ts+5−12
@@ -8,27 +8,20 @@ import * as Cause from "effect/Cause"; · 7 unmodified lines
88 import * as Effect from "effect/Effect";
99 import * as Schema from "effect/Schema";
1010
11−import { requirePermission, withPolicy } from "#domain";
1211 import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service";
1312 import { ControlPlaneApi } from "../api";
1413 import {
1514 ControlPlaneBadRequestError,
1615 ControlPlaneStorageError,
1716 } from "../errors";
17+import { resolveRequestedLocalWorkspace } from "../local-context";
1818 import {
1919 SourceOAuthPopupResultSchema,
2020 type SourceOAuthPopupResult,
2121 } from "./api";
22−import { withWorkspaceRequestActor } from "../http-auth";
2322
2423 const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
2524
26−const requireWriteSources = (workspaceId: WorkspaceId) =>
27− requirePermission({
28− permission: "sources:write",
29− workspaceId,
30− });
31−
3225 const escapeHtml = (value: string): string =>
3326 value
3427 .replaceAll("&", "&amp;")
@@ -241,14 +234,14 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 206 unmodified lines
241234 (handlers) =>
242235 handlers
243236 .handle("startSourceAuth", ({ path, payload }) =>
244− withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) =>
245− withPolicy(requireWriteSources(path.workspaceId))(
237+ resolveRequestedLocalWorkspace("oauth.start_source_auth", path.workspaceId).pipe(
238+ Effect.flatMap((runtimeLocalWorkspace) =>
246239 Effect.gen(function* () {
247240 const request = yield* HttpServerRequest.HttpServerRequest;
248241 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
249242 return yield* sourceAuthService.startSourceOAuthSession({
250243 workspaceId: path.workspaceId,
251− actorAccountId: actor.principal.accountId,
244+ actorAccountId: runtimeLocalWorkspace.installation.accountId,
252245 baseUrl: resolveRequestOrigin(request),
253246 displayName: payload.name,
254247 provider: {
@@ -270,7 +263,7 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 15 unmodified lines
270263 }),
271264 ),
272265 ),
273− ),
266+ )
274267 ),
275268 ),
276269 )
packages/control-plane/src/api/organizations/api.ts+0−82
@@ -1,82 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2−import {
3− OrganizationIdSchema,
4− OrganizationStatusSchema,
5− OrganizationSchema,
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−import {
17− OptionalTrimmedNonEmptyStringSchema,
18− TrimmedNonEmptyStringSchema,
19−} from "../string-schemas";
20−
21−export const CreateOrganizationPayloadSchema = Schema.Struct({
22− name: TrimmedNonEmptyStringSchema,
23− slug: OptionalTrimmedNonEmptyStringSchema,
24−});
25−
26−export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type;
27−
28−export const UpdateOrganizationPayloadSchema = Schema.Struct({
29− name: OptionalTrimmedNonEmptyStringSchema,
30− status: Schema.optional(OrganizationStatusSchema),
31−});
32−
33−export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type;
34−
35−const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
36−
37−export class OrganizationsApi extends HttpApiGroup.make("organizations")
38− .add(
39− HttpApiEndpoint.get("list")`/organizations`
40− .addSuccess(Schema.Array(OrganizationSchema))
41− .addError(ControlPlaneBadRequestError)
42− .addError(ControlPlaneUnauthorizedError)
43− .addError(ControlPlaneForbiddenError)
44− .addError(ControlPlaneStorageError),
45− )
46− .add(
47− HttpApiEndpoint.post("create")`/organizations`
48− .setPayload(CreateOrganizationPayloadSchema)
49− .addSuccess(OrganizationSchema)
50− .addError(ControlPlaneBadRequestError)
51− .addError(ControlPlaneUnauthorizedError)
52− .addError(ControlPlaneForbiddenError)
53− .addError(ControlPlaneStorageError),
54− )
55− .add(
56− HttpApiEndpoint.get("get")`/organizations/${organizationIdParam}`
57− .addSuccess(OrganizationSchema)
58− .addError(ControlPlaneBadRequestError)
59− .addError(ControlPlaneUnauthorizedError)
60− .addError(ControlPlaneForbiddenError)
61− .addError(ControlPlaneNotFoundError)
62− .addError(ControlPlaneStorageError),
63− )
64− .add(
65− HttpApiEndpoint.patch("update")`/organizations/${organizationIdParam}`
66− .setPayload(UpdateOrganizationPayloadSchema)
67− .addSuccess(OrganizationSchema)
68− .addError(ControlPlaneBadRequestError)
69− .addError(ControlPlaneUnauthorizedError)
70− .addError(ControlPlaneForbiddenError)
71− .addError(ControlPlaneNotFoundError)
72− .addError(ControlPlaneStorageError),
73− )
74− .add(
75− HttpApiEndpoint.del("remove")`/organizations/${organizationIdParam}`
76− .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
77− .addError(ControlPlaneBadRequestError)
78− .addError(ControlPlaneUnauthorizedError)
79− .addError(ControlPlaneForbiddenError)
80− .addError(ControlPlaneStorageError),
81− )
82− .prefix("/v1") {}
packages/control-plane/src/api/organizations/http.ts+0−75
@@ -1,75 +0,0 @@
1−import { HttpApiBuilder } from "@effect/platform";
2−import type { OrganizationId } from "#schema";
3−
4−import { requirePermission, withPolicy } from "#domain";
5−import {
6− createOrganization,
7− getOrganization,
8− listOrganizations,
9− removeOrganization,
10− updateOrganization,
11−} from "../../runtime/organizations-operations";
12−
13−import { ControlPlaneApi } from "../api";
14−import { withRequestActor } from "../http-auth";
15−
16−const requireReadOrganizations = requirePermission({
17− permission: "organizations:read",
18−});
19−
20−const requireManageOrganizations = (organizationId: OrganizationId) =>
21− requirePermission({
22− permission: "organizations:manage",
23− organizationId,
24− });
25−
26−export const ControlPlaneOrganizationsLive = HttpApiBuilder.group(
27− ControlPlaneApi,
28− "organizations",
29− (handlers) =>
30− handlers
31− .handle("list", () =>
32− withRequestActor("organizations.list", (actor) =>
33− withPolicy(requireReadOrganizations)(
34− listOrganizations({
35− accountId: actor.principal.accountId,
36− }),
37− ),
38− ),
39− )
40− .handle("create", ({ payload }) =>
41− withRequestActor("organizations.create", (actor) =>
42− createOrganization({
43− payload,
44− createdByAccountId: actor.principal.accountId,
45− }),
46− ),
47− )
48− .handle("get", ({ path }) =>
49− withRequestActor("organizations.get", (actor) =>
50− withPolicy(requireReadOrganizations)(
51− getOrganization({
52− organizationId: path.organizationId,
53− accountId: actor.principal.accountId,
54− }),
55− ),
56− ),
57− )
58− .handle("update", ({ path, payload }) =>
59− withRequestActor("organizations.update", () =>
60− withPolicy(requireManageOrganizations(path.organizationId))(
61− updateOrganization({
62− organizationId: path.organizationId,
63− payload,
64− }),
65− ),
66− ),
67− )
68− .handle("remove", ({ path }) =>
69− withRequestActor("organizations.remove", () =>
70− withPolicy(requireManageOrganizations(path.organizationId))(
71− removeOrganization({ organizationId: path.organizationId }),
72− ),
73− ),
74− ),
75−);
packages/control-plane/src/api/payload-schemas.test.ts+6−35
@@ -5,38 +5,15 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines
55 import {
66 CreateExecutionPayloadSchema,
77 } from "./executions/api";
8−import {
9− CreateOrganizationPayloadSchema,
10− UpdateOrganizationPayloadSchema,
11−} from "./organizations/api";
128 import { CreatePolicyPayloadSchema } from "./policies/api";
139 import {
1410 ConnectSourcePayloadSchema,
1511 CreateSourcePayloadSchema,
1612 UpdateSourcePayloadSchema,
1713 } from "./sources/api";
18−import { CreateWorkspacePayloadSchema } from "./workspaces/api";
1914
2015 describe("control-plane payload schemas", () => {
2116 it("normalizes trimmed strings at decode time", () => {
22− expect(
23− Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({
24− name: " Acme ",
25− slug: " acme ",
26− }),
27− ).toEqual({
28− name: "Acme",
29− slug: "acme",
30− });
31−
32− expect(
33− Schema.decodeUnknownSync(CreateWorkspacePayloadSchema)({
34− name: " Primary ",
35− }),
36− ).toEqual({
37− name: "Primary",
38− });
39−
4017 expect(
4118 Schema.decodeUnknownSync(CreateSourcePayloadSchema)({
4219 name: " Github ",
@@ -101,18 +78,6 @@ describe("control-plane payload schemas", () => { · 58 unmodified lines
10178 });
10279
10380 it("rejects blank strings for normalized string fields", () => {
104− throws(() =>
105− Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({
106− name: " ",
107− })
108− );
109−
110− throws(() =>
111− Schema.decodeUnknownSync(UpdateOrganizationPayloadSchema)({
112− name: " ",
113− })
114− );
115−
11681 throws(() =>
11782 Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({
11883 endpoint: " ",
@@ -125,5 +90,11 @@ describe("control-plane payload schemas", () => { · 6 unmodified lines
12590 endpoint: " ",
12691 })
12792 );
93+
94+ throws(() =>
95+ Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({
96+ resourcePattern: " ",
97+ })
98+ );
12899 });
129100 });
packages/control-plane/src/api/policies/api.ts+13−79
@@ -1,13 +1,9 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import {
3− AccountIdSchema,
4− OrganizationIdSchema,
3+ LocalWorkspacePolicyApprovalModeSchema,
4+ LocalWorkspacePolicyEffectSchema,
5+ LocalWorkspacePolicySchema,
56 PolicyIdSchema,
6− PolicyApprovalModeSchema,
7− PolicyEffectSchema,
8− PolicyMatchTypeSchema,
9− PolicyResourceTypeSchema,
10− PolicySchema,
117 WorkspaceIdSchema,
128 } from "#schema";
139 import * as Schema from "effect/Schema";
@@ -21,91 +17,29 @@ import { · 7 unmodified lines
2117 } from "../errors";
2218 import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas";
2319
24−export const CreatePolicyPayloadSchema = Schema.Struct({
25− resourceType: Schema.optional(PolicyResourceTypeSchema),
20+const LocalWorkspacePolicyPayloadSchema = Schema.Struct({
2621 resourcePattern: OptionalTrimmedNonEmptyStringSchema,
27− matchType: Schema.optional(PolicyMatchTypeSchema),
28− effect: Schema.optional(PolicyEffectSchema),
29− approvalMode: Schema.optional(PolicyApprovalModeSchema),
30− argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)),
22+ effect: Schema.optional(LocalWorkspacePolicyEffectSchema),
23+ approvalMode: Schema.optional(LocalWorkspacePolicyApprovalModeSchema),
3124 priority: Schema.optional(Schema.Number),
3225 enabled: Schema.optional(Schema.Boolean),
33− targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)),
34− clientId: Schema.optional(Schema.NullOr(Schema.String)),
3526 });
3627
28+export const CreatePolicyPayloadSchema = LocalWorkspacePolicyPayloadSchema;
29+
3730 export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type;
3831
39−export const UpdatePolicyPayloadSchema = Schema.Struct({
40− resourceType: Schema.optional(PolicyResourceTypeSchema),
41− resourcePattern: OptionalTrimmedNonEmptyStringSchema,
42− matchType: Schema.optional(PolicyMatchTypeSchema),
43− effect: Schema.optional(PolicyEffectSchema),
44− approvalMode: Schema.optional(PolicyApprovalModeSchema),
45− argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)),
46− priority: Schema.optional(Schema.Number),
47− enabled: Schema.optional(Schema.Boolean),
48− targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)),
49− clientId: Schema.optional(Schema.NullOr(Schema.String)),
50−});
32+export const UpdatePolicyPayloadSchema = LocalWorkspacePolicyPayloadSchema;
5133
5234 export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;
5335
54−const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
5536 const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
5637 const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema);
5738
5839 export class PoliciesApi extends HttpApiGroup.make("policies")
59− .add(
60− HttpApiEndpoint.get("listOrganization")`/organizations/${organizationIdParam}/policies`
61− .addSuccess(Schema.Array(PolicySchema))
62− .addError(ControlPlaneBadRequestError)
63− .addError(ControlPlaneUnauthorizedError)
64− .addError(ControlPlaneForbiddenError)
65− .addError(ControlPlaneNotFoundError)
66− .addError(ControlPlaneStorageError),
67− )
68− .add(
69− HttpApiEndpoint.post("createOrganization")`/organizations/${organizationIdParam}/policies`
70− .setPayload(CreatePolicyPayloadSchema)
71− .addSuccess(PolicySchema)
72− .addError(ControlPlaneBadRequestError)
73− .addError(ControlPlaneUnauthorizedError)
74− .addError(ControlPlaneForbiddenError)
75− .addError(ControlPlaneNotFoundError)
76− .addError(ControlPlaneStorageError),
77− )
78− .add(
79− HttpApiEndpoint.get("getOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}`
80− .addSuccess(PolicySchema)
81− .addError(ControlPlaneBadRequestError)
82− .addError(ControlPlaneUnauthorizedError)
83− .addError(ControlPlaneForbiddenError)
84− .addError(ControlPlaneNotFoundError)
85− .addError(ControlPlaneStorageError),
86− )
87− .add(
88− HttpApiEndpoint.patch("updateOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}`
89− .setPayload(UpdatePolicyPayloadSchema)
90− .addSuccess(PolicySchema)
91− .addError(ControlPlaneBadRequestError)
92− .addError(ControlPlaneUnauthorizedError)
93− .addError(ControlPlaneForbiddenError)
94− .addError(ControlPlaneNotFoundError)
95− .addError(ControlPlaneStorageError),
96− )
97− .add(
98− HttpApiEndpoint.del("removeOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}`
99− .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
100− .addError(ControlPlaneBadRequestError)
101− .addError(ControlPlaneUnauthorizedError)
102− .addError(ControlPlaneForbiddenError)
103− .addError(ControlPlaneNotFoundError)
104− .addError(ControlPlaneStorageError),
105− )
10640 .add(
10741 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies`
108− .addSuccess(Schema.Array(PolicySchema))
42+ .addSuccess(Schema.Array(LocalWorkspacePolicySchema))
10943 .addError(ControlPlaneBadRequestError)
11044 .addError(ControlPlaneUnauthorizedError)
11145 .addError(ControlPlaneForbiddenError)
@@ -115,7 +49,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 3 unmodified lines
11549 .add(
11650 HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/policies`
11751 .setPayload(CreatePolicyPayloadSchema)
118− .addSuccess(PolicySchema)
52+ .addSuccess(LocalWorkspacePolicySchema)
11953 .addError(ControlPlaneBadRequestError)
12054 .addError(ControlPlaneUnauthorizedError)
12155 .addError(ControlPlaneForbiddenError)
@@ -124,7 +58,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 2 unmodified lines
12458 )
12559 .add(
12660 HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}`
127− .addSuccess(PolicySchema)
61+ .addSuccess(LocalWorkspacePolicySchema)
12862 .addError(ControlPlaneBadRequestError)
12963 .addError(ControlPlaneUnauthorizedError)
13064 .addError(ControlPlaneForbiddenError)
@@ -134,7 +68,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 3 unmodified lines
13468 .add(
13569 HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}`
13670 .setPayload(UpdatePolicyPayloadSchema)
137− .addSuccess(PolicySchema)
71+ .addSuccess(LocalWorkspacePolicySchema)
13872 .addError(ControlPlaneBadRequestError)
13973 .addError(ControlPlaneUnauthorizedError)
14074 .addError(ControlPlaneForbiddenError)
packages/control-plane/src/api/policies/http.ts+12−91
@@ -1,117 +1,38 @@
11 import { HttpApiBuilder } from "@effect/platform";
2−import type { OrganizationId, WorkspaceId } from "#schema";
3−
4−import { requirePermission, withPolicy } from "#domain";
2+import * as Effect from "effect/Effect";
53 import {
6− createOrganizationPolicy,
74 createPolicy,
8− getOrganizationPolicy,
95 getPolicy,
10− listOrganizationPolicies,
116 listPolicies,
12− removeOrganizationPolicy,
137 removePolicy,
14− updateOrganizationPolicy,
158 updatePolicy,
169 } from "../../runtime/policies-operations";
1710
1811 import { ControlPlaneApi } from "../api";
19−import { withRequestActor, withWorkspaceRequestActor } from "../http-auth";
20−
21−const requireReadOrganizationPolicies = (organizationId: OrganizationId) =>
22− requirePermission({
23− permission: "policies:read",
24− organizationId,
25− });
26−
27−const requireWriteOrganizationPolicies = (organizationId: OrganizationId) =>
28− requirePermission({
29− permission: "policies:write",
30− organizationId,
31− });
32−
33−const requireReadPolicies = (workspaceId: WorkspaceId) =>
34− requirePermission({
35− permission: "policies:read",
36− workspaceId,
37− });
38−
39−const requireWritePolicies = (workspaceId: WorkspaceId) =>
40− requirePermission({
41− permission: "policies:write",
42− workspaceId,
43− });
12+import { resolveRequestedLocalWorkspace } from "../local-context";
4413
4514 export const ControlPlanePoliciesLive = HttpApiBuilder.group(
4615 ControlPlaneApi,
4716 "policies",
4817 (handlers) =>
4918 handlers
50− .handle("listOrganization", ({ path }) =>
51− withRequestActor("policies.listOrganization", () =>
52− withPolicy(requireReadOrganizationPolicies(path.organizationId))(
53− listOrganizationPolicies(path.organizationId),
54− ),
55− ),
56− )
57− .handle("createOrganization", ({ path, payload }) =>
58− withRequestActor("policies.createOrganization", () =>
59− withPolicy(requireWriteOrganizationPolicies(path.organizationId))(
60− createOrganizationPolicy({
61− organizationId: path.organizationId,
62− payload,
63− }),
64− ),
65− ),
66− )
67− .handle("getOrganization", ({ path }) =>
68− withRequestActor("policies.getOrganization", () =>
69− withPolicy(requireReadOrganizationPolicies(path.organizationId))(
70− getOrganizationPolicy({
71− organizationId: path.organizationId,
72− policyId: path.policyId,
73− }),
74− ),
75− ),
76− )
77− .handle("updateOrganization", ({ path, payload }) =>
78− withRequestActor("policies.updateOrganization", () =>
79− withPolicy(requireWriteOrganizationPolicies(path.organizationId))(
80− updateOrganizationPolicy({
81− organizationId: path.organizationId,
82− policyId: path.policyId,
83− payload,
84− }),
85− ),
86− ),
87− )
88− .handle("removeOrganization", ({ path }) =>
89− withRequestActor("policies.removeOrganization", () =>
90− withPolicy(requireWriteOrganizationPolicies(path.organizationId))(
91− removeOrganizationPolicy({
92− organizationId: path.organizationId,
93− policyId: path.policyId,
94− }),
95− ),
96− ),
97− )
9819 .handle("list", ({ path }) =>
99− withWorkspaceRequestActor("policies.list", path.workspaceId, () =>
100− withPolicy(requireReadPolicies(path.workspaceId))(
20+ resolveRequestedLocalWorkspace("policies.list", path.workspaceId).pipe(
21+ Effect.zipRight(
10122 listPolicies(path.workspaceId),
10223 ),
10324 ),
10425 )
10526 .handle("create", ({ path, payload }) =>
106− withWorkspaceRequestActor("policies.create", path.workspaceId, () =>
107− withPolicy(requireWritePolicies(path.workspaceId))(
27+ resolveRequestedLocalWorkspace("policies.create", path.workspaceId).pipe(
28+ Effect.zipRight(
10829 createPolicy({ workspaceId: path.workspaceId, payload }),
10930 ),
11031 ),
11132 )
11233 .handle("get", ({ path }) =>
113− withWorkspaceRequestActor("policies.get", path.workspaceId, () =>
114− withPolicy(requireReadPolicies(path.workspaceId))(
34+ resolveRequestedLocalWorkspace("policies.get", path.workspaceId).pipe(
35+ Effect.zipRight(
11536 getPolicy({
11637 workspaceId: path.workspaceId,
11738 policyId: path.policyId,
@@ -120,8 +41,8 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 2 unmodified lines
12041 ),
12142 )
12243 .handle("update", ({ path, payload }) =>
123− withWorkspaceRequestActor("policies.update", path.workspaceId, () =>
124− withPolicy(requireWritePolicies(path.workspaceId))(
44+ resolveRequestedLocalWorkspace("policies.update", path.workspaceId).pipe(
45+ Effect.zipRight(
12546 updatePolicy({
12647 workspaceId: path.workspaceId,
12748 policyId: path.policyId,
@@ -131,8 +52,8 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 3 unmodified lines
13152 ),
13253 )
13354 .handle("remove", ({ path }) =>
134− withWorkspaceRequestActor("policies.remove", path.workspaceId, () =>
135− withPolicy(requireWritePolicies(path.workspaceId))(
55+ resolveRequestedLocalWorkspace("policies.remove", path.workspaceId).pipe(
56+ Effect.zipRight(
13657 removePolicy({
13758 workspaceId: path.workspaceId,
13859 policyId: path.policyId,
packages/control-plane/src/api/sources/http.ts+44−54
@@ -3,14 +3,9 @@ import { · 2 unmodified lines
33 HttpServerRequest,
44 HttpServerResponse,
55 } from "@effect/platform";
6−import type {
7− ExecutionInteraction,
8− Source,
9− WorkspaceId,
10−} from "#schema";
6+import type { ExecutionInteraction, Source, WorkspaceId } from "#schema";
117 import * as Effect from "effect/Effect";
128
13−import { requirePermission, withPolicy } from "#domain";
149 import {
1510 createSource,
1611 getSource,
@@ -39,19 +34,7 @@ import { · 22 unmodified lines
3934 ControlPlaneStorageError,
4035 } from "../errors";
4136 import { ControlPlaneApi } from "../api";
42−import { withRequestActor, withWorkspaceRequestActor } from "../http-auth";
43−
44−const requireReadSources = (workspaceId: WorkspaceId) =>
45− requirePermission({
46− permission: "sources:read",
47− workspaceId,
48− });
49−
50−const requireWriteSources = (workspaceId: WorkspaceId) =>
51− requirePermission({
52− permission: "sources:write",
53− workspaceId,
54− });
37+import { resolveRequestedLocalWorkspace } from "../local-context";
5538
5639 const readHeader = (headers: unknown, name: string): string | null => {
5740 if (headers == null || typeof headers !== "object") {
@@ -665,20 +648,18 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 607 unmodified lines
665648 (handlers) =>
666649 handlers
667650 .handle("discover", ({ payload }) =>
668− withRequestActor("sources.discover", () =>
669− discoverSource({
670− url: payload.url,
671− probeAuth: payload.probeAuth,
672− }).pipe(
673− Effect.catchAll((cause) =>
674− Effect.fail(toBadRequestError("sources.discover", cause)),
675− ),
651+ discoverSource({
652+ url: payload.url,
653+ probeAuth: payload.probeAuth,
654+ }).pipe(
655+ Effect.catchAll((cause) =>
656+ Effect.fail(toBadRequestError("sources.discover", cause)),
676657 ),
677658 ),
678659 )
679660 .handle("connect", ({ path, payload }) =>
680− withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) =>
681− withPolicy(requireWriteSources(path.workspaceId))(
661+ resolveRequestedLocalWorkspace("sources.connect", path.workspaceId).pipe(
662+ Effect.flatMap((runtimeLocalWorkspace) =>
682663 Effect.gen(function* () {
683664 const request = yield* HttpServerRequest.HttpServerRequest;
684665 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
@@ -687,7 +668,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 2 unmodified lines
687668 if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) {
688669 return yield* sourceAuthService.connectMcpSource({
689670 workspaceId: path.workspaceId,
690− actorAccountId: actor.principal.accountId,
671+ actorAccountId: runtimeLocalWorkspace.installation.accountId,
691672 endpoint: payload.endpoint,
692673 name: payload.name,
693674 namespace: payload.namespace,
@@ -701,7 +682,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 7 unmodified lines
701682 return yield* sourceAuthService.addExecutorSource(
702683 {
703684 workspaceId: path.workspaceId,
704− actorAccountId: actor.principal.accountId,
685+ actorAccountId: runtimeLocalWorkspace.installation.accountId,
705686 executionId: null,
706687 interactionId: null,
707688 ...payload,
@@ -712,37 +693,45 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 4 unmodified lines
712693 Effect.catchAll((cause) =>
713694 Effect.fail(toBadRequestError("sources.connect", cause)),
714695 ),
715− ),
696+ )
716697 ),
717698 ),
718699 )
719700 .handle("list", ({ path }) =>
720− withWorkspaceRequestActor("sources.list", path.workspaceId, () =>
721− withPolicy(requireReadSources(path.workspaceId))(
722− listSources(path.workspaceId),
701+ resolveRequestedLocalWorkspace("sources.list", path.workspaceId).pipe(
702+ Effect.flatMap((runtimeLocalWorkspace) =>
703+ listSources({
704+ workspaceId: path.workspaceId,
705+ accountId: runtimeLocalWorkspace.installation.accountId,
706+ })
723707 ),
724708 ),
725709 )
726710 .handle("create", ({ path, payload }) =>
727− withWorkspaceRequestActor("sources.create", path.workspaceId, () =>
728− withPolicy(requireWriteSources(path.workspaceId))(
729− createSource({ workspaceId: path.workspaceId, payload }),
711+ resolveRequestedLocalWorkspace("sources.create", path.workspaceId).pipe(
712+ Effect.flatMap((runtimeLocalWorkspace) =>
713+ createSource({
714+ workspaceId: path.workspaceId,
715+ accountId: runtimeLocalWorkspace.installation.accountId,
716+ payload,
717+ })
730718 ),
731719 ),
732720 )
733721 .handle("get", ({ path }) =>
734− withWorkspaceRequestActor("sources.get", path.workspaceId, () =>
735− withPolicy(requireReadSources(path.workspaceId))(
722+ resolveRequestedLocalWorkspace("sources.get", path.workspaceId).pipe(
723+ Effect.flatMap((runtimeLocalWorkspace) =>
736724 getSource({
737725 workspaceId: path.workspaceId,
738726 sourceId: path.sourceId,
739− }),
727+ accountId: runtimeLocalWorkspace.installation.accountId,
728+ })
740729 ),
741730 ),
742731 )
743732 .handle("inspection", ({ path }) =>
744− withWorkspaceRequestActor("sources.inspection", path.workspaceId, () =>
745− withPolicy(requireReadSources(path.workspaceId))(
733+ resolveRequestedLocalWorkspace("sources.inspection", path.workspaceId).pipe(
734+ Effect.zipRight(
746735 getSourceInspection({
747736 workspaceId: path.workspaceId,
748737 sourceId: path.sourceId,
@@ -751,8 +740,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 2 unmodified lines
751740 ),
752741 )
753742 .handle("inspectionTool", ({ path }) =>
754− withWorkspaceRequestActor("sources.inspection_tool", path.workspaceId, () =>
755− withPolicy(requireReadSources(path.workspaceId))(
743+ resolveRequestedLocalWorkspace("sources.inspection_tool", path.workspaceId).pipe(
744+ Effect.zipRight(
756745 getSourceInspectionToolDetail({
757746 workspaceId: path.workspaceId,
758747 sourceId: path.sourceId,
@@ -762,8 +751,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines
762751 ),
763752 )
764753 .handle("inspectionSchemaBundle", ({ path }) =>
765− withWorkspaceRequestActor("sources.inspection_schema_bundle", path.workspaceId, () =>
766− withPolicy(requireReadSources(path.workspaceId))(
754+ resolveRequestedLocalWorkspace("sources.inspection_schema_bundle", path.workspaceId).pipe(
755+ Effect.zipRight(
767756 getSourceInspectionSchemaBundle({
768757 workspaceId: path.workspaceId,
769758 sourceId: path.sourceId,
@@ -773,8 +762,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines
773762 ),
774763 )
775764 .handle("inspectionDiscover", ({ path, payload }) =>
776− withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () =>
777− withPolicy(requireReadSources(path.workspaceId))(
765+ resolveRequestedLocalWorkspace("sources.inspection_discover", path.workspaceId).pipe(
766+ Effect.zipRight(
778767 discoverSourceInspectionTools({
779768 workspaceId: path.workspaceId,
780769 sourceId: path.sourceId,
@@ -784,19 +773,20 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines
784773 ),
785774 )
786775 .handle("update", ({ path, payload }) =>
787− withWorkspaceRequestActor("sources.update", path.workspaceId, () =>
788− withPolicy(requireWriteSources(path.workspaceId))(
776+ resolveRequestedLocalWorkspace("sources.update", path.workspaceId).pipe(
777+ Effect.flatMap((runtimeLocalWorkspace) =>
789778 updateSource({
790779 workspaceId: path.workspaceId,
791780 sourceId: path.sourceId,
781+ accountId: runtimeLocalWorkspace.installation.accountId,
792782 payload,
793− }),
783+ })
794784 ),
795785 ),
796786 )
797787 .handle("remove", ({ path }) =>
798− withWorkspaceRequestActor("sources.remove", path.workspaceId, () =>
799− withPolicy(requireWriteSources(path.workspaceId))(
788+ resolveRequestedLocalWorkspace("sources.remove", path.workspaceId).pipe(
789+ Effect.zipRight(
800790 removeSource({
801791 workspaceId: path.workspaceId,
802792 sourceId: path.sourceId,
packages/control-plane/src/api/workspaces/api.ts+0−83
@@ -1,83 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2−import {
3− OrganizationIdSchema,
4− WorkspaceIdSchema,
5− WorkspaceSchema,
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−import {
17− OptionalTrimmedNonEmptyStringSchema,
18− TrimmedNonEmptyStringSchema,
19−} from "../string-schemas";
20−
21−export const CreateWorkspacePayloadSchema = Schema.Struct({
22− name: TrimmedNonEmptyStringSchema,
23−});
24−
25−export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type;
26−
27−export const UpdateWorkspacePayloadSchema = Schema.Struct({
28− name: OptionalTrimmedNonEmptyStringSchema,
29−});
30−
31−export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type;
32−
33−const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
34−const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
35−
36−export class WorkspacesApi extends HttpApiGroup.make("workspaces")
37− .add(
38− HttpApiEndpoint.get("list")`/organizations/${organizationIdParam}/workspaces`
39− .addSuccess(Schema.Array(WorkspaceSchema))
40− .addError(ControlPlaneBadRequestError)
41− .addError(ControlPlaneUnauthorizedError)
42− .addError(ControlPlaneForbiddenError)
43− .addError(ControlPlaneNotFoundError)
44− .addError(ControlPlaneStorageError),
45− )
46− .add(
47− HttpApiEndpoint.post("create")`/organizations/${organizationIdParam}/workspaces`
48− .setPayload(CreateWorkspacePayloadSchema)
49− .addSuccess(WorkspaceSchema)
50− .addError(ControlPlaneBadRequestError)
51− .addError(ControlPlaneUnauthorizedError)
52− .addError(ControlPlaneForbiddenError)
53− .addError(ControlPlaneNotFoundError)
54− .addError(ControlPlaneStorageError),
55− )
56− .add(
57− HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}`
58− .addSuccess(WorkspaceSchema)
59− .addError(ControlPlaneBadRequestError)
60− .addError(ControlPlaneUnauthorizedError)
61− .addError(ControlPlaneForbiddenError)
62− .addError(ControlPlaneNotFoundError)
63− .addError(ControlPlaneStorageError),
64− )
65− .add(
66− HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}`
67− .setPayload(UpdateWorkspacePayloadSchema)
68− .addSuccess(WorkspaceSchema)
69− .addError(ControlPlaneBadRequestError)
70− .addError(ControlPlaneUnauthorizedError)
71− .addError(ControlPlaneForbiddenError)
72− .addError(ControlPlaneNotFoundError)
73− .addError(ControlPlaneStorageError),
74− )
75− .add(
76− HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}`
77− .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
78− .addError(ControlPlaneBadRequestError)
79− .addError(ControlPlaneUnauthorizedError)
80− .addError(ControlPlaneForbiddenError)
81− .addError(ControlPlaneStorageError),
82− )
83− .prefix("/v1") {}
packages/control-plane/src/api/workspaces/http.ts+0−89
@@ -1,89 +0,0 @@
1−import { HttpApiBuilder } from "@effect/platform";
2−import type { OrganizationId, WorkspaceId } from "#schema";
3−
4−import { requirePermission, withPolicy } from "#domain";
5−import {
6− createWorkspace,
7− getWorkspace,
8− listWorkspaces,
9− removeWorkspace,
10− updateWorkspace,
11−} from "../../runtime/organizations-operations";
12−
13−import { ControlPlaneApi } from "../api";
14−import {
15− withRequestActor,
16− withWorkspaceRequestActor,
17−} from "../http-auth";
18−
19−const requireReadWorkspace = (workspaceId: WorkspaceId) =>
20− requirePermission({
21− permission: "workspace:read",
22− workspaceId,
23− });
24−
25−const requireManageWorkspace = (workspaceId: WorkspaceId) =>
26− requirePermission({
27− permission: "workspace:manage",
28− workspaceId,
29− });
30−
31−const requireOrganizationWorkspaceRead = (organizationId: OrganizationId) =>
32− requirePermission({
33− permission: "workspace:read",
34− organizationId,
35− });
36−
37−const requireOrganizationWorkspaceManage = (organizationId: OrganizationId) =>
38− requirePermission({
39− permission: "workspace:manage",
40− organizationId,
41− });
42−
43−export const ControlPlaneWorkspacesLive = HttpApiBuilder.group(
44− ControlPlaneApi,
45− "workspaces",
46− (handlers) =>
47− handlers
48− .handle("list", ({ path }) =>
49− withRequestActor("workspaces.list", () =>
50− withPolicy(
51− requireOrganizationWorkspaceRead(path.organizationId),
52− )(listWorkspaces(path.organizationId))
53− ),
54− )
55− .handle("create", ({ path, payload }) =>
56− withRequestActor("workspaces.create", (actor) =>
57− withPolicy(
58− requireOrganizationWorkspaceManage(path.organizationId),
59− )(
60− createWorkspace({
61− organizationId: path.organizationId,
62− payload,
63− createdByAccountId: actor.principal.accountId,
64− }),
65− ),
66− ),
67− )
68− .handle("get", ({ path }) =>
69− withWorkspaceRequestActor("workspaces.get", path.workspaceId, () =>
70− withPolicy(requireReadWorkspace(path.workspaceId))(
71− getWorkspace(path.workspaceId),
72− ),
73− ),
74− )
75− .handle("update", ({ path, payload }) =>
76− withWorkspaceRequestActor("workspaces.update", path.workspaceId, () =>
77− withPolicy(requireManageWorkspace(path.workspaceId))(
78− updateWorkspace({ workspaceId: path.workspaceId, payload }),
79− ),
80− ),
81− )
82− .handle("remove", ({ path }) =>
83− withWorkspaceRequestActor("workspaces.remove", path.workspaceId, () =>
84− withPolicy(requireManageWorkspace(path.workspaceId))(
85− removeWorkspace({ workspaceId: path.workspaceId }),
86− ),
87− ),
88− ),
89−);
packages/control-plane/src/auth-headers.ts+0−7
@@ -1,7 +0,0 @@
1−export const ControlPlaneAuthHeaders = {
2− accountId: "x-executor-account-id",
3− principalProvider: "x-executor-principal-provider",
4− principalSubject: "x-executor-principal-subject",
5− principalEmail: "x-executor-principal-email",
6− principalDisplayName: "x-executor-principal-name",
7−} as const;
packages/control-plane/src/client.ts+2−19
@@ -1,35 +1,18 @@
11 import {
22 FetchHttpClient,
33 HttpApiClient,
4− HttpClient,
5− HttpClientRequest,
64 } from "@effect/platform";
75 import * as Effect from "effect/Effect";
86
97 import { ControlPlaneApi } from "./api/api";
10−import { ControlPlaneAuthHeaders } from "./auth-headers";
118
129 export const createControlPlaneClient = (input: {
1310 baseUrl: string;
1411 accountId?: string;
15−}) => {
16− const accountId = input.accountId;
17−
18− return HttpApiClient.make(ControlPlaneApi, {
12+}) =>
13+ HttpApiClient.make(ControlPlaneApi, {
1914 baseUrl: input.baseUrl,
20− transformClient: accountId
21− ? (client) =>
22− client.pipe(
23− HttpClient.mapRequest(
24− HttpClientRequest.setHeader(
25− ControlPlaneAuthHeaders.accountId,
26− accountId,
27− ),
28− ),
29− )
30− : undefined,
3115 }).pipe(Effect.provide(FetchHttpClient.layer));
32−};
3316
3417 export type ControlPlaneClient = Effect.Effect.Success<
3518 ReturnType<typeof createControlPlaneClient>
packages/control-plane/src/domain/auth/actor.test.ts+0−98
@@ -1,98 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { assertInstanceOf, assertTrue } from "@effect/vitest/utils";
3−import { AccountIdSchema, WorkspaceIdSchema } from "#schema";
4−import * as Effect from "effect/Effect";
5−
6−import {
7− createActor,
8− ActorForbiddenError,
9− ActorUnauthenticatedError,
10−} from "./actor";
11−
12−describe("control-plane-domain actor", () => {
13− it.effect("allows workspace permissions from active membership", () =>
14− Effect.gen(function* () {
15− const accountId = AccountIdSchema.make("acc_1");
16− const workspaceId = WorkspaceIdSchema.make("ws_1");
17−
18− const actor = yield* createActor({
19− principal: {
20− accountId,
21− provider: "local",
22− subject: "local:acc_1",
23− email: null,
24− displayName: null,
25− },
26− workspaceMemberships: [
27− {
28− accountId,
29− workspaceId,
30− role: "editor",
31− status: "active",
32− grantedAt: 1,
33− updatedAt: 1,
34− },
35− ],
36− organizationMemberships: [],
37− });
38−
39− yield* actor.requirePermission({
40− permission: "sources:write",
41− workspaceId,
42− });
43− }),
44− );
45−
46− it.effect("denies permission when role is insufficient", () =>
47− Effect.gen(function* () {
48− const accountId = AccountIdSchema.make("acc_1");
49− const workspaceId = WorkspaceIdSchema.make("ws_1");
50−
51− const actor = yield* createActor({
52− principal: {
53− accountId,
54− provider: "local",
55− subject: "local:acc_1",
56− email: null,
57− displayName: null,
58− },
59− workspaceMemberships: [
60− {
61− accountId,
62− workspaceId,
63− role: "viewer",
64− status: "active",
65− grantedAt: 1,
66− updatedAt: 1,
67− },
68− ],
69− organizationMemberships: [],
70− });
71−
72− const denied = yield* Effect.either(
73− actor.requirePermission({
74− permission: "sources:write",
75− workspaceId,
76− }),
77− );
78−
79− assertTrue(denied._tag === "Left");
80− assertInstanceOf(denied.left, ActorForbiddenError);
81− }),
82− );
83−
84− it.effect("fails when principal is missing", () =>
85− Effect.gen(function* () {
86− const result = yield* Effect.either(
87− createActor({
88− principal: null,
89− workspaceMemberships: [],
90− organizationMemberships: [],
91− }),
92− );
93−
94− assertTrue(result._tag === "Left");
95− assertInstanceOf(result.left, ActorUnauthenticatedError);
96− }),
97− );
98−});
packages/control-plane/src/domain/auth/actor.ts+0−239
@@ -1,239 +0,0 @@
1−import {
2− RolePermissions,
3− type OrganizationId,
4− type OrganizationMembership,
5− type Permission,
6− type Principal,
7− type WorkspaceId,
8− type WorkspaceMembership,
9−} from "#schema";
10−import * as Context from "effect/Context";
11−import * as Data from "effect/Data";
12−import * as Effect from "effect/Effect";
13−import * as Layer from "effect/Layer";
14−
15−export class ActorUnauthenticatedError extends Data.TaggedError(
16− "ActorUnauthenticatedError",
17−)<{
18− message: string;
19−}> {}
20−
21−export class ActorForbiddenError extends Data.TaggedError("ActorForbiddenError")<{
22− permission: Permission;
23− scope: string;
24−}> {}
25−
26−export type PermissionRequest = {
27− permission: Permission;
28− workspaceId?: WorkspaceId;
29− organizationId?: OrganizationId;
30−};
31−
32−export type ActorShape = {
33− principal: Principal;
34− workspaceMemberships: ReadonlyArray<WorkspaceMembership>;
35− organizationMemberships: ReadonlyArray<OrganizationMembership>;
36− hasPermission: (request: PermissionRequest) => boolean;
37− requirePermission: (
38− request: PermissionRequest,
39− ) => Effect.Effect<void, ActorForbiddenError>;
40− hasWorkspaceAccess: (workspaceId: WorkspaceId) => boolean;
41− requireWorkspaceAccess: (
42− workspaceId: WorkspaceId,
43− ) => Effect.Effect<void, ActorForbiddenError>;
44−};
45−
46−export class Actor extends Context.Tag("#domain/auth/Actor")<
47− Actor,
48− ActorShape
49−>() {}
50−
51−export type CreateActorInput = {
52− principal: Principal | null;
53− workspaceMemberships: ReadonlyArray<WorkspaceMembership>;
54− organizationMemberships: ReadonlyArray<OrganizationMembership>;
55−};
56−
57−const isWorkspaceMembershipActive = (
58− membership: WorkspaceMembership,
59−): boolean => membership.status === "active";
60−
61−const isOrganizationMembershipActive = (
62− membership: OrganizationMembership,
63−): boolean => membership.status === "active";
64−
65−const permissionsByRole = new Map(
66− Object.entries(RolePermissions).map(([role, permissions]) => [
67− role,
68− new Set<Permission>(permissions),
69− ]),
70−);
71−
72−const permissionsForRole = (
73− role: keyof typeof RolePermissions,
74−): ReadonlySet<Permission> => permissionsByRole.get(role) ?? new Set<Permission>();
75−
76−const hasPermissionInWorkspace = (
77− request: PermissionRequest,
78− memberships: ReadonlyArray<WorkspaceMembership>,
79−): boolean => {
80− if (request.workspaceId === undefined) {
81− return false;
82− }
83−
84− return memberships.some((membership) => {
85− if (!isWorkspaceMembershipActive(membership)) {
86− return false;
87− }
88−
89− if (membership.workspaceId !== request.workspaceId) {
90− return false;
91− }
92−
93− return permissionsForRole(membership.role).has(request.permission);
94− });
95−};
96−
97−const hasPermissionInOrganization = (
98− request: PermissionRequest,
99− memberships: ReadonlyArray<OrganizationMembership>,
100−): boolean => {
101− if (request.organizationId === undefined) {
102− return false;
103− }
104−
105− return memberships.some((membership) => {
106− if (!isOrganizationMembershipActive(membership)) {
107− return false;
108− }
109−
110− if (membership.organizationId !== request.organizationId) {
111− return false;
112− }
113−
114− return permissionsForRole(membership.role).has(request.permission);
115− });
116−};
117−
118−const hasPermissionGlobally = (
119− request: PermissionRequest,
120− workspaceMemberships: ReadonlyArray<WorkspaceMembership>,
121− organizationMemberships: ReadonlyArray<OrganizationMembership>,
122−): boolean => {
123− const workspaceAllowed = workspaceMemberships.some(
124− (membership) =>
125− isWorkspaceMembershipActive(membership)
126− && permissionsForRole(membership.role).has(request.permission),
127− );
128−
129− if (workspaceAllowed) {
130− return true;
131− }
132−
133− return organizationMemberships.some(
134− (membership) =>
135− isOrganizationMembershipActive(membership)
136− && permissionsForRole(membership.role).has(request.permission),
137− );
138−};
139−
140−const scopeLabel = (request: PermissionRequest): string => {
141− if (request.workspaceId !== undefined) {
142− return `workspace:${request.workspaceId}`;
143− }
144−
145− if (request.organizationId !== undefined) {
146− return `organization:${request.organizationId}`;
147− }
148−
149− return "global";
150−};
151−
152−const toWorkspaceAccessPermissionRequest = (
153− workspaceId: WorkspaceId,
154−): PermissionRequest => ({
155− permission: "workspace:read",
156− workspaceId,
157−});
158−
159−const toActorShape = (input: {
160− principal: Principal;
161− workspaceMemberships: ReadonlyArray<WorkspaceMembership>;
162− organizationMemberships: ReadonlyArray<OrganizationMembership>;
163−}): ActorShape => {
164− const hasPermission = (request: PermissionRequest): boolean => {
165− if (request.workspaceId !== undefined) {
166− return hasPermissionInWorkspace(request, input.workspaceMemberships);
167− }
168−
169− if (request.organizationId !== undefined) {
170− return hasPermissionInOrganization(request, input.organizationMemberships);
171− }
172−
173− return hasPermissionGlobally(
174− request,
175− input.workspaceMemberships,
176− input.organizationMemberships,
177− );
178− };
179−
180− const requirePermission = (
181− request: PermissionRequest,
182− ): Effect.Effect<void, ActorForbiddenError> =>
183− hasPermission(request)
184− ? Effect.void
185− : Effect.fail(
186− new ActorForbiddenError({
187− permission: request.permission,
188− scope: scopeLabel(request),
189− }),
190− );
191−
192− const hasWorkspaceAccess = (workspaceId: WorkspaceId): boolean =>
193− hasPermission(toWorkspaceAccessPermissionRequest(workspaceId));
194−
195− const requireWorkspaceAccess = (
196− workspaceId: WorkspaceId,
197− ): Effect.Effect<void, ActorForbiddenError> =>
198− requirePermission(toWorkspaceAccessPermissionRequest(workspaceId));
199−
200− return {
201− principal: input.principal,
202− workspaceMemberships: input.workspaceMemberships,
203− organizationMemberships: input.organizationMemberships,
204− hasPermission,
205− requirePermission,
206− hasWorkspaceAccess,
207− requireWorkspaceAccess,
208− };
209−};
210−
211−export const createAllowAllActor = (principal: Principal): ActorShape => ({
212− principal,
213− workspaceMemberships: [],
214− organizationMemberships: [],
215− hasPermission: () => true,
216− requirePermission: () => Effect.void,
217− hasWorkspaceAccess: () => true,
218− requireWorkspaceAccess: () => Effect.void,
219−});
220−
221−export const createActor = (
222− input: CreateActorInput,
223−): Effect.Effect<ActorShape, ActorUnauthenticatedError> =>
224− input.principal === null
225− ? Effect.fail(
226− new ActorUnauthenticatedError({
227− message: "Authenticated principal is required",
228− }),
229− )
230− : Effect.succeed(
231− toActorShape({
232− principal: input.principal,
233− workspaceMemberships: input.workspaceMemberships,
234− organizationMemberships: input.organizationMemberships,
235− }),
236− );
237−
238−export const ActorLive = (actor: ActorShape): Layer.Layer<Actor> =>
239− Layer.succeed(Actor, actor);
packages/control-plane/src/domain/auth/index.ts+0−21
@@ -1,21 +0,0 @@
1−export {
2− Actor,
3− ActorForbiddenError,
4− ActorLive,
5− ActorUnauthenticatedError,
6− createActor,
7− createAllowAllActor,
8− type ActorShape,
9− type CreateActorInput,
10− type PermissionRequest,
11−} from "./actor";
12−
13−export {
14− all,
15− any,
16− policy,
17− requirePermission,
18− requireWorkspaceAccess,
19− withPolicy,
20− type Policy,
21−} from "./policy";
packages/control-plane/src/domain/auth/policy.test.ts+0−34
@@ -1,34 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { AccountIdSchema, WorkspaceIdSchema } from "#schema";
3−import * as Effect from "effect/Effect";
4−
5−import { Actor, createAllowAllActor } from "./actor";
6−import { requirePermission, withPolicy } from "./policy";
7−
8−describe("control-plane-domain policy", () => {
9− it.effect("runs protected effect when permission is granted", () =>
10− Effect.gen(function* () {
11− const accountId = AccountIdSchema.make("acc_1");
12− const workspaceId = WorkspaceIdSchema.make("ws_1");
13−
14− const principal = {
15− accountId,
16− provider: "local" as const,
17− subject: "local:acc_1",
18− email: null,
19− displayName: null,
20− };
21−
22− const result = yield* withPolicy(
23− requirePermission({
24− permission: "workspace:read",
25− workspaceId,
26− }),
27− )(Effect.succeed("ok")).pipe(
28− Effect.provideService(Actor, createAllowAllActor(principal)),
29− );
30−
31− expect(result).toBe("ok");
32− }),
33− );
34−});
packages/control-plane/src/domain/auth/policy.ts+0−55
@@ -1,55 +0,0 @@
1−import type {
2− OrganizationId,
3− Permission,
4− WorkspaceId,
5−} from "#schema";
6−import * as Effect from "effect/Effect";
7−
8−import {
9− Actor,
10− ActorForbiddenError,
11− type ActorShape,
12−} from "./actor";
13−
14−export type Policy<E = never, R = never> = Effect.Effect<
15− void,
16− ActorForbiddenError | E,
17− Actor | R
18−>;
19−
20−export const policy = <E, R>(
21− predicate: (actor: ActorShape) => Effect.Effect<boolean, E, R>,
22− onForbidden: (actor: ActorShape) => ActorForbiddenError,
23−): Policy<E, R> =>
24− Effect.gen(function* () {
25− const actor = yield* Actor;
26− const allowed = yield* predicate(actor);
27−
28− if (!allowed) {
29− return yield* Effect.fail(onForbidden(actor));
30− }
31− });
32−
33−export const requirePermission = (request: {
34− permission: Permission;
35− workspaceId?: WorkspaceId;
36− organizationId?: OrganizationId;
37−}): Policy =>
38− Effect.flatMap(Actor, (actor) => actor.requirePermission(request));
39−
40−export const requireWorkspaceAccess = (workspaceId: WorkspaceId): Policy =>
41− Effect.flatMap(Actor, (actor) => actor.requireWorkspaceAccess(workspaceId));
42−
43−export const withPolicy =
44− <E1, R1>(auth: Policy<E1, R1>) =>
45− <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E | E1 | ActorForbiddenError, R | R1 | Actor> =>
46− Effect.zipRight(auth, self);
47−
48−export const all = <E, R>(
49− policies: ReadonlyArray<Policy<E, R>>,
50−): Policy<E, R> =>
51− Effect.forEach(policies, (item) => item, { discard: true });
52−
53−export const any = <E, R>(
54− policies: readonly [Policy<E, R>, ...Array<Policy<E, R>>],
55−): Policy<E, R> => Effect.firstSuccessOf(policies);
packages/control-plane/src/domain/index.ts+0−1
@@ -1 +0,0 @@
1−export * from "./auth";
packages/control-plane/src/index.ts+0−2
@@ -3,5 +3,3 @@ export * from "./persistence/index"; · 2 unmodified lines
33 export * from "./api/index";
44 export * from "./runtime/index";
55 export * from "./client";
6−
7−export * as Domain from "./domain/index";
packages/control-plane/src/persistence/control-plane-rows.test.ts+0−947
@@ -1,947 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { assertTrue } from "@effect/vitest/utils";
3−import {
4− AccountIdSchema,
5− CredentialIdSchema,
6− McpSourceAuthSessionDataJsonSchema,
7− OrganizationIdSchema,
8− OrganizationMemberIdSchema,
9− PolicyIdSchema,
10− SecretMaterialIdSchema,
11− StaticBearerAuthArtifactConfigJsonSchema,
12− StaticOAuth2AuthArtifactConfigJsonSchema,
13− decodeBuiltInAuthArtifactConfig,
14− SourceAuthSessionIdSchema,
15− SourceIdSchema,
16− SourceRecipeIdSchema,
17− SourceRecipeSchemaBundleIdSchema,
18− SourceRecipeRevisionIdSchema,
19− WorkspaceIdSchema,
20−} from "#schema";
21−import * as Effect from "effect/Effect";
22−import * as Option from "effect/Option";
23−import * as Schema from "effect/Schema";
24−import * as Scope from "effect/Scope";
25−
26−import {
27− createSqlControlPlanePersistence,
28− type SqlControlPlanePersistence,
29−} from "./index";
30−import { drizzleSchema } from "./schema";
31−
32−const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
33− Effect.acquireRelease(
34− createSqlControlPlanePersistence({
35− localDataDir: ":memory:",
36− }),
37− (persistence) =>
38− Effect.tryPromise({
39− try: () => persistence.close(),
40− catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
41− }).pipe(Effect.orDie),
42− );
43−
44−const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
45−const encodeStaticBearerArtifactConfig = Schema.encodeSync(
46− StaticBearerAuthArtifactConfigJsonSchema,
47−);
48−const encodeStaticOAuth2ArtifactConfig = Schema.encodeSync(
49− StaticOAuth2AuthArtifactConfigJsonSchema,
50−);
51−
52−const openApiBindingConfigJson = (specUrl: string): string =>
53− JSON.stringify({
54− adapterKey: "openapi",
55− version: 1,
56− payload: {
57− specUrl,
58− defaultHeaders: null,
59− },
60− });
61−
62−const baseRevisionRecord = (input: {
63− id: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
64− recipeId: ReturnType<typeof SourceRecipeIdSchema.make>;
65− revisionNumber: number;
66− sourceConfigJson: string;
67− manifestJson?: string | null;
68− manifestHash?: string | null;
69− materializationHash?: string | null;
70− createdAt: number;
71− updatedAt: number;
72−}) => ({
73− id: input.id,
74− recipeId: input.recipeId,
75− revisionNumber: input.revisionNumber,
76− sourceConfigJson: input.sourceConfigJson,
77− manifestJson: input.manifestJson ?? null,
78− manifestHash: input.manifestHash ?? null,
79− materializationHash: input.materializationHash ?? null,
80− createdAt: input.createdAt,
81− updatedAt: input.updatedAt,
82−});
83−
84−const seedWorkspaceSourceState = (input: {
85− persistence: SqlControlPlanePersistence;
86− accountId: ReturnType<typeof AccountIdSchema.make>;
87− organizationId: ReturnType<typeof OrganizationIdSchema.make>;
88− workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
89− sourceId: ReturnType<typeof SourceIdSchema.make>;
90−}): Effect.Effect<void, unknown, never> =>
91− Effect.gen(function* () {
92− const now = Date.now();
93− const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
94− const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`);
95−
96− yield* input.persistence.rows.organizations.insert({
97− id: input.organizationId,
98− slug: `org-${input.organizationId}`,
99− name: `Org ${input.organizationId}`,
100− status: "active",
101− createdByAccountId: input.accountId,
102− createdAt: now,
103− updatedAt: now,
104− });
105− yield* input.persistence.rows.workspaces.insert({
106− id: input.workspaceId,
107− organizationId: input.organizationId,
108− name: `Workspace ${input.workspaceId}`,
109− createdByAccountId: input.accountId,
110− createdAt: now,
111− updatedAt: now,
112− });
113− yield* input.persistence.rows.sourceRecipes.upsert({
114− id: recipeId,
115− kind: "http_api",
116− adapterKey: "openapi",
117− providerKey: "openapi:https://api.github.com",
118− name: "Github",
119− summary: null,
120− visibility: "workspace",
121− latestRevisionId: recipeRevisionId,
122− createdAt: now,
123− updatedAt: now,
124− });
125− yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
126− id: recipeRevisionId,
127− recipeId,
128− revisionNumber: 1,
129− sourceConfigJson: JSON.stringify({
130− kind: "openapi",
131− endpoint: "https://api.github.com",
132− specUrl: "https://api.github.com/openapi.json",
133− }),
134− manifestJson: null,
135− manifestHash: null,
136− materializationHash: null,
137− createdAt: now,
138− updatedAt: now,
139− }));
140− yield* input.persistence.rows.sources.insert({
141− id: input.sourceId,
142− workspaceId: input.workspaceId,
143− recipeId,
144− recipeRevisionId,
145− name: "Github",
146− kind: "openapi",
147− endpoint: "https://api.github.com",
148− status: "connected",
149− enabled: true,
150− namespace: "github",
151− importAuthPolicy: "reuse_runtime",
152− bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
153− sourceHash: null,
154− lastError: null,
155− createdAt: now,
156− updatedAt: now,
157− });
158− });
159−
160−const seedWorkspaceAuthArtifactState = (input: {
161− persistence: SqlControlPlanePersistence;
162− accountId: ReturnType<typeof AccountIdSchema.make>;
163− organizationId: ReturnType<typeof OrganizationIdSchema.make>;
164− workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
165− sourceId: ReturnType<typeof SourceIdSchema.make>;
166−}): Effect.Effect<{
167− tokenId: ReturnType<typeof SecretMaterialIdSchema.make>;
168− refreshId: ReturnType<typeof SecretMaterialIdSchema.make>;
169−}, unknown, never> =>
170− Effect.gen(function* () {
171− const now = Date.now();
172− const authArtifactId = CredentialIdSchema.make(`cred_${input.workspaceId}`);
173− const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);
174− const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);
175−
176− yield* seedWorkspaceSourceState(input);
177− yield* input.persistence.rows.secretMaterials.upsert({
178− id: tokenId,
179− name: null,
180− purpose: "oauth_access_token",
181− value: "token",
182− createdAt: now,
183− updatedAt: now,
184− });
185− yield* input.persistence.rows.secretMaterials.upsert({
186− id: refreshId,
187− name: null,
188− purpose: "oauth_refresh_token",
189− value: "refresh",
190− createdAt: now,
191− updatedAt: now,
192− });
193− yield* input.persistence.rows.authArtifacts.upsert({
194− id: authArtifactId,
195− workspaceId: input.workspaceId,
196− sourceId: input.sourceId,
197− actorAccountId: input.accountId,
198− slot: "runtime",
199− artifactKind: "static_oauth2",
200− configJson: encodeStaticOAuth2ArtifactConfig({
201− headerName: "Authorization",
202− prefix: "Bearer ",
203− accessToken: {
204− providerId: "postgres",
205− handle: tokenId,
206− },
207− refreshToken: {
208− providerId: "postgres",
209− handle: refreshId,
210− },
211− }),
212− grantSetJson: null,
213− createdAt: now,
214− updatedAt: now,
215− });
216− yield* input.persistence.rows.sourceAuthSessions.upsert({
217− id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`),
218− workspaceId: input.workspaceId,
219− sourceId: input.sourceId,
220− actorAccountId: input.accountId,
221− executionId: null,
222− interactionId: null,
223− providerKind: "mcp_oauth",
224− credentialSlot: "runtime",
225− status: "pending",
226− state: `state_${input.workspaceId}`,
227− sessionDataJson: encodeSessionDataJson({
228− kind: "mcp_oauth",
229− endpoint: "https://api.github.com",
230− redirectUri: "http://127.0.0.1/callback",
231− scope: null,
232− resourceMetadataUrl: null,
233− authorizationServerUrl: null,
234− resourceMetadata: null,
235− authorizationServerMetadata: null,
236− clientInformation: null,
237− codeVerifier: "verifier",
238− authorizationUrl: "https://example.com/auth",
239− }),
240− errorText: null,
241− completedAt: null,
242− createdAt: now,
243− updatedAt: now,
244− });
245−
246− return { tokenId, refreshId };
247− });
248−
249−describe("control-plane-persistence-drizzle", () => {
250− it.scoped("creates and reads organization/workspace/source/policy rows", () =>
251− Effect.gen(function* () {
252− const persistence = yield* makePersistence;
253− const now = Date.now();
254− const organizationId = OrganizationIdSchema.make("org_1");
255− const accountId = AccountIdSchema.make("acc_1");
256− const workspaceId = WorkspaceIdSchema.make("ws_1");
257−
258− yield* persistence.rows.organizations.insert({
259− id: organizationId,
260− slug: "acme",
261− name: "Acme",
262− status: "active",
263− createdByAccountId: accountId,
264− createdAt: now,
265− updatedAt: now,
266− });
267−
268− yield* persistence.rows.workspaces.insert({
269− id: workspaceId,
270− organizationId,
271− name: "Main",
272− createdByAccountId: accountId,
273− createdAt: now,
274− updatedAt: now,
275− });
276−
277− yield* persistence.rows.sources.insert({
278− id: SourceIdSchema.make("src_1"),
279− workspaceId,
280− recipeId: SourceRecipeIdSchema.make("src_recipe_1"),
281− recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"),
282− name: "Github",
283− kind: "openapi",
284− endpoint: "https://api.github.com",
285− status: "draft",
286− enabled: true,
287− namespace: "github",
288− importAuthPolicy: "reuse_runtime",
289− bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
290− sourceHash: null,
291− lastError: null,
292− createdAt: now,
293− updatedAt: now,
294− });
295−
296− yield* persistence.rows.policies.insert({
297− id: PolicyIdSchema.make("pol_1"),
298− scopeType: "workspace",
299− organizationId,
300− workspaceId,
301− targetAccountId: null,
302− clientId: null,
303− resourceType: "tool_path",
304− resourcePattern: "source.github.*",
305− matchType: "glob",
306− effect: "allow",
307− approvalMode: "auto",
308− argumentConditionsJson: null,
309− priority: 10,
310− enabled: true,
311− createdAt: now,
312− updatedAt: now,
313− });
314−
315− const workspace = yield* persistence.rows.workspaces.getById(workspaceId);
316− assertTrue(Option.isSome(workspace));
317−
318− const sources = yield* persistence.rows.sources.listByWorkspaceId(workspaceId);
319− expect(sources).toHaveLength(1);
320− expect(sources[0]?.name).toBe("Github");
321−
322− const policies = yield* persistence.rows.policies.listByWorkspaceId(workspaceId);
323− expect(policies).toHaveLength(1);
324− expect(policies[0]?.resourcePattern).toBe("source.github.*");
325− }),
326− );
327−
328− it.scoped("upserts organization memberships by org/account", () =>
329− Effect.gen(function* () {
330− const persistence = yield* makePersistence;
331− const now = Date.now();
332− const organizationId = OrganizationIdSchema.make("org_1");
333− const accountId = AccountIdSchema.make("acc_1");
334−
335− yield* persistence.rows.organizationMemberships.upsert({
336− id: OrganizationMemberIdSchema.make("mem_1"),
337− organizationId,
338− accountId,
339− role: "viewer",
340− status: "active",
341− billable: true,
342− invitedByAccountId: null,
343− joinedAt: now,
344− createdAt: now,
345− updatedAt: now,
346− });
347−
348− yield* persistence.rows.organizationMemberships.upsert({
349− id: OrganizationMemberIdSchema.make("mem_2"),
350− organizationId,
351− accountId,
352− role: "admin",
353− status: "active",
354− billable: true,
355− invitedByAccountId: null,
356− joinedAt: now,
357− createdAt: now,
358− updatedAt: now + 1,
359− });
360−
361− const membership = yield* persistence.rows.organizationMemberships.getByOrganizationAndAccount(
362− organizationId,
363− accountId,
364− );
365−
366− assertTrue(Option.isSome(membership));
367− if (Option.isSome(membership)) {
368− expect(membership.value.role).toBe("admin");
369− }
370− }),
371− );
372−
373− it.scoped("batches large recipe operation replacement inserts", () =>
374− Effect.gen(function* () {
375− const persistence = yield* makePersistence;
376− const now = Date.now();
377− const recipeId = SourceRecipeIdSchema.make("src_recipe_bulk_ops");
378− const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bulk_ops");
379− const operationCount = 3_000;
380−
381− yield* persistence.rows.sourceRecipes.upsert({
382− id: recipeId,
383− kind: "http_api",
384− adapterKey: "openapi",
385− providerKey: "openapi:https://api.cloudflare.com/client/v4",
386− name: "Cloudflare API",
387− summary: null,
388− visibility: "private",
389− latestRevisionId: recipeRevisionId,
390− createdAt: now,
391− updatedAt: now,
392− });
393−
394− yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
395− id: recipeRevisionId,
396− recipeId,
397− revisionNumber: 1,
398− sourceConfigJson: JSON.stringify({
399− endpoint: "https://api.cloudflare.com/client/v4",
400− specUrl:
401− "https://raw.githubusercontent.com/cloudflare/api-schemas/refs/heads/main/openapi.json",
402− }),
403− manifestHash: null,
404− manifestJson: null,
405− materializationHash: null,
406− createdAt: now,
407− updatedAt: now,
408− }));
409−
410− yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
411− recipeRevisionId,
412− operations: Array.from({ length: operationCount }, (_, index) => ({
413− id: `src_recipe_op_${index}`,
414− recipeRevisionId,
415− operationKey: `zones.get${index}`,
416− transportKind: "http" as const,
417− toolId: `zones.get${index}`,
418− title: `Get zone ${index}`,
419− description: `Operation ${index}`,
420− operationKind: "read" as const,
421− searchText: `zones get ${index}`,
422− inputSchemaJson: null,
423− outputSchemaJson: null,
424− providerKind: "openapi" as const,
425− providerDataJson: JSON.stringify({
426− kind: "openapi",
427− toolId: `zones.get${index}`,
428− rawToolId: `getZone${index}`,
429− operationId: `getZone${index}`,
430− group: "zones",
431− leaf: `get${index}`,
432− tags: ["zones"],
433− method: "get",
434− path: `/zones/${index}`,
435− operationHash: `hash_${index}`,
436− invocation: {
437− method: "get",
438− pathTemplate: `/zones/${index}`,
439− parameters: [],
440− requestBody: null,
441− },
442− }),
443− createdAt: now,
444− updatedAt: now,
445− })),
446− });
447−
448− const operations = yield* persistence.rows.sourceRecipeOperations.listByRevisionId(
449− recipeRevisionId,
450− );
451− expect(operations).toHaveLength(operationCount);
452− expect(operations.some((operation) => operation.toolId === "zones.get0")).toBe(true);
453− expect(
454− operations.some(
455− (operation) => operation.toolId === `zones.get${operationCount - 1}`,
456− ),
457− ).toBe(true);
458− }),
459− );
460−
461− it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () =>
462− Effect.gen(function* () {
463− const persistence = yield* makePersistence;
464− const accountId = AccountIdSchema.make("acc_credentials");
465− const organizationId = OrganizationIdSchema.make("org_credentials");
466− const workspaceId = WorkspaceIdSchema.make("ws_credentials");
467− const sourceId = SourceIdSchema.make("src_credentials");
468− const actorCredentialId = CredentialIdSchema.make("cred_actor_credentials");
469− const nullCredentialA = CredentialIdSchema.make("cred_null_credentials_a");
470− const nullCredentialB = CredentialIdSchema.make("cred_null_credentials_b");
471− const now = Date.now();
472−
473− yield* seedWorkspaceSourceState({
474− persistence,
475− accountId,
476− organizationId,
477− workspaceId,
478− sourceId,
479− });
480−
481− yield* Effect.tryPromise(async () => {
482− await persistence.db.insert(drizzleSchema.authArtifactsTable).values([
483− {
484− id: nullCredentialA,
485− workspaceId,
486− sourceId,
487− actorAccountId: null,
488− slot: "runtime",
489− artifactKind: "static_bearer",
490− configJson: encodeStaticBearerArtifactConfig({
491− headerName: "Authorization",
492− prefix: "Bearer ",
493− token: {
494− providerId: "postgres",
495− handle: "sec_null_a",
496− },
497− }),
498− grantSetJson: null,
499− createdAt: now,
500− updatedAt: now,
501− },
502− {
503− id: nullCredentialB,
504− workspaceId,
505− sourceId,
506− actorAccountId: null,
507− slot: "runtime",
508− artifactKind: "static_bearer",
509− configJson: encodeStaticBearerArtifactConfig({
510− headerName: "Authorization",
511− prefix: "Bearer ",
512− token: {
513− providerId: "postgres",
514− handle: "sec_null_b",
515− },
516− }),
517− grantSetJson: null,
518− createdAt: now + 1,
519− updatedAt: now + 1,
520− },
521− ]);
522− }).pipe(Effect.orDie);
523−
524− yield* persistence.rows.authArtifacts.upsert({
525− id: CredentialIdSchema.make("cred_null_credentials_replacement"),
526− workspaceId,
527− sourceId,
528− actorAccountId: null,
529− slot: "runtime",
530− artifactKind: "static_oauth2",
531− configJson: encodeStaticOAuth2ArtifactConfig({
532− headerName: "X-Auth",
533− prefix: "Token ",
534− accessToken: {
535− providerId: "postgres",
536− handle: "sec_null_replacement",
537− },
538− refreshToken: {
539− providerId: "postgres",
540− handle: "sec_null_refresh",
541− },
542− }),
543− grantSetJson: null,
544− createdAt: now + 2,
545− updatedAt: now + 2,
546− });
547−
548− yield* persistence.rows.authArtifacts.upsert({
549− id: actorCredentialId,
550− workspaceId,
551− sourceId,
552− actorAccountId: accountId,
553− slot: "runtime",
554− artifactKind: "static_bearer",
555− configJson: encodeStaticBearerArtifactConfig({
556− headerName: "Authorization",
557− prefix: "Bearer ",
558− token: {
559− providerId: "postgres",
560− handle: "sec_actor",
561− },
562− }),
563− grantSetJson: null,
564− createdAt: now + 3,
565− updatedAt: now + 3,
566− });
567−
568− const allAuthArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
569− workspaceId,
570− sourceId,
571− });
572− expect(allAuthArtifacts).toHaveLength(2);
573− const nullActorArtifacts = allAuthArtifacts.filter((artifact) => artifact.actorAccountId === null);
574− expect(nullActorArtifacts).toHaveLength(1);
575− expect(nullActorArtifacts[0]?.id).toBe(nullCredentialA);
576− const nullActorConfig = decodeBuiltInAuthArtifactConfig(nullActorArtifacts[0]!);
577− expect(nullActorConfig?.artifactKind).toBe("static_oauth2");
578− if (nullActorConfig !== null && nullActorConfig.artifactKind === "static_oauth2") {
579− expect(nullActorConfig.config.headerName).toBe("X-Auth");
580− expect(nullActorConfig.config.accessToken.handle).toBe("sec_null_replacement");
581− }
582− expect(allAuthArtifacts.map((artifact) => artifact.id).sort()).toEqual(
583− [actorCredentialId, nullCredentialA].sort(),
584− );
585−
586− const forActor = yield* persistence.rows.authArtifacts.listByWorkspaceSourceAndActor({
587− workspaceId,
588− sourceId,
589− actorAccountId: accountId,
590− });
591− expect(forActor).toHaveLength(2);
592− expect(new Set(forActor.map((artifact) => artifact.id))).toEqual(
593− new Set([actorCredentialId, nullCredentialA]),
594− );
595−
596− const nullActorOnly = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
597− workspaceId,
598− sourceId,
599− actorAccountId: null,
600− slot: "runtime",
601− });
602− assertTrue(Option.isSome(nullActorOnly));
603− if (Option.isSome(nullActorOnly)) {
604− expect(nullActorOnly.value.id).toBe(nullCredentialA);
605− }
606−
607− const missingActor = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
608− workspaceId,
609− sourceId,
610− actorAccountId: AccountIdSchema.make("acc_missing_credentials"),
611− slot: "runtime",
612− });
613− expect(Option.isNone(missingActor)).toBe(true);
614− }),
615− );
616−
617− it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () =>
618− Effect.gen(function* () {
619− const persistence = yield* makePersistence;
620− const accountId = AccountIdSchema.make("acc_ws_cleanup");
621− const organizationId = OrganizationIdSchema.make("org_ws_cleanup");
622− const workspaceId = WorkspaceIdSchema.make("ws_cleanup");
623− const sourceId = SourceIdSchema.make("src_ws_cleanup");
624− const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
625−
626− const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({
627− persistence,
628− accountId,
629− organizationId,
630− workspaceId,
631− sourceId,
632− });
633− yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
634− recipeRevisionId,
635− documents: [
636− {
637− id: "src_recipe_doc_ws_cleanup",
638− recipeRevisionId,
639− documentKind: "openapi",
640− documentKey: "https://api.github.com/openapi.json",
641− contentText: "{\"openapi\":\"3.1.0\"}",
642− contentHash: "doc_hash_ws_cleanup",
643− fetchedAt: null,
644− createdAt: Date.now(),
645− updatedAt: Date.now(),
646− },
647− ],
648− });
649− yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
650− recipeRevisionId,
651− bundles: [
652− {
653− id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_ws_cleanup"),
654− recipeRevisionId,
655− bundleKind: "json_schema_ref_map",
656− refsJson: "{}",
657− contentHash: "bundle_hash_ws_cleanup",
658− createdAt: Date.now(),
659− updatedAt: Date.now(),
660− },
661− ],
662− });
663− yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
664− recipeRevisionId,
665− operations: [
666− {
667− id: "src_recipe_op_ws_cleanup",
668− recipeRevisionId,
669− operationKey: "users.getAuthenticated",
670− transportKind: "http",
671− toolId: "users.getAuthenticated",
672− title: "Get authenticated user",
673− description: null,
674− operationKind: "read",
675− searchText: "users get authenticated",
676− inputSchemaJson: null,
677− outputSchemaJson: null,
678− providerKind: "openapi",
679− providerDataJson: JSON.stringify({
680− kind: "openapi",
681− toolId: "users.getAuthenticated",
682− rawToolId: "users.getAuthenticated",
683− operationId: "users.getAuthenticated",
684− group: "users",
685− leaf: "getAuthenticated",
686− tags: ["users"],
687− method: "get",
688− path: "/user",
689− operationHash: "op_hash_ws_cleanup",
690− invocation: {
691− method: "get",
692− pathTemplate: "/user",
693− pathParameterOrder: [],
694− serverUrl: "https://api.github.com",
695− parameters: [],
696− requestBody: null,
697− },
698− }),
699− createdAt: Date.now(),
700− updatedAt: Date.now(),
701− },
702− ],
703− });
704−
705− const removed = yield* persistence.rows.workspaces.removeById(workspaceId);
706− expect(removed).toBe(true);
707− expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
708− expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
709− expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
710− expect(
711− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId),
712− ).toHaveLength(0);
713− expect(
714− yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(recipeRevisionId),
715− ).toHaveLength(0);
716− expect(
717− yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId),
718− ).toHaveLength(0);
719− expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
720− expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
721− }),
722− );
723−
724− it.scoped("deleting a workspace retains shared recipe data still referenced elsewhere", () =>
725− Effect.gen(function* () {
726− const persistence = yield* makePersistence;
727− const now = Date.now();
728− const accountId = AccountIdSchema.make("acc_shared_recipe");
729− const organizationId = OrganizationIdSchema.make("org_shared_recipe");
730− const removedWorkspaceId = WorkspaceIdSchema.make("ws_shared_removed");
731− const remainingWorkspaceId = WorkspaceIdSchema.make("ws_shared_remaining");
732− const sharedRecipeId = SourceRecipeIdSchema.make("src_recipe_shared_workspace");
733− const sharedRecipeRevisionId = SourceRecipeRevisionIdSchema.make(
734− "src_recipe_rev_shared_workspace",
735− );
736−
737− yield* persistence.rows.organizations.insert({
738− id: organizationId,
739− slug: "org-shared-recipe",
740− name: "Org Shared Recipe",
741− status: "active",
742− createdByAccountId: accountId,
743− createdAt: now,
744− updatedAt: now,
745− });
746− yield* persistence.rows.workspaces.insert({
747− id: removedWorkspaceId,
748− organizationId,
749− name: "Workspace Removed",
750− createdByAccountId: accountId,
751− createdAt: now,
752− updatedAt: now,
753− });
754− yield* persistence.rows.workspaces.insert({
755− id: remainingWorkspaceId,
756− organizationId,
757− name: "Workspace Remaining",
758− createdByAccountId: accountId,
759− createdAt: now,
760− updatedAt: now,
761− });
762− yield* persistence.rows.sourceRecipes.upsert({
763− id: sharedRecipeId,
764− kind: "http_api",
765− adapterKey: "openapi",
766− providerKey: "openapi:https://api.github.com",
767− name: "Github",
768− summary: null,
769− visibility: "workspace",
770− latestRevisionId: sharedRecipeRevisionId,
771− createdAt: now,
772− updatedAt: now,
773− });
774− yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
775− id: sharedRecipeRevisionId,
776− recipeId: sharedRecipeId,
777− revisionNumber: 1,
778− sourceConfigJson: JSON.stringify({
779− kind: "openapi",
780− endpoint: "https://api.github.com",
781− specUrl: "https://api.github.com/openapi.json",
782− }),
783− manifestJson: null,
784− manifestHash: null,
785− materializationHash: null,
786− createdAt: now,
787− updatedAt: now,
788− }));
789− yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
790− recipeRevisionId: sharedRecipeRevisionId,
791− documents: [
792− {
793− id: "src_recipe_doc_shared_workspace",
794− recipeRevisionId: sharedRecipeRevisionId,
795− documentKind: "openapi",
796− documentKey: "https://api.github.com/openapi.json",
797− contentText: "{\"openapi\":\"3.1.0\"}",
798− contentHash: "doc_hash_shared_workspace",
799− fetchedAt: null,
800− createdAt: now,
801− updatedAt: now,
802− },
803− ],
804− });
805− yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
806− recipeRevisionId: sharedRecipeRevisionId,
807− bundles: [
808− {
809− id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_shared_workspace"),
810− recipeRevisionId: sharedRecipeRevisionId,
811− bundleKind: "json_schema_ref_map",
812− refsJson: "{}",
813− contentHash: "bundle_hash_shared_workspace",
814− createdAt: now,
815− updatedAt: now,
816− },
817− ],
818− });
819− yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
820− recipeRevisionId: sharedRecipeRevisionId,
821− operations: [
822− {
823− id: "src_recipe_op_shared_workspace",
824− recipeRevisionId: sharedRecipeRevisionId,
825− operationKey: "users.getAuthenticated",
826− transportKind: "http",
827− toolId: "users.getAuthenticated",
828− title: "Get authenticated user",
829− description: null,
830− operationKind: "read",
831− searchText: "users get authenticated",
832− inputSchemaJson: null,
833− outputSchemaJson: null,
834− providerKind: "openapi",
835− providerDataJson: JSON.stringify({
836− kind: "openapi",
837− toolId: "users.getAuthenticated",
838− rawToolId: "users.getAuthenticated",
839− operationId: "users.getAuthenticated",
840− group: "users",
841− leaf: "getAuthenticated",
842− tags: ["users"],
843− method: "get",
844− path: "/user",
845− operationHash: "op_hash_shared_workspace",
846− invocation: {
847− method: "get",
848− pathTemplate: "/user",
849− pathParameterOrder: [],
850− serverUrl: "https://api.github.com",
851− parameters: [],
852− requestBody: null,
853− },
854− }),
855− createdAt: now,
856− updatedAt: now,
857− },
858− ],
859− });
860− yield* persistence.rows.sources.insert({
861− id: SourceIdSchema.make("src_shared_removed"),
862− workspaceId: removedWorkspaceId,
863− recipeId: sharedRecipeId,
864− recipeRevisionId: sharedRecipeRevisionId,
865− name: "Github Removed",
866− kind: "openapi",
867− endpoint: "https://api.github.com",
868− status: "connected",
869− enabled: true,
870− namespace: "github",
871− importAuthPolicy: "reuse_runtime",
872− bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
873− sourceHash: null,
874− lastError: null,
875− createdAt: now,
876− updatedAt: now,
877− });
878− yield* persistence.rows.sources.insert({
879− id: SourceIdSchema.make("src_shared_remaining"),
880− workspaceId: remainingWorkspaceId,
881− recipeId: sharedRecipeId,
882− recipeRevisionId: sharedRecipeRevisionId,
883− name: "Github Remaining",
884− kind: "openapi",
885− endpoint: "https://api.github.com",
886− status: "connected",
887− enabled: true,
888− namespace: "github",
889− importAuthPolicy: "reuse_runtime",
890− bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
891− sourceHash: null,
892− lastError: null,
893− createdAt: now,
894− updatedAt: now,
895− });
896−
897− const removed = yield* persistence.rows.workspaces.removeById(removedWorkspaceId);
898− expect(removed).toBe(true);
899− expect(Option.isNone(yield* persistence.rows.workspaces.getById(removedWorkspaceId))).toBe(true);
900− expect(Option.isSome(yield* persistence.rows.workspaces.getById(remainingWorkspaceId))).toBe(true);
901− expect(
902− yield* persistence.rows.sources.listByWorkspaceId(remainingWorkspaceId),
903− ).toHaveLength(1);
904− expect(
905− Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId)),
906− ).toBe(true);
907− expect(
908− Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)),
909− ).toBe(true);
910− expect(
911− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId),
912− ).toHaveLength(1);
913− expect(
914− yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(sharedRecipeRevisionId),
915− ).toHaveLength(1);
916− expect(
917− yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId),
918− ).toHaveLength(1);
919− }),
920− );
921−
922− it.scoped("deleting an organization removes workspace credential state and postgres secrets", () =>
923− Effect.gen(function* () {
924− const persistence = yield* makePersistence;
925− const accountId = AccountIdSchema.make("acc_org_cleanup");
926− const organizationId = OrganizationIdSchema.make("org_cleanup");
927− const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup");
928− const sourceId = SourceIdSchema.make("src_org_cleanup");
929−
930− const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({
931− persistence,
932− accountId,
933− organizationId,
934− workspaceId,
935− sourceId,
936− });
937−
938− const removed = yield* persistence.rows.organizations.removeTreeById(organizationId);
939− expect(removed).toBe(true);
940− expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
941− expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
942− expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
943− expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
944− expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
945− }),
946− );
947−});
packages/control-plane/src/persistence/index.ts+0−10
@@ -11,10 +11,6 @@ import { · 10 unmodified lines
1111 createCodeMigrationsRepo,
1212 createExecutionInteractionsRepo,
1313 createExecutionsRepo,
14− createLocalInstallationsRepo,
15− createOrganizationMembershipsRepo,
16− createOrganizationsRepo,
17− createPoliciesRepo,
1814 createSecretMaterialsRepo,
1915 createSourceAuthSessionsRepo,
2016 createSourceOauthClientsRepo,
@@ -24,7 +20,6 @@ import { · 3 unmodified lines
2420 createSourceRecipeRevisionsRepo,
2521 createSourceRecipesRepo,
2622 createSourcesRepo,
27− createWorkspacesRepo,
2823 } from "./repos";
2924 import { drizzleSchema, tableNames, type DrizzleTables } from "./schema";
3025 import {
@@ -55,9 +50,6 @@ export { · 24 unmodified lines
5550
5651 const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema) => ({
5752 accounts: createAccountsRepo(client, tables),
58− organizations: createOrganizationsRepo(client, tables),
59− organizationMemberships: createOrganizationMembershipsRepo(client, tables),
60− workspaces: createWorkspacesRepo(client, tables),
6153 sources: createSourcesRepo(client, tables),
6254 sourceRecipes: createSourceRecipesRepo(client, tables),
6355 sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables),
@@ -70,8 +62,6 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 6 unmodified lines
7062 sourceOauthClients: createSourceOauthClientsRepo(client, tables),
7163 secretMaterials: createSecretMaterialsRepo(client, tables),
7264 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
73− policies: createPoliciesRepo(client, tables),
74− localInstallations: createLocalInstallationsRepo(client, tables),
7565 executions: createExecutionsRepo(client, tables),
7666 executionInteractions: createExecutionInteractionsRepo(client, tables),
7767 });
packages/control-plane/src/persistence/post-migrations.test.ts+86−108
@@ -7,7 +7,6 @@ import { describe, expect, it } from "@effect/vitest"; · 6 unmodified lines
77 import {
88 AccountIdSchema,
99 McpSourceAuthSessionDataJsonSchema,
10− OrganizationIdSchema,
1110 SourceAuthSessionIdSchema,
1211 decodeAuthLeasePlacementTemplates,
1312 SourceIdSchema,
@@ -27,7 +26,6 @@ import { · 13 unmodified lines
2726 type SqlControlPlanePersistence,
2827 } from "./index";
2928 import { runCodeMigrations } from "./code-migrations";
30−import { loadSourceById } from "../runtime/source-store";
3129
3230 const openApiBindingConfigJson = (specUrl: string): string =>
3331 JSON.stringify({
@@ -124,29 +122,11 @@ const seedMigratedSourceRecipe = (input: { · 90 unmodified lines
124122 Effect.gen(function* () {
125123 const now = Date.now();
126124 const accountId = AccountIdSchema.make(`acc_${input.sourceId}`);
127− const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`);
128125 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
129126 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(
130127 `src_recipe_rev_${input.sourceId}`,
131128 );
132129
133− yield* input.persistence.rows.organizations.insert({
134− id: organizationId,
135− slug: `org-${input.sourceId}`,
136− name: `Org ${input.sourceId}`,
137− status: "active",
138− createdByAccountId: accountId,
139− createdAt: now,
140− updatedAt: now,
141− });
142− yield* input.persistence.rows.workspaces.insert({
143− id: input.workspaceId,
144− organizationId,
145− name: `Workspace ${input.sourceId}`,
146− createdByAccountId: accountId,
147− createdAt: now,
148− updatedAt: now,
149− });
150130 yield* input.persistence.rows.sourceRecipes.upsert({
151131 id: recipeId,
152132 kind: "http_api",
@@ -318,6 +298,7 @@ describe("code-migrations", () => { · 165 unmodified lines
318298 )).length,
319299 ).toBeGreaterThan(0);
320300 }),
301+ 60_000,
321302 );
322303
323304 it.scoped("repairs migrated GraphQL recipes from stored documents", () =>
@@ -376,6 +357,7 @@ describe("code-migrations", () => { · 52 unmodified lines
376357 )).length,
377358 ).toBeGreaterThan(0);
378359 }),
360+ 60_000,
379361 );
380362
381363 it.scoped("repairs legacy MCP source auth session payloads", () =>
@@ -450,7 +432,6 @@ describe("code-migrations", () => { · 68 unmodified lines
450432 );
451433 const workspaceId = WorkspaceIdSchema.make("ws_legacy_source_bindings");
452434 const accountId = AccountIdSchema.make("acc_legacy_source_bindings");
453− const organizationId = OrganizationIdSchema.make("org_legacy_source_bindings");
454435 const now = Date.now();
455436 let legacyPersistence: SqlControlPlanePersistence | null = null;
456437 let upgradedPersistence: SqlControlPlanePersistence | null = null;
@@ -464,24 +445,6 @@ describe("code-migrations", () => { · 7 unmodified lines
464445 }),
465446 );
466447
467− await Effect.runPromise(legacyPersistence.rows.organizations.insert({
468− id: organizationId,
469− slug: "org-legacy-source-bindings",
470− name: "Legacy Org",
471− status: "active",
472− createdByAccountId: accountId,
473− createdAt: now,
474− updatedAt: now,
475− }));
476− await Effect.runPromise(legacyPersistence.rows.workspaces.insert({
477− id: workspaceId,
478− organizationId,
479− name: "Legacy Workspace",
480− createdByAccountId: accountId,
481− createdAt: now,
482− updatedAt: now,
483− }));
484−
485448 const seedRecipe = async (input: {
486449 sourceId: string;
487450 kind: "openapi" | "graphql" | "mcp";
@@ -693,31 +656,40 @@ describe("code-migrations", () => { · 205 unmodified lines
693656 }),
694657 );
695658
696− const openApiSource = await Effect.runPromise(loadSourceById(
697− upgradedPersistence.rows,
698− {
659+ const openApiSource = await Effect.runPromise(
660+ upgradedPersistence.rows.sources.getByWorkspaceAndId(
699661 workspaceId,
700− sourceId: SourceIdSchema.make("src_legacy_openapi"),
701− },
702− ));
703− expect(openApiSource.binding).toEqual({
704− specUrl: "https://api.example.com/openapi.json",
705− defaultHeaders: {
706− accept: "application/json",
662+ SourceIdSchema.make("src_legacy_openapi"),
663+ ),
664+ );
665+ expect(
666+ JSON.parse(Option.getOrThrow(openApiSource).bindingConfigJson),
667+ ).toEqual({
668+ adapterKey: "openapi",
669+ version: 1,
670+ payload: {
671+ specUrl: "https://api.example.com/openapi.json",
672+ defaultHeaders: {
673+ accept: "application/json",
674+ },
707675 },
708676 });
709− expect(openApiSource.bindingVersion).toBe(1);
710677
711− const graphqlSource = await Effect.runPromise(loadSourceById(
712− upgradedPersistence.rows,
713− {
678+ const graphqlSource = await Effect.runPromise(
679+ upgradedPersistence.rows.sources.getByWorkspaceAndId(
714680 workspaceId,
715− sourceId: SourceIdSchema.make("src_legacy_graphql"),
716− },
717− ));
718− expect(graphqlSource.binding).toEqual({
719− defaultHeaders: {
720− accept: "application/json",
681+ SourceIdSchema.make("src_legacy_graphql"),
682+ ),
683+ );
684+ expect(
685+ JSON.parse(Option.getOrThrow(graphqlSource).bindingConfigJson),
686+ ).toEqual({
687+ adapterKey: "graphql",
688+ version: 1,
689+ payload: {
690+ defaultHeaders: {
691+ accept: "application/json",
692+ },
721693 },
722694 });
723695
@@ -726,20 +698,25 @@ describe("code-migrations", () => { · 2 unmodified lines
726698 );
727699 expect(Option.getOrNull(graphqlRecipe)?.adapterKey).toBe("graphql");
728700
729− const mcpSource = await Effect.runPromise(loadSourceById(
730− upgradedPersistence.rows,
731− {
701+ const mcpSource = await Effect.runPromise(
702+ upgradedPersistence.rows.sources.getByWorkspaceAndId(
732703 workspaceId,
733− sourceId: SourceIdSchema.make("src_legacy_mcp"),
734− },
735− ));
736− expect(mcpSource.binding).toEqual({
737− transport: "streamable-http",
738− queryParams: {
739− tenant: "acme",
740− },
741− headers: {
742− "x-tenant": "acme",
704+ SourceIdSchema.make("src_legacy_mcp"),
705+ ),
706+ );
707+ expect(
708+ JSON.parse(Option.getOrThrow(mcpSource).bindingConfigJson),
709+ ).toEqual({
710+ adapterKey: "mcp",
711+ version: 1,
712+ payload: {
713+ transport: "streamable-http",
714+ queryParams: {
715+ tenant: "acme",
716+ },
717+ headers: {
718+ "x-tenant": "acme",
719+ },
743720 },
744721 });
745722
@@ -762,7 +739,6 @@ describe("code-migrations", () => { · 16 unmodified lines
762739 );
763740 const workspaceId = WorkspaceIdSchema.make("ws_legacy_auth_lease");
764741 const accountId = AccountIdSchema.make("acc_legacy_auth_lease");
765− const organizationId = OrganizationIdSchema.make("org_legacy_auth_lease");
766742 const sourceId = SourceIdSchema.make("src_legacy_auth_lease");
767743 const recipeId = SourceRecipeIdSchema.make("src_recipe_legacy_auth_lease");
768744 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(
@@ -782,23 +758,6 @@ describe("code-migrations", () => { · 13 unmodified lines
782758 }),
783759 );
784760
785− await Effect.runPromise(legacyPersistence.rows.organizations.insert({
786− id: organizationId,
787− slug: "org-legacy-auth-lease",
788− name: "Legacy Org",
789− status: "active",
790− createdByAccountId: accountId,
791− createdAt: now,
792− updatedAt: now,
793− }));
794− await Effect.runPromise(legacyPersistence.rows.workspaces.insert({
795− id: workspaceId,
796− organizationId,
797− name: "Legacy Workspace",
798− createdByAccountId: accountId,
799− createdAt: now,
800− updatedAt: now,
801− }));
802761 await Effect.runPromise(legacyPersistence.rows.sourceRecipes.upsert({
803762 id: recipeId,
804763 kind: "http_api",
@@ -823,24 +782,43 @@ describe("code-migrations", () => { · 18 unmodified lines
823782 createdAt: now,
824783 updatedAt: now,
825784 })));
826− await Effect.runPromise(legacyPersistence.rows.sources.insert({
827− id: sourceId,
828− workspaceId,
829− recipeId,
830− recipeRevisionId,
831− name: "Legacy OpenAPI",
832− kind: "openapi",
833− endpoint: "https://api.example.com",
834− status: "connected",
835− enabled: true,
836− namespace: "legacy.openapi",
837− importAuthPolicy: "reuse_runtime",
838− bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"),
839− sourceHash: null,
840− lastError: null,
841− createdAt: now,
842− updatedAt: now,
843− }));
785+ await legacyPersistence.db.execute(sql`
786+ INSERT INTO "sources" (
787+ "source_id",
788+ "workspace_id",
789+ "recipe_id",
790+ "recipe_revision_id",
791+ "name",
792+ "kind",
793+ "endpoint",
794+ "status",
795+ "enabled",
796+ "namespace",
797+ "import_auth_policy",
798+ "binding_config_json",
799+ "source_hash",
800+ "last_error",
801+ "created_at",
802+ "updated_at"
803+ ) VALUES (
804+ ${sourceId},
805+ ${workspaceId},
806+ ${recipeId},
807+ ${recipeRevisionId},
808+ ${"Legacy OpenAPI"},
809+ ${"openapi"},
810+ ${"https://api.example.com"},
811+ ${"connected"},
812+ ${true},
813+ ${"legacy.openapi"},
814+ ${"reuse_runtime"},
815+ ${openApiBindingConfigJson("https://api.example.com/openapi.json")},
816+ NULL,
817+ NULL,
818+ ${now},
819+ ${now}
820+ )
821+ `);
844822 await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({
845823 id: authArtifactId as any,
846824 workspaceId,
packages/control-plane/src/persistence/repos/index.ts+0−5
@@ -1,7 +1,4 @@
11 export { createAccountsRepo } from "./accounts-repo";
2−export { createOrganizationsRepo } from "./organizations-repo";
3−export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";
4−export { createWorkspacesRepo } from "./workspaces-repo";
52 export { createSourcesRepo } from "./sources-repo";
63 export { createSourceRecipesRepo } from "./source-recipes-repo";
74 export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";
@@ -14,7 +11,5 @@ export { createAuthLeasesRepo } from "./auth-leases-repo"; · 6 unmodified lines
1411 export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
1512 export { createSecretMaterialsRepo } from "./secret-materials-repo";
1613 export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
17−export { createPoliciesRepo } from "./policies-repo";
18−export { createLocalInstallationsRepo } from "./local-installations-repo";
1914 export { createExecutionsRepo } from "./executions-repo";
2015 export { createExecutionInteractionsRepo } from "./execution-interactions-repo";
packages/control-plane/src/persistence/repos/local-installations-repo.ts+0−48
@@ -1,48 +0,0 @@
1−import {
2− type LocalInstallation,
3− LocalInstallationSchema,
4−} from "#schema";
5−import * as Option from "effect/Option";
6−import { Schema } from "effect";
7−import { eq } from "drizzle-orm";
8−
9−import type { DrizzleClient } from "../client";
10−import type { DrizzleTables } from "../schema";
11−import { firstOption } from "./shared";
12−
13−const decodeLocalInstallation = Schema.decodeUnknownSync(LocalInstallationSchema);
14−
15−export const createLocalInstallationsRepo = (
16− client: DrizzleClient,
17− tables: DrizzleTables,
18−) => ({
19− getById: (installationId: LocalInstallation["id"]) =>
20− client.use("rows.local_installations.get_by_id", async (db) => {
21− const rows = await db
22− .select()
23− .from(tables.localInstallationsTable)
24− .where(eq(tables.localInstallationsTable.id, installationId))
25− .limit(1);
26−
27− const row = firstOption(rows);
28− return Option.isSome(row)
29− ? Option.some(decodeLocalInstallation(row.value))
30− : Option.none<LocalInstallation>();
31− }),
32−
33− upsert: (installation: LocalInstallation) =>
34− client.use("rows.local_installations.upsert", async (db) => {
35− await db
36− .insert(tables.localInstallationsTable)
37− .values(installation)
38− .onConflictDoUpdate({
39− target: tables.localInstallationsTable.id,
40− set: {
41− accountId: installation.accountId,
42− organizationId: installation.organizationId,
43− workspaceId: installation.workspaceId,
44− updatedAt: installation.updatedAt,
45− },
46− });
47− }),
48−});
packages/control-plane/src/persistence/repos/organization-memberships-repo.ts+0−110
@@ -1,110 +0,0 @@
1−import {
2− type OrganizationMembership,
3− OrganizationMembershipSchema,
4−} from "#schema";
5−import * as Option from "effect/Option";
6−import { Schema } from "effect";
7−import { and, asc, eq } from "drizzle-orm";
8−
9−import type { DrizzleClient } from "../client";
10−import type { DrizzleTables } from "../schema";
11−import { firstOption, withoutCreatedAt } from "./shared";
12−
13−const decodeOrganizationMembership = Schema.decodeUnknownSync(
14− OrganizationMembershipSchema,
15−);
16−
17−export const createOrganizationMembershipsRepo = (
18− client: DrizzleClient,
19− tables: DrizzleTables,
20−) => ({
21− listByOrganizationId: (
22− organizationId: OrganizationMembership["organizationId"],
23− ) =>
24− client.use("rows.organization_memberships.list_by_organization", async (db) => {
25− const rows = await db
26− .select()
27− .from(tables.organizationMembershipsTable)
28− .where(eq(tables.organizationMembershipsTable.organizationId, organizationId))
29− .orderBy(
30− asc(tables.organizationMembershipsTable.updatedAt),
31− asc(tables.organizationMembershipsTable.id),
32− );
33−
34− return rows.map((row) => decodeOrganizationMembership(row));
35− }),
36−
37− listByAccountId: (accountId: OrganizationMembership["accountId"]) =>
38− client.use("rows.organization_memberships.list_by_account", async (db) => {
39− const rows = await db
40− .select()
41− .from(tables.organizationMembershipsTable)
42− .where(eq(tables.organizationMembershipsTable.accountId, accountId))
43− .orderBy(
44− asc(tables.organizationMembershipsTable.updatedAt),
45− asc(tables.organizationMembershipsTable.id),
46− );
47−
48− return rows.map((row) => decodeOrganizationMembership(row));
49− }),
50−
51− getByOrganizationAndAccount: (
52− organizationId: OrganizationMembership["organizationId"],
53− accountId: OrganizationMembership["accountId"],
54− ) =>
55− client.use(
56− "rows.organization_memberships.get_by_organization_and_account",
57− async (db) => {
58− const rows = await db
59− .select()
60− .from(tables.organizationMembershipsTable)
61− .where(
62− and(
63− eq(tables.organizationMembershipsTable.organizationId, organizationId),
64− eq(tables.organizationMembershipsTable.accountId, accountId),
65− ),
66− )
67− .limit(1);
68−
69− const row = firstOption(rows);
70− return Option.isSome(row)
71− ? Option.some(decodeOrganizationMembership(row.value))
72− : Option.none<OrganizationMembership>();
73− },
74− ),
75−
76− upsert: (membership: OrganizationMembership) =>
77− client.use("rows.organization_memberships.upsert", async (db) => {
78− await db
79− .insert(tables.organizationMembershipsTable)
80− .values(membership)
81− .onConflictDoUpdate({
82− target: [
83− tables.organizationMembershipsTable.organizationId,
84− tables.organizationMembershipsTable.accountId,
85− ],
86− set: {
87− ...withoutCreatedAt(membership),
88− id: membership.id,
89− },
90− });
91− }),
92−
93− removeByOrganizationAndAccount: (
94− organizationId: OrganizationMembership["organizationId"],
95− accountId: OrganizationMembership["accountId"],
96− ) =>
97− client.use("rows.organization_memberships.remove", async (db) => {
98− const deleted = await db
99− .delete(tables.organizationMembershipsTable)
100− .where(
101− and(
102− eq(tables.organizationMembershipsTable.organizationId, organizationId),
103− eq(tables.organizationMembershipsTable.accountId, accountId),
104− ),
105− )
106− .returning();
107−
108− return deleted.length > 0;
109− }),
110−});
packages/control-plane/src/persistence/repos/organizations-repo.ts+0−233
@@ -1,233 +0,0 @@
1−import {
2− type Organization,
3− type OrganizationMembership,
4− OrganizationMembershipSchema,
5− OrganizationSchema,
6−} from "#schema";
7−import * as Option from "effect/Option";
8−import { Schema } from "effect";
9−import { asc, eq, inArray } from "drizzle-orm";
10−
11−import type { DrizzleClient } from "../client";
12−import type { DrizzleTables } from "../schema";
13−import {
14− cleanupOrphanedSourceRecipes,
15− firstOption,
16− postgresSecretHandlesFromAuthArtifacts,
17− withoutCreatedAt,
18−} from "./shared";
19−
20−const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema);
21−const decodeOrganizationMembership = Schema.decodeUnknownSync(
22− OrganizationMembershipSchema,
23−);
24−
25−export const createOrganizationsRepo = (
26− client: DrizzleClient,
27− tables: DrizzleTables,
28−) => ({
29− list: () =>
30− client.use("rows.organizations.list", async (db) => {
31− const rows = await db
32− .select()
33− .from(tables.organizationsTable)
34− .orderBy(
35− asc(tables.organizationsTable.updatedAt),
36− asc(tables.organizationsTable.id),
37− );
38−
39− return rows.map((row) => decodeOrganization(row));
40− }),
41−
42− getById: (organizationId: Organization["id"]) =>
43− client.use("rows.organizations.get_by_id", async (db) => {
44− const rows = await db
45− .select()
46− .from(tables.organizationsTable)
47− .where(eq(tables.organizationsTable.id, organizationId))
48− .limit(1);
49−
50− const row = firstOption(rows);
51− return Option.isSome(row)
52− ? Option.some(decodeOrganization(row.value))
53− : Option.none<Organization>();
54− }),
55−
56− getBySlug: (slug: Organization["slug"]) =>
57− client.use("rows.organizations.get_by_slug", async (db) => {
58− const rows = await db
59− .select()
60− .from(tables.organizationsTable)
61− .where(eq(tables.organizationsTable.slug, slug))
62− .limit(1);
63−
64− const row = firstOption(rows);
65− return Option.isSome(row)
66− ? Option.some(decodeOrganization(row.value))
67− : Option.none<Organization>();
68− }),
69−
70− insert: (organization: Organization) =>
71− client.use("rows.organizations.insert", async (db) => {
72− await db.insert(tables.organizationsTable).values(organization);
73− }),
74−
75− insertWithOwnerMembership: (
76− organization: Organization,
77− ownerMembership: OrganizationMembership | null,
78− ) =>
79− client.useTx("rows.organizations.insert_with_owner_membership", async (tx) => {
80− await tx.insert(tables.organizationsTable).values(organization);
81−
82− if (ownerMembership !== null) {
83− await tx
84− .insert(tables.organizationMembershipsTable)
85− .values(decodeOrganizationMembership(ownerMembership))
86− .onConflictDoUpdate({
87− target: [
88− tables.organizationMembershipsTable.organizationId,
89− tables.organizationMembershipsTable.accountId,
90− ],
91− set: {
92− ...withoutCreatedAt(ownerMembership),
93− id: ownerMembership.id,
94− },
95− });
96− }
97− }),
98−
99− update: (
100− organizationId: Organization["id"],
101− patch: Partial<Omit<Organization, "id" | "createdAt">>,
102− ) =>
103− client.use("rows.organizations.update", async (db) => {
104− const rows = await db
105− .update(tables.organizationsTable)
106− .set(patch)
107− .where(eq(tables.organizationsTable.id, organizationId))
108− .returning();
109−
110− const row = firstOption(rows);
111− return Option.isSome(row)
112− ? Option.some(decodeOrganization(row.value))
113− : Option.none<Organization>();
114− }),
115−
116− removeById: (organizationId: Organization["id"]) =>
117− client.use("rows.organizations.remove", async (db) => {
118− const deleted = await db
119− .delete(tables.organizationsTable)
120− .where(eq(tables.organizationsTable.id, organizationId))
121− .returning();
122−
123− return deleted.length > 0;
124− }),
125−
126− removeTreeById: (organizationId: Organization["id"]) =>
127− client.useTx("rows.organizations.remove_tree", async (tx) => {
128− const workspaces = await tx
129− .select({ id: tables.workspacesTable.id })
130− .from(tables.workspacesTable)
131− .where(eq(tables.workspacesTable.organizationId, organizationId));
132−
133− const workspaceIds = workspaces.map((workspace) => workspace.id);
134−
135− if (workspaceIds.length > 0) {
136− const executionRows = await tx
137− .select({ id: tables.executionsTable.id })
138− .from(tables.executionsTable)
139− .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
140− const sourceRows = await tx
141− .select({
142− recipeId: tables.sourcesTable.recipeId,
143− recipeRevisionId: tables.sourcesTable.recipeRevisionId,
144− })
145− .from(tables.sourcesTable)
146− .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
147− const authArtifacts = await tx
148− .select({
149− artifactKind: tables.authArtifactsTable.artifactKind,
150− configJson: tables.authArtifactsTable.configJson,
151− })
152− .from(tables.authArtifactsTable)
153− .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds));
154−
155− const executionIds = executionRows.map((execution) => execution.id);
156− const recipeIds = sourceRows.map((source) => source.recipeId);
157− const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
158− const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts);
159−
160− if (executionIds.length > 0) {
161− await tx
162− .delete(tables.executionInteractionsTable)
163− .where(
164− inArray(tables.executionInteractionsTable.executionId, executionIds),
165− );
166− }
167−
168− await tx
169− .delete(tables.executionsTable)
170− .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
171−
172− await tx
173− .delete(tables.authLeasesTable)
174− .where(inArray(tables.authLeasesTable.workspaceId, workspaceIds));
175−
176− await tx
177− .delete(tables.sourceAuthSessionsTable)
178− .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));
179−
180− await tx
181− .delete(tables.workspaceSourceOauthClientsTable)
182− .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds));
183−
184− await tx
185− .delete(tables.authArtifactsTable)
186− .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds));
187−
188− await tx
189− .delete(tables.sourcesTable)
190− .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
191−
192− await cleanupOrphanedSourceRecipes({
193− tx,
194− tables,
195− candidateRecipeIds: recipeIds,
196− candidateRecipeRevisionIds: recipeRevisionIds,
197− });
198−
199− await tx
200− .delete(tables.policiesTable)
201− .where(inArray(tables.policiesTable.workspaceId, workspaceIds));
202−
203− await tx
204− .delete(tables.workspacesTable)
205− .where(inArray(tables.workspacesTable.id, workspaceIds));
206−
207− if (postgresSecretHandles.length > 0) {
208− await tx
209− .delete(tables.secretMaterialsTable)
210− .where(inArray(tables.secretMaterialsTable.id, postgresSecretHandles));
211− }
212− }
213−
214− await tx
215− .delete(tables.localInstallationsTable)
216− .where(eq(tables.localInstallationsTable.organizationId, organizationId));
217−
218− await tx
219− .delete(tables.organizationMembershipsTable)
220− .where(eq(tables.organizationMembershipsTable.organizationId, organizationId));
221−
222− await tx
223− .delete(tables.policiesTable)
224− .where(eq(tables.policiesTable.organizationId, organizationId));
225−
226− const deleted = await tx
227− .delete(tables.organizationsTable)
228− .where(eq(tables.organizationsTable.id, organizationId))
229− .returning();
230−
231− return deleted.length > 0;
232− }),
233−});
packages/control-plane/src/persistence/repos/policies-repo.ts+0−118
@@ -1,118 +0,0 @@
1−import { type Policy, PolicySchema } from "#schema";
2−import * as Option from "effect/Option";
3−import { Schema } from "effect";
4−import { and, asc, desc, eq, or } from "drizzle-orm";
5−
6−import type { DrizzleClient } from "../client";
7−import type { DrizzleTables } from "../schema";
8−import { firstOption } from "./shared";
9−
10−const decodePolicy = Schema.decodeUnknownSync(PolicySchema);
11−
12−export const createPoliciesRepo = (
13− client: DrizzleClient,
14− tables: DrizzleTables,
15−) => ({
16− listByOrganizationId: (organizationId: Policy["organizationId"]) =>
17− client.use("rows.policies.list_by_organization", async (db) => {
18− const rows = await db
19− .select()
20− .from(tables.policiesTable)
21− .where(
22− and(
23− eq(tables.policiesTable.organizationId, organizationId),
24− eq(tables.policiesTable.scopeType, "organization"),
25− ),
26− )
27− .orderBy(desc(tables.policiesTable.priority), asc(tables.policiesTable.updatedAt));
28−
29− return rows.map((row) => decodePolicy(row));
30− }),
31−
32− listByWorkspaceId: (workspaceId: Exclude<Policy["workspaceId"], null>) =>
33− client.use("rows.policies.list_by_workspace", async (db) => {
34− const rows = await db
35− .select()
36− .from(tables.policiesTable)
37− .where(
38− and(
39− eq(tables.policiesTable.workspaceId, workspaceId),
40− eq(tables.policiesTable.scopeType, "workspace"),
41− ),
42− )
43− .orderBy(desc(tables.policiesTable.priority), asc(tables.policiesTable.updatedAt));
44−
45− return rows.map((row) => decodePolicy(row));
46− }),
47−
48− listForWorkspaceContext: (input: {
49− organizationId: Policy["organizationId"];
50− workspaceId: Exclude<Policy["workspaceId"], null>;
51− }) =>
52− client.use("rows.policies.list_for_workspace_context", async (db) => {
53− const rows = await db
54− .select()
55− .from(tables.policiesTable)
56− .where(
57− and(
58− eq(tables.policiesTable.organizationId, input.organizationId),
59− or(
60− eq(tables.policiesTable.scopeType, "organization"),
61− and(
62− eq(tables.policiesTable.scopeType, "workspace"),
63− eq(tables.policiesTable.workspaceId, input.workspaceId),
64− ),
65− ),
66− ),
67− )
68− .orderBy(desc(tables.policiesTable.priority), asc(tables.policiesTable.updatedAt));
69−
70− return rows.map((row) => decodePolicy(row));
71− }),
72−
73− getById: (policyId: Policy["id"]) =>
74− client.use("rows.policies.get_by_id", async (db) => {
75− const rows = await db
76− .select()
77− .from(tables.policiesTable)
78− .where(eq(tables.policiesTable.id, policyId))
79− .limit(1);
80−
81− const row = firstOption(rows);
82− return Option.isSome(row)
83− ? Option.some(decodePolicy(row.value))
84− : Option.none<Policy>();
85− }),
86−
87− insert: (policy: Policy) =>
88− client.use("rows.policies.insert", async (db) => {
89− await db.insert(tables.policiesTable).values(policy);
90− }),
91−
92− update: (
93− policyId: Policy["id"],
94− patch: Partial<Omit<Policy, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">>,
95− ) =>
96− client.use("rows.policies.update", async (db) => {
97− const rows = await db
98− .update(tables.policiesTable)
99− .set(patch)
100− .where(eq(tables.policiesTable.id, policyId))
101− .returning();
102−
103− const row = firstOption(rows);
104− return Option.isSome(row)
105− ? Option.some(decodePolicy(row.value))
106− : Option.none<Policy>();
107− }),
108−
109− removeById: (policyId: Policy["id"]) =>
110− client.use("rows.policies.remove", async (db) => {
111− const deleted = await db
112− .delete(tables.policiesTable)
113− .where(eq(tables.policiesTable.id, policyId))
114− .returning();
115−
116− return deleted.length > 0;
117− }),
118−});
packages/control-plane/src/persistence/repos/workspaces-repo.ts+0−158
@@ -1,158 +0,0 @@
1−import { type Workspace, WorkspaceSchema } from "#schema";
2−import * as Option from "effect/Option";
3−import { Schema } from "effect";
4−import { and, asc, eq, inArray } from "drizzle-orm";
5−
6−import type { DrizzleClient } from "../client";
7−import type { DrizzleTables } from "../schema";
8−import {
9− cleanupOrphanedSourceRecipes,
10− firstOption,
11− postgresSecretHandlesFromAuthArtifacts,
12−} from "./shared";
13−
14−const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
15−
16−export const createWorkspacesRepo = (
17− client: DrizzleClient,
18− tables: DrizzleTables,
19−) => ({
20− listByOrganizationId: (organizationId: Workspace["organizationId"]) =>
21− client.use("rows.workspaces.list_by_organization", async (db) => {
22− const rows = await db
23− .select()
24− .from(tables.workspacesTable)
25− .where(eq(tables.workspacesTable.organizationId, organizationId))
26− .orderBy(
27− asc(tables.workspacesTable.updatedAt),
28− asc(tables.workspacesTable.id),
29− );
30−
31− return rows.map((row) => decodeWorkspace(row));
32− }),
33−
34− getById: (workspaceId: Workspace["id"]) =>
35− client.use("rows.workspaces.get_by_id", async (db) => {
36− const rows = await db
37− .select()
38− .from(tables.workspacesTable)
39− .where(eq(tables.workspacesTable.id, workspaceId))
40− .limit(1);
41−
42− const row = firstOption(rows);
43− return Option.isSome(row)
44− ? Option.some(decodeWorkspace(row.value))
45− : Option.none<Workspace>();
46− }),
47−
48− insert: (workspace: Workspace) =>
49− client.use("rows.workspaces.insert", async (db) => {
50− await db.insert(tables.workspacesTable).values(workspace);
51− }),
52−
53− update: (
54− workspaceId: Workspace["id"],
55− patch: Partial<Omit<Workspace, "id" | "createdAt">>,
56− ) =>
57− client.use("rows.workspaces.update", async (db) => {
58− const rows = await db
59− .update(tables.workspacesTable)
60− .set(patch)
61− .where(eq(tables.workspacesTable.id, workspaceId))
62− .returning();
63−
64− const row = firstOption(rows);
65− return Option.isSome(row)
66− ? Option.some(decodeWorkspace(row.value))
67− : Option.none<Workspace>();
68− }),
69−
70− removeById: (workspaceId: Workspace["id"]) =>
71− client.useTx("rows.workspaces.remove", async (tx) => {
72− const executionRows = await tx
73− .select({ id: tables.executionsTable.id })
74− .from(tables.executionsTable)
75− .where(eq(tables.executionsTable.workspaceId, workspaceId));
76− const sourceRows = await tx
77− .select({
78− recipeId: tables.sourcesTable.recipeId,
79− recipeRevisionId: tables.sourcesTable.recipeRevisionId,
80− })
81− .from(tables.sourcesTable)
82− .where(eq(tables.sourcesTable.workspaceId, workspaceId));
83− const executionIds = executionRows.map((execution) => execution.id);
84− const recipeIds = sourceRows.map((source) => source.recipeId);
85− const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
86− const authArtifacts = await tx
87− .select({
88− artifactKind: tables.authArtifactsTable.artifactKind,
89− configJson: tables.authArtifactsTable.configJson,
90− })
91− .from(tables.authArtifactsTable)
92− .where(eq(tables.authArtifactsTable.workspaceId, workspaceId));
93− const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts);
94−
95− if (executionIds.length > 0) {
96− await tx
97− .delete(tables.executionInteractionsTable)
98− .where(inArray(tables.executionInteractionsTable.executionId, executionIds));
99− }
100−
101− await tx
102− .delete(tables.executionsTable)
103− .where(eq(tables.executionsTable.workspaceId, workspaceId));
104−
105− await tx
106− .delete(tables.authLeasesTable)
107− .where(eq(tables.authLeasesTable.workspaceId, workspaceId));
108−
109− await tx
110− .delete(tables.sourceAuthSessionsTable)
111− .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));
112−
113− await tx
114− .delete(tables.workspaceSourceOauthClientsTable)
115− .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId));
116−
117− await tx
118− .delete(tables.authArtifactsTable)
119− .where(eq(tables.authArtifactsTable.workspaceId, workspaceId));
120−
121− await tx
122− .delete(tables.sourcesTable)
123− .where(eq(tables.sourcesTable.workspaceId, workspaceId));
124−
125− await cleanupOrphanedSourceRecipes({
126− tx,
127− tables,
128− candidateRecipeIds: recipeIds,
129− candidateRecipeRevisionIds: recipeRevisionIds,
130− });
131−
132− await tx
133− .delete(tables.policiesTable)
134− .where(
135− and(
136− eq(tables.policiesTable.scopeType, "workspace"),
137− eq(tables.policiesTable.workspaceId, workspaceId),
138− ),
139− );
140−
141− await tx
142− .delete(tables.localInstallationsTable)
143− .where(eq(tables.localInstallationsTable.workspaceId, workspaceId));
144−
145− if (postgresSecretHandles.length > 0) {
146− await tx
147− .delete(tables.secretMaterialsTable)
148− .where(inArray(tables.secretMaterialsTable.id, postgresSecretHandles));
149− }
150−
151− const deleted = await tx
152− .delete(tables.workspacesTable)
153− .where(eq(tables.workspacesTable.id, workspaceId))
154− .returning();
155−
156− return deleted.length > 0;
157− }),
158−});
packages/control-plane/src/persistence/schema.ts+0−167
@@ -12,9 +12,6 @@ import { sql } from "drizzle-orm"; · 11 unmodified lines
1212
1313 export const tableNames = {
1414 accounts: "accounts",
15− organizations: "organizations",
16− organizationMemberships: "organization_memberships",
17− workspaces: "workspaces",
1815 sources: "sources",
1916 sourceRecipes: "source_recipes",
2017 sourceRecipeRevisions: "source_recipe_revisions",
@@ -27,8 +24,6 @@ export const tableNames = { · 6 unmodified lines
2724 workspaceSourceOauthClients: "workspace_source_oauth_clients",
2825 secretMaterials: "secret_materials",
2926 sourceAuthSessions: "source_auth_sessions",
30− policies: "policies",
31− localInstallations: "local_installations",
3227 executions: "executions",
3328 executionInteractions: "execution_interactions",
3429 } as const;
@@ -54,83 +49,6 @@ export const accountsTable = pgTable( · 19 unmodified lines
5449 ],
5550 );
5651
57−export const organizationsTable = pgTable(tableNames.organizations, {
58− id: text("id").notNull().primaryKey(),
59− slug: text("slug").notNull(),
60− name: text("name").notNull(),
61− status: text("status").notNull(),
62− createdByAccountId: text("created_by_account_id"),
63− createdAt: bigint("created_at", { mode: "number" }).notNull(),
64− updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
65−}, (table) => [
66− uniqueIndex("organizations_slug_idx").on(table.slug),
67− index("organizations_updated_idx").on(table.updatedAt, table.id),
68− check(
69− "organizations_status_check",
70− sql`${table.status} in ('active', 'suspended', 'archived')`,
71− ),
72−]);
73−
74−export const organizationMembershipsTable = pgTable(
75− tableNames.organizationMemberships,
76− {
77− id: text("id").notNull().primaryKey(),
78− organizationId: text("organization_id").notNull(),
79− accountId: text("account_id").notNull(),
80− role: text("role").notNull(),
81− status: text("status").notNull(),
82− billable: boolean("billable").notNull(),
83− invitedByAccountId: text("invited_by_account_id"),
84− joinedAt: bigint("joined_at", { mode: "number" }),
85− createdAt: bigint("created_at", { mode: "number" }).notNull(),
86− updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
87− },
88− (table) => [
89− index("organization_memberships_org_updated_idx").on(
90− table.organizationId,
91− table.updatedAt,
92− table.id,
93− ),
94− index("organization_memberships_account_updated_idx").on(
95− table.accountId,
96− table.updatedAt,
97− table.id,
98− ),
99− uniqueIndex("organization_memberships_org_account_idx").on(
100− table.organizationId,
101− table.accountId,
102− ),
103− check(
104− "organization_memberships_role_check",
105− sql`${table.role} in ('viewer', 'editor', 'admin', 'owner')`,
106− ),
107− check(
108− "organization_memberships_status_check",
109− sql`${table.status} in ('invited', 'active', 'suspended', 'removed')`,
110− ),
111− ],
112−);
113−
114−export const workspacesTable = pgTable(
115− tableNames.workspaces,
116− {
117− id: text("id").notNull().primaryKey(),
118− organizationId: text("organization_id").notNull(),
119− name: text("name").notNull(),
120− createdByAccountId: text("created_by_account_id"),
121− createdAt: bigint("created_at", { mode: "number" }).notNull(),
122− updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
123− },
124− (table) => [
125− index("workspaces_org_updated_idx").on(
126− table.organizationId,
127− table.updatedAt,
128− table.id,
129− ),
130− uniqueIndex("workspaces_org_name_idx").on(table.organizationId, table.name),
131− ],
132−);
133−
13452 export const sourcesTable = pgTable(
13553 tableNames.sources,
13654 {
@@ -520,86 +438,6 @@ export const sourceAuthSessionsTable = pgTable( · 383 unmodified lines
520438 ],
521439 );
522440
523−export const policiesTable = pgTable(
524− tableNames.policies,
525− {
526− id: text("id").notNull().primaryKey(),
527− scopeType: text("scope_type").notNull(),
528− organizationId: text("organization_id").notNull(),
529− workspaceId: text("workspace_id"),
530− targetAccountId: text("target_account_id"),
531− clientId: text("client_id"),
532− resourceType: text("resource_type").notNull(),
533− resourcePattern: text("resource_pattern").notNull(),
534− matchType: text("match_type").notNull(),
535− effect: text("effect").notNull(),
536− approvalMode: text("approval_mode").notNull(),
537− argumentConditionsJson: text("argument_conditions_json"),
538− priority: bigint("priority", { mode: "number" }).notNull(),
539− enabled: boolean("enabled").notNull(),
540− createdAt: bigint("created_at", { mode: "number" }).notNull(),
541− updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
542− },
543− (table) => [
544− index("policies_organization_priority_idx").on(
545− table.organizationId,
546− table.priority.desc(),
547− table.updatedAt,
548− table.id,
549− ),
550− index("policies_workspace_priority_idx").on(
551− table.workspaceId,
552− table.priority.desc(),
553− table.updatedAt,
554− table.id,
555− ),
556− check(
557− "policies_scope_type_check",
558− sql`${table.scopeType} in ('organization', 'workspace')`,
559− ),
560− check(
561− "policies_scope_consistency_check",
562− sql`(
563− ${table.scopeType} = 'organization' and ${table.workspaceId} is null
564− ) or (
565− ${table.scopeType} = 'workspace' and ${table.workspaceId} is not null
566− )`,
567− ),
568− check(
569− "policies_resource_type_check",
570− sql`${table.resourceType} in ('all_tools', 'source', 'namespace', 'tool_path')`,
571− ),
572− check(
573− "policies_match_type_check",
574− sql`${table.matchType} in ('glob', 'exact')`,
575− ),
576− check(
577− "policies_effect_check",
578− sql`${table.effect} in ('allow', 'deny')`,
579− ),
580− check(
581− "policies_approval_mode_check",
582− sql`${table.approvalMode} in ('auto', 'required')`,
583− ),
584− ],
585−);
586−
587−export const localInstallationsTable = pgTable(
588− tableNames.localInstallations,
589− {
590− id: text("id").notNull().primaryKey(),
591− accountId: text("account_id").notNull(),
592− organizationId: text("organization_id").notNull(),
593− workspaceId: text("workspace_id").notNull(),
594− createdAt: bigint("created_at", { mode: "number" }).notNull(),
595− updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
596− },
597− (table) => [
598− index("local_installations_organization_idx").on(table.organizationId),
599− index("local_installations_workspace_idx").on(table.workspaceId),
600− ],
601−);
602−
603441 export const executionsTable = pgTable(
604442 tableNames.executions,
605443 {
@@ -653,9 +491,6 @@ export const executionInteractionsTable = pgTable( · 47 unmodified lines
653491
654492 export const drizzleSchema = {
655493 accountsTable,
656− organizationsTable,
657− organizationMembershipsTable,
658− workspacesTable,
659494 sourcesTable,
660495 sourceRecipesTable,
661496 sourceRecipeRevisionsTable,
@@ -668,8 +503,6 @@ export const drizzleSchema = { · 6 unmodified lines
668503 workspaceSourceOauthClientsTable,
669504 secretMaterialsTable,
670505 sourceAuthSessionsTable,
671− policiesTable,
672− localInstallationsTable,
673506 executionsTable,
674507 executionInteractionsTable,
675508 };
packages/control-plane/src/runtime/actor-resolver.ts+0−160
@@ -1,160 +0,0 @@
1−import * as PlatformHeaders from "@effect/platform/Headers";
2−import { ControlPlaneAuthHeaders } from "../auth-headers";
3−import {
4− ControlPlaneActorResolver,
5− type ControlPlaneActorResolverShape,
6− deriveWorkspaceMembershipsForPrincipal,
7−} from "#api";
8−import {
9− ActorUnauthenticatedError,
10− createActor,
11−} from "#domain";
12−import {
13− SqlControlPlaneRowsService,
14− type SqlControlPlaneRows,
15−} from "#persistence";
16−import {
17− PrincipalProviderSchema,
18− PrincipalSchema,
19− type Principal,
20−} from "#schema";
21−import * as Effect from "effect/Effect";
22−import * as Layer from "effect/Layer";
23−import * as Option from "effect/Option";
24−import * as ParseResult from "effect/ParseResult";
25−import * as Schema from "effect/Schema";
26−
27−export { ControlPlaneAuthHeaders };
28−
29−const decodePrincipal = Schema.decodeUnknown(PrincipalSchema);
30−const decodePrincipalProvider = Schema.decodeUnknown(PrincipalProviderSchema);
31−
32−const headerValue = (
33− headers: PlatformHeaders.Headers,
34− name: string,
35−): string | null => {
36− const value = Option.getOrNull(PlatformHeaders.get(headers, name));
37− if (value === null) {
38− return null;
39− }
40−
41− const trimmed = value.trim();
42− return trimmed.length > 0 ? trimmed : null;
43−};
44−
45−const toUnauthenticatedError = (
46− message: string,
47− cause?: unknown,
48−): ActorUnauthenticatedError =>
49− new ActorUnauthenticatedError({
50− message:
51− cause === undefined
52− ? message
53− : `${message}: ${
54− ParseResult.isParseError(cause)
55− ? ParseResult.TreeFormatter.formatErrorSync(cause)
56− : String(cause)
57− }`,
58− });
59−
60−const readPrincipalFromHeaders = (
61− headers: PlatformHeaders.Headers,
62−): Effect.Effect<Principal, ActorUnauthenticatedError> =>
63− Effect.gen(function* () {
64− const accountId = headerValue(headers, ControlPlaneAuthHeaders.accountId);
65− if (accountId === null) {
66− return yield* Effect.fail(
67− new ActorUnauthenticatedError({
68− message: `Missing required header: ${ControlPlaneAuthHeaders.accountId}`,
69− }),
70− );
71− }
72−
73− const providerRaw =
74− headerValue(headers, ControlPlaneAuthHeaders.principalProvider) ?? "local";
75− const provider = yield* decodePrincipalProvider(providerRaw).pipe(
76− Effect.mapError((cause) =>
77− toUnauthenticatedError("Invalid principal provider header", cause),
78− ),
79− );
80−
81− const subject =
82− headerValue(headers, ControlPlaneAuthHeaders.principalSubject)
83− ?? `${provider}:${accountId}`;
84−
85− return yield* decodePrincipal({
86− accountId,
87− provider,
88− subject,
89− email: headerValue(headers, ControlPlaneAuthHeaders.principalEmail),
90− displayName: headerValue(headers, ControlPlaneAuthHeaders.principalDisplayName),
91− }).pipe(
92− Effect.mapError((cause) =>
93− toUnauthenticatedError("Invalid principal headers", cause),
94− ),
95− );
96− });
97−
98−export const createHeaderActorResolver = (
99− rows: SqlControlPlaneRows,
100−): ControlPlaneActorResolverShape => ({
101− resolveActor: ({ headers }) =>
102− Effect.gen(function* () {
103− const principal = yield* readPrincipalFromHeaders(headers);
104− const organizationMemberships = yield* rows.organizationMemberships
105− .listByAccountId(principal.accountId)
106− .pipe(
107− Effect.mapError((cause) =>
108− toUnauthenticatedError("Failed loading memberships", cause),
109− ),
110− );
111−
112− return yield* createActor({
113− principal,
114− workspaceMemberships: [],
115− organizationMemberships,
116− });
117− }),
118−
119− resolveWorkspaceActor: ({ workspaceId, headers }) =>
120− Effect.gen(function* () {
121− const principal = yield* readPrincipalFromHeaders(headers);
122− const organizationMemberships = yield* rows.organizationMemberships
123− .listByAccountId(principal.accountId)
124− .pipe(
125− Effect.mapError((cause) =>
126− toUnauthenticatedError("Failed loading memberships", cause),
127− ),
128− );
129−
130− const workspaceOption = yield* rows.workspaces.getById(workspaceId).pipe(
131− Effect.mapError((cause) =>
132− toUnauthenticatedError("Failed loading workspace context", cause),
133− ),
134− );
135− const workspace = Option.getOrNull(workspaceOption);
136−
137− const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({
138− principalAccountId: principal.accountId,
139− workspaceId,
140− workspace,
141− organizationMemberships,
142− });
143−
144− return yield* createActor({
145− principal,
146− workspaceMemberships,
147− organizationMemberships,
148− });
149− }),
150−});
151−
152−export const RuntimeActorResolverLive = (
153− actorResolver?: ControlPlaneActorResolverShape,
154−) =>
155− actorResolver
156− ? Layer.succeed(ControlPlaneActorResolver, actorResolver)
157− : Layer.effect(
158− ControlPlaneActorResolver,
159− Effect.map(SqlControlPlaneRowsService, (rows) => createHeaderActorResolver(rows)),
160− );
packages/control-plane/src/runtime/control-plane-runtime.test.ts+97−428
@@ -1,4 +1,7 @@
11 import { createServer } from "node:http";
2+import { mkdtempSync, readFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
25
36 import { describe, expect, it } from "@effect/vitest";
47 import { assertTrue } from "@effect/vitest/utils";
@@ -12,33 +15,26 @@ import { · 7 unmodified lines
1215 ExecutionInteractionIdSchema,
1316 SecretMaterialIdSchema,
1417 SourceIdSchema,
15− SourceRecipeIdSchema,
16− SourceRecipeRevisionIdSchema,
1718 } from "#schema";
1819 import type { ToolPath } from "@executor/codemode-core";
1920
2021 import {
21− createSqlControlPlaneRuntime,
22+ createControlPlaneRuntime,
2223 LiveExecutionManagerService,
2324 } from "./index";
25+import { createSourceFromPayload } from "./source-definitions";
2426 import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
27+import { persistSource } from "./source-store";
2528 import { withControlPlaneClient } from "./test-http-client";
2629
2730 const makeRuntime = Effect.acquireRelease(
28− createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),
31+ createControlPlaneRuntime({
32+ localDataDir: ":memory:",
33+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")),
34+ }),
2935 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
3036 );
3137
32−const openApiBindingConfigJson = (specUrl: string): string =>
33− JSON.stringify({
34− adapterKey: "openapi",
35− version: 1,
36− payload: {
37− specUrl,
38− defaultHeaders: null,
39− },
40− });
41−
4238 type OpenApiSpecServer = {
4339 baseUrl: string;
4440 specUrl: string;
@@ -123,155 +119,72 @@ const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => · 78 unmodified lines
123119 );
124120
125121 describe("control-plane-runtime", () => {
126− it.scoped("supports full CRUD flow over HTTP API", () =>
122+ it.scoped("writes local source changes through executor.jsonc", () =>
127123 Effect.gen(function* () {
128− const runtime = yield* makeRuntime;
129− const openApiServer = yield* makeOpenApiSpecServer;
130−
131− const createOrg = yield* withControlPlaneClient(
132− { runtime, accountId: "acc_1" },
133− (client) =>
134− client.organizations.create({
135− payload: {
136− name: "Acme",
137− },
138− }),
139− );
140− const organizationId = createOrg.id;
141−
142− const createWorkspace = yield* withControlPlaneClient(
143− { runtime, accountId: "acc_1" },
144− (client) =>
145− client.workspaces.create({
146− path: { organizationId },
147− payload: { name: "Primary" },
148− }),
124+ const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-local-config-runtime-"));
125+ const runtime = yield* Effect.acquireRelease(
126+ createControlPlaneRuntime({
127+ localDataDir: ":memory:",
128+ workspaceRoot,
129+ }),
130+ (createdRuntime) => Effect.promise(() => createdRuntime.close()).pipe(Effect.orDie),
149131 );
150− expect(createWorkspace.createdByAccountId).toBe("acc_1");
151− const workspaceId = createWorkspace.id;
132+ const openApiServer = yield* makeOpenApiSpecServer;
133+ const installation = runtime.localInstallation;
152134
153− yield* withControlPlaneClient(
154− { runtime, accountId: "acc_1" },
135+ const createdSource = yield* withControlPlaneClient(
136+ { runtime, accountId: installation.accountId },
155137 (client) =>
156138 client.sources.create({
157− path: { workspaceId },
139+ path: { workspaceId: installation.workspaceId },
158140 payload: {
159− name: "Github",
141+ name: "GitHub",
160142 kind: "openapi",
161143 endpoint: openApiServer.baseUrl,
144+ namespace: "github",
162145 binding: {
163146 specUrl: openApiServer.specUrl,
164147 defaultHeaders: null,
165148 },
166− auth: {
167− kind: "none",
168− },
169− },
170− }),
171− );
172−
173− yield* withControlPlaneClient(
174− { runtime, accountId: "acc_1" },
175− (client) =>
176− client.policies.create({
177− path: { workspaceId },
178− payload: {
179− resourceType: "tool_path",
180− resourcePattern: "source.github.*",
181− matchType: "glob",
182− effect: "allow",
183− approvalMode: "auto",
184− priority: 50,
185− enabled: true,
149+ auth: { kind: "none" },
186150 },
187151 }),
188152 );
189153
190− const listSources = yield* withControlPlaneClient(
191− { runtime, accountId: "acc_1" },
192− (client) =>
193− client.sources.list({
194− path: { workspaceId },
195− }),
196− );
197− expect(listSources.length).toBe(1);
198−
199− const listPolicies = yield* withControlPlaneClient(
200− { runtime, accountId: "acc_1" },
201− (client) =>
202− client.policies.list({
203− path: { workspaceId },
204− }),
205− );
206− expect(listPolicies.length).toBe(1);
207− }),
208− );
209−
210− it.scoped("discovers and connects an OpenAPI source through the HTTP API", () =>
211− Effect.gen(function* () {
212− const runtime = yield* makeRuntime;
213− const openApiServer = yield* makeOpenApiSpecServer;
154+ const configPath = join(workspaceRoot, ".executor", "executor.jsonc");
155+ const createdConfig = JSON.parse(readFileSync(configPath, "utf8")) as {
156+ sources?: Record<string, { kind: string; connection: { endpoint: string } }>;
157+ };
158+ expect(createdConfig.sources?.github?.kind).toBe("openapi");
159+ expect(createdConfig.sources?.github?.connection.endpoint).toBe(openApiServer.baseUrl);
214160
215− const organization = yield* withControlPlaneClient(
216− { runtime, accountId: "acc_discover" },
217− (client) =>
218− client.organizations.create({
219− payload: { name: "Discovery Org" },
220− }),
221− );
222− const workspace = yield* withControlPlaneClient(
223− { runtime, accountId: "acc_discover" },
161+ const removed = yield* withControlPlaneClient(
162+ { runtime, accountId: installation.accountId },
224163 (client) =>
225− client.workspaces.create({
226− path: { organizationId: organization.id },
227− payload: { name: "Discovery Workspace" },
228− }),
229− );
230−
231− const discovered = yield* withControlPlaneClient(
232− { runtime, accountId: "acc_discover" },
233− (client) =>
234− client.sources.discover({
235− payload: {
236− url: openApiServer.specUrl,
164+ client.sources.remove({
165+ path: {
166+ workspaceId: installation.workspaceId,
167+ sourceId: createdSource.id,
237168 },
238169 }),
239170 );
240− expect(discovered.detectedKind).toBe("openapi");
241− expect(discovered.specUrl).toBe(openApiServer.specUrl);
242− expect(discovered.endpoint).toBe(openApiServer.baseUrl);
171+ expect(removed.removed).toBe(true);
243172
244− const connected = yield* withControlPlaneClient(
245− { runtime, accountId: "acc_discover" },
246− (client) =>
247− client.sources.connect({
248− path: { workspaceId: workspace.id },
249− payload: {
250− kind: "openapi",
251− endpoint: discovered.endpoint,
252− specUrl: discovered.specUrl ?? openApiServer.specUrl,
253− name: discovered.name,
254− namespace: discovered.namespace,
255− auth: { kind: "none" },
256− },
257− }),
258− );
259− expect(connected.kind).toBe("connected");
260− expect(connected.source.kind).toBe("openapi");
261− expect(connected.source.status).toBe("connected");
262− expect(connected.source.binding).toEqual({
263− specUrl: openApiServer.specUrl,
264− defaultHeaders: null,
265− });
173+ const removedConfig = JSON.parse(readFileSync(configPath, "utf8")) as {
174+ sources?: Record<string, unknown>;
175+ };
176+ expect(removedConfig.sources?.github).toBeUndefined();
266177 }),
178+ 60_000,
267179 );
268180
181+
269182 it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () =>
270183 Effect.gen(function* () {
271184 const runtime = yield* makeRuntime;
272185 const installation = runtime.localInstallation;
273186 const executionId = ExecutionIdSchema.make("exec_local_credential");
274− const sourceId = SourceIdSchema.make("src_local_credential");
187+ const sourceId = SourceIdSchema.make("github");
275188 const interactionSuffix = "executor.sources.add:test";
276189 const interactionId = ExecutionInteractionIdSchema.make(
277190 `${executionId}:${interactionSuffix}`,
@@ -293,24 +206,32 @@ describe("control-plane-runtime", () => { · 15 unmodified lines
293206 updatedAt: now,
294207 });
295208
296− yield* runtime.persistence.rows.sources.insert({
297− id: sourceId,
209+ const localSource = yield* createSourceFromPayload({
298210 workspaceId: installation.workspaceId,
299− recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`),
300− recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`),
301− name: "GitHub",
302− kind: "openapi",
303− endpoint: "https://api.github.com",
304− status: "auth_required",
305− enabled: true,
306− namespace: "github",
307− importAuthPolicy: "reuse_runtime",
308− bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
309− sourceHash: null,
310− lastError: null,
311− createdAt: now,
312− updatedAt: now,
313− });
211+ sourceId,
212+ payload: {
213+ name: "GitHub",
214+ kind: "openapi",
215+ endpoint: "https://api.github.com",
216+ status: "auth_required",
217+ enabled: true,
218+ namespace: "github",
219+ importAuthPolicy: "reuse_runtime",
220+ binding: {
221+ specUrl: "https://example.com/github-openapi.yaml",
222+ defaultHeaders: null,
223+ },
224+ importAuth: { kind: "none" },
225+ auth: { kind: "none" },
226+ },
227+ now,
228+ }).pipe(Effect.orDie);
229+ yield* persistSource(runtime.persistence.rows, localSource, {
230+ actorAccountId: installation.accountId,
231+ }).pipe(
232+ Effect.provide(runtime.runtimeLayer),
233+ Effect.orDie,
234+ );
314235
315236 const interactionFiber = yield* Effect.gen(function* () {
316237 const liveExecutionManager = yield* LiveExecutionManagerService;
@@ -426,7 +347,7 @@ describe("control-plane-runtime", () => { · 109 unmodified lines
426347 const runtime = yield* makeRuntime;
427348 const installation = runtime.localInstallation;
428349 const executionId = ExecutionIdSchema.make("exec_local_credential_continue");
429− const sourceId = SourceIdSchema.make("src_local_credential_continue");
350+ const sourceId = SourceIdSchema.make("github");
430351 const interactionSuffix = "executor.sources.add:continue";
431352 const interactionId = ExecutionInteractionIdSchema.make(
432353 `${executionId}:${interactionSuffix}`,
@@ -448,24 +369,32 @@ describe("control-plane-runtime", () => { · 15 unmodified lines
448369 updatedAt: now,
449370 });
450371
451− yield* runtime.persistence.rows.sources.insert({
452− id: sourceId,
372+ const localSource = yield* createSourceFromPayload({
453373 workspaceId: installation.workspaceId,
454− recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`),
455− recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`),
456− name: "GitHub",
457− kind: "openapi",
458− endpoint: "https://api.github.com",
459− status: "auth_required",
460− enabled: true,
461− namespace: "github",
462− importAuthPolicy: "reuse_runtime",
463− bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
464− sourceHash: null,
465− lastError: null,
466− createdAt: now,
467− updatedAt: now,
468− });
374+ sourceId,
375+ payload: {
376+ name: "GitHub",
377+ kind: "openapi",
378+ endpoint: "https://api.github.com",
379+ status: "auth_required",
380+ enabled: true,
381+ namespace: "github",
382+ importAuthPolicy: "reuse_runtime",
383+ binding: {
384+ specUrl: "https://example.com/github-openapi.yaml",
385+ defaultHeaders: null,
386+ },
387+ importAuth: { kind: "none" },
388+ auth: { kind: "none" },
389+ },
390+ now,
391+ }).pipe(Effect.orDie);
392+ yield* persistSource(runtime.persistence.rows, localSource, {
393+ actorAccountId: installation.accountId,
394+ }).pipe(
395+ Effect.provide(runtime.runtimeLayer),
396+ Effect.orDie,
397+ );
469398
470399 const interactionFiber = yield* Effect.gen(function* () {
471400 const liveExecutionManager = yield* LiveExecutionManagerService;
@@ -538,264 +467,4 @@ describe("control-plane-runtime", () => { · 66 unmodified lines
538467 }),
539468 );
540469
541− it.scoped("scopes organization list/get to memberships", () =>
542− Effect.gen(function* () {
543− const runtime = yield* makeRuntime;
544−
545− const orgOne = yield* withControlPlaneClient(
546− { runtime, accountId: "acc_1" },
547− (client) =>
548− client.organizations.create({
549− payload: { name: "Org One" },
550− }),
551− );
552−
553− const orgTwo = yield* withControlPlaneClient(
554− { runtime, accountId: "acc_2" },
555− (client) =>
556− client.organizations.create({
557− payload: { name: "Org Two" },
558− }),
559− );
560−
561− const listForAcc1 = yield* withControlPlaneClient(
562− { runtime, accountId: "acc_1" },
563− (client) => client.organizations.list({}),
564− );
565−
566− expect(listForAcc1.length).toBe(1);
567−
568− const getOtherOrgError = yield* expectLeft(
569− withControlPlaneClient(
570− { runtime, accountId: "acc_1" },
571− (client) =>
572− client.organizations.get({
573− path: { organizationId: orgTwo.id },
574− }),
575− ),
576− );
577−
578− assertTrue(orgOne.id.length > 0);
579− expect(getOtherOrgError._tag).toBe("ControlPlaneNotFoundError");
580− }),
581− );
582−
583− it.scoped("prevents viewers from workspace manage actions", () =>
584− Effect.gen(function* () {
585− const runtime = yield* makeRuntime;
586−
587− const organization = yield* withControlPlaneClient(
588− { runtime, accountId: "owner_acc" },
589− (client) =>
590− client.organizations.create({
591− payload: { name: "Secured Org" },
592− }),
593− );
594−
595− const workspace = yield* withControlPlaneClient(
596− { runtime, accountId: "owner_acc" },
597− (client) =>
598− client.workspaces.create({
599− path: { organizationId: organization.id },
600− payload: { name: "Secured WS" },
601− }),
602− );
603−
604− yield* withControlPlaneClient(
605− { runtime, accountId: "owner_acc" },
606− (client) =>
607− client.memberships.create({
608− path: { organizationId: organization.id },
609− payload: {
610− accountId: "viewer_acc" as AccountId,
611− role: "viewer",
612− status: "active",
613− },
614− }),
615− );
616−
617− const viewerDeleteError = yield* expectLeft(
618− withControlPlaneClient(
619− { runtime, accountId: "viewer_acc" },
620− (client) =>
621− client.workspaces.remove({
622− path: { workspaceId: workspace.id },
623− }),
624− ),
625− );
626−
627− expect(viewerDeleteError._tag).toBe("ControlPlaneForbiddenError");
628− }),
629− );
630−
631− it.scoped("blocks organization manage across tenants", () =>
632− Effect.gen(function* () {
633− const runtime = yield* makeRuntime;
634−
635− const orgA = yield* withControlPlaneClient(
636− { runtime, accountId: "acc_a" },
637− (client) =>
638− client.organizations.create({
639− payload: { name: "Org A" },
640− }),
641− );
642−
643− const orgB = yield* withControlPlaneClient(
644− { runtime, accountId: "acc_b" },
645− (client) =>
646− client.organizations.create({
647− payload: { name: "Org B" },
648− }),
649− );
650−
651− const patchOtherOrgError = yield* expectLeft(
652− withControlPlaneClient(
653− { runtime, accountId: "acc_a" },
654− (client) =>
655− client.organizations.update({
656− path: { organizationId: orgB.id },
657− payload: { name: "Renamed" },
658− }),
659− ),
660− );
661− assertTrue(orgA.id.length > 0);
662− expect(patchOtherOrgError._tag).toBe("ControlPlaneForbiddenError");
663−
664− const deleteOtherOrgError = yield* expectLeft(
665− withControlPlaneClient(
666− { runtime, accountId: "acc_a" },
667− (client) =>
668− client.organizations.remove({
669− path: { organizationId: orgB.id },
670− }),
671− ),
672− );
673− expect(deleteOtherOrgError._tag).toBe("ControlPlaneForbiddenError");
674− }),
675− );
676−
677− it.scoped("suspended creators cannot manage previously created workspaces", () =>
678− Effect.gen(function* () {
679− const runtime = yield* makeRuntime;
680−
681− const organization = yield* withControlPlaneClient(
682− { runtime, accountId: "creator_acc" },
683− (client) =>
684− client.organizations.create({
685− payload: { name: "Creator Org" },
686− }),
687− );
688−
689− const workspace = yield* withControlPlaneClient(
690− { runtime, accountId: "creator_acc" },
691− (client) =>
692− client.workspaces.create({
693− path: { organizationId: organization.id },
694− payload: { name: "Creator WS" },
695− }),
696− );
697−
698− yield* withControlPlaneClient(
699− { runtime, accountId: "creator_acc" },
700− (client) =>
701− client.memberships.update({
702− path: {
703− organizationId: organization.id,
704− accountId: "creator_acc" as AccountId,
705− },
706− payload: { status: "suspended" },
707− }),
708− );
709−
710− const deleteWorkspaceError = yield* expectLeft(
711− withControlPlaneClient(
712− { runtime, accountId: "creator_acc" },
713− (client) =>
714− client.workspaces.remove({
715− path: { workspaceId: workspace.id },
716− }),
717− ),
718− );
719−
720− expect(deleteWorkspaceError._tag).toBe("ControlPlaneForbiddenError");
721− }),
722− );
723−
724− it.scoped("deleting organization cascades and blocks stale org operations", () =>
725− Effect.gen(function* () {
726− const runtime = yield* makeRuntime;
727−
728− const organization = yield* withControlPlaneClient(
729− { runtime, accountId: "acc_del" },
730− (client) =>
731− client.organizations.create({
732− payload: { name: "Delete Me" },
733− }),
734− );
735−
736− const workspace = yield* withControlPlaneClient(
737− { runtime, accountId: "acc_del" },
738− (client) =>
739− client.workspaces.create({
740− path: { organizationId: organization.id },
741− payload: { name: "Delete WS" },
742− }),
743− );
744−
745− const deleteOrg = yield* withControlPlaneClient(
746− { runtime, accountId: "acc_del" },
747− (client) =>
748− client.organizations.remove({
749− path: { organizationId: organization.id },
750− }),
751− );
752− assertTrue(deleteOrg.removed);
753−
754− const listStaleWorkspacesError = yield* expectLeft(
755− withControlPlaneClient(
756− { runtime, accountId: "acc_del" },
757− (client) =>
758− client.workspaces.list({
759− path: { organizationId: organization.id },
760− }),
761− ),
762− );
763− expect(listStaleWorkspacesError._tag).toBe("ControlPlaneForbiddenError");
764−
765− const createStaleWorkspaceError = yield* expectLeft(
766− withControlPlaneClient(
767− { runtime, accountId: "acc_del" },
768− (client) =>
769− client.workspaces.create({
770− path: { organizationId: organization.id },
771− payload: { name: "Should Fail" },
772− }),
773− ),
774− );
775− expect(createStaleWorkspaceError._tag).toBe("ControlPlaneForbiddenError");
776−
777− const getDeletedWorkspaceError = yield* expectLeft(
778− withControlPlaneClient(
779− { runtime, accountId: "acc_del" },
780− (client) =>
781− client.workspaces.get({
782− path: { workspaceId: workspace.id },
783− }),
784− ),
785− );
786− expect(getDeletedWorkspaceError._tag).toBe("ControlPlaneForbiddenError");
787− }),
788− );
789−
790− it.scoped("rejects unauthenticated calls", () =>
791− Effect.gen(function* () {
792− const runtime = yield* makeRuntime;
793−
794− const error = yield* expectLeft(
795− withControlPlaneClient({ runtime }, (client) => client.organizations.list({})),
796− );
797−
798− expect(error._tag).toBe("ControlPlaneUnauthorizedError");
799− }),
800− );
801470 });
packages/control-plane/src/runtime/execution-http.test.ts+7−2
@@ -1,3 +1,6 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
14 import { describe, expect, it } from "@effect/vitest";
25 import * as Effect from "effect/Effect";
36 import * as Schema from "effect/Schema";
@@ -6,7 +9,7 @@ import { makeToolInvokerFromTools } from "@executor/codemode-core"; · 2 unmodified lines
69 import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
710
811 import {
9− createSqlControlPlaneRuntime,
12+ createControlPlaneRuntime,
1013 } from "./index";
1114 import { withControlPlaneClient } from "./test-http-client";
1215
@@ -37,8 +40,9 @@ const makeExecutionResolver = () => { · 24 unmodified lines
3740 };
3841
3942 const makeRuntime = Effect.acquireRelease(
40− createSqlControlPlaneRuntime({
43+ createControlPlaneRuntime({
4144 localDataDir: ":memory:",
45+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")),
4246 executionResolver: makeExecutionResolver(),
4347 }),
4448 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
@@ -88,5 +92,6 @@ describe("execution-http", () => { · 43 unmodified lines
8892 expect(getExecution.execution.status).toBe("completed");
8993 expect(getExecution.pendingInteraction).toBeNull();
9094 }),
95+ 60_000,
9196 );
9297 });
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+7−2
@@ -5,6 +5,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines
55 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
66 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
77 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8+import { mkdtempSync } from "node:fs";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
811 import { describe, expect, it } from "@effect/vitest";
912 import { assertTrue } from "@effect/vitest/utils";
1013 import * as Effect from "effect/Effect";
@@ -15,7 +18,7 @@ import { discoverMcpToolsFromConnector } from "@executor/codemode-mcp"; · 4 unmodified lines
1518 import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
1619
1720 import {
18− createSqlControlPlaneRuntime,
21+ createControlPlaneRuntime,
1922 type ResolveExecutionEnvironment,
2023 } from "./index";
2124 import { withControlPlaneClient } from "./test-http-client";
@@ -281,8 +284,9 @@ describe("execution-mcp-resume", () => { · 259 unmodified lines
281284 Effect.gen(function* () {
282285 const mcpServer = yield* makeFormElicitationServer;
283286 const runtime = yield* Effect.acquireRelease(
284− createSqlControlPlaneRuntime({
287+ createControlPlaneRuntime({
285288 localDataDir: ":memory:",
289+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")),
286290 executionResolver: makeMcpExecutionResolver(mcpServer.endpoint),
287291 }),
288292 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
@@ -354,5 +358,6 @@ describe("execution-mcp-resume", () => { · 65 unmodified lines
354358 }),
355359 );
356360 }),
361+ 60_000,
357362 );
358363 });
packages/control-plane/src/runtime/execution-service.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
22 ControlPlaneBadRequestError,
33 ControlPlaneNotFoundError,
44 ControlPlaneStorageError,
5−} from "#api";
5+} from "../api/errors";
66 import type {
77 CreateExecutionPayload,
88 ResumeExecutionPayload,
packages/control-plane/src/runtime/executor-tools.ts+29−5
@@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines
1919 import * as Cause from "effect/Cause";
2020 import * as Exit from "effect/Exit";
2121 import * as Schema from "effect/Schema";
22+import type { RuntimeLocalWorkspaceState } from "./local-runtime-context";
23+import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
2224
2325 /** Run an Effect as a Promise, preserving the original error (not FiberFailure). */
24−const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => {
25− const exit = await Effect.runPromiseExit(effect);
26+const runEffect = async <A>(
27+ effect: Effect.Effect<A, unknown, never>,
28+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null = null,
29+): Promise<A> => {
30+ const provided =
31+ runtimeLocalWorkspace === null
32+ ? effect
33+ : effect.pipe(
34+ Effect.provideService(
35+ RuntimeLocalWorkspaceService,
36+ runtimeLocalWorkspace,
37+ ),
38+ );
39+ const exit = await Effect.runPromiseExit(provided);
2640 if (Exit.isSuccess(exit)) return exit.value;
2741 throw Cause.squash(exit.cause);
2842 };
@@ -106,6 +120,9 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 77 unmodified lines
106120 return trimmed.length > 0 ? trimmed : null;
107121 };
108122
123+const toSerializableValue = <A>(value: A): A =>
124+ JSON.parse(JSON.stringify(value)) as A;
125+
109126 const resolveLocalCredentialUrl = (input: {
110127 baseUrl: string;
111128 workspaceId: WorkspaceId;
@@ -205,6 +222,7 @@ export const createExecutorToolMap = (input: { · 93 unmodified lines
205222 workspaceId: WorkspaceId;
206223 accountId: AccountId;
207224 sourceAuthService: RuntimeSourceAuthService;
225+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
208226 }): ToolMap => ({
209227 "executor.sources.add": toTool({
210228 tool: {
@@ -281,10 +299,11 @@ export const createExecutorToolMap = (input: { · 70 unmodified lines
281299 }
282300 : undefined,
283301 ),
302+ input.runtimeLocalWorkspace,
284303 );
285304
286305 if (result.kind === "connected") {
287− return result.source;
306+ return toSerializableValue(result.source);
288307 }
289308
290309 if (result.kind === "credential_required") {
@@ -313,6 +332,7 @@ export const createExecutorToolMap = (input: { · 22 unmodified lines
313332 invocation: context?.invocation,
314333 onElicitation: context?.onElicitation,
315334 }),
335+ input.runtimeLocalWorkspace,
316336 );
317337
318338 pendingArgs = pendingResult.credentialSlot === "import"
@@ -342,10 +362,11 @@ export const createExecutorToolMap = (input: { · 23 unmodified lines
342362 }
343363 : undefined,
344364 ),
365+ input.runtimeLocalWorkspace,
345366 );
346367
347368 if (completed.kind === "connected") {
348− return completed.source;
369+ return toSerializableValue(completed.source);
349370 }
350371
351372 if (completed.kind === "credential_required") {
@@ -385,19 +406,22 @@ export const createExecutorToolMap = (input: { · 33 unmodified lines
385406 elicitationId: result.sessionId,
386407 },
387408 }),
409+ input.runtimeLocalWorkspace,
388410 );
389411
390412 if (response.action !== "accept") {
391413 throw new Error(`Source add was not completed for ${result.source.id}`);
392414 }
393415
394− return await runEffect(
416+ const connected = await runEffect(
395417 input.sourceAuthService.getSourceById({
396418 workspaceId: input.workspaceId,
397419 sourceId: result.source.id,
398420 actorAccountId: input.accountId,
399421 }),
422+ input.runtimeLocalWorkspace,
400423 );
424+ return toSerializableValue(connected);
401425 },
402426 },
403427 metadata: {
packages/control-plane/src/runtime/index.ts+117−34
@@ -6,13 +6,12 @@ import * as Scope from "effect/Scope"; · 5 unmodified lines
66
77 import {
88 type ControlPlaneApiRuntimeContext,
9− ControlPlaneActorResolver,
10− type ControlPlaneActorResolverShape,
119 } from "#api";
1210 import {
1311 SqlControlPlanePersistenceLive,
1412 SqlControlPlanePersistenceService,
1513 SqlControlPlaneRowsLive,
14+ SqlControlPlaneRowsService,
1615 SqlPersistenceBootstrapError,
1716 type CreateSqlRuntimeOptions,
1817 type SqlControlPlanePersistence,
@@ -20,43 +19,48 @@ import { · 1 unmodified line
2019 import type { LocalInstallation } from "#schema";
2120
2221 import {
23− ControlPlaneAuthHeaders,
24− RuntimeActorResolverLive,
25− createHeaderActorResolver,
26−} from "./actor-resolver";
27−import { type ResolveExecutionEnvironment } from "./execution-state";
22+ type ResolveExecutionEnvironment,
23+} from "./execution-state";
2824 import {
25+ createLiveExecutionManager,
2926 LiveExecutionManagerLive,
3027 LiveExecutionManagerService,
3128 } from "./live-execution";
3229 import { getOrProvisionLocalInstallation } from "./local-installation";
30+import {
31+ loadLocalExecutorConfig,
32+ resolveLocalWorkspaceContext,
33+} from "./local-config";
34+import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
35+import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";
3336 import {
3437 ControlPlaneStore,
3538 ControlPlaneStoreLive,
3639 } from "./store";
3740 import {
41+ createRuntimeSourceAuthService,
3842 RuntimeSourceAuthServiceLive,
3943 RuntimeSourceAuthServiceTag,
4044 } from "./source-auth-service";
4145 import type { ResolveSecretMaterial } from "./secret-material-providers";
46+import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
4247 import {
48+ createWorkspaceExecutionEnvironmentResolver,
4349 RuntimeExecutionResolverLive,
4450 RuntimeExecutionResolverService,
4551 } from "./workspace-execution-environment";
4652
47−export {
48− ControlPlaneAuthHeaders,
49− createHeaderActorResolver,
50−};
51−
5253 export * from "./execution-state";
5354 export * from "./executor-tools";
5455 export * from "./live-execution";
56+export * from "./local-config";
5557 export * from "./local-installation";
58+export * from "./local-source-artifacts";
5659 export * from "./schema-type-signature";
5760 export * from "./source-auth-service";
5861 export * from "./secret-material-providers";
5962 export * from "./source-credential-interactions";
63+export * from "./source-adapters/mcp";
6064 export * from "./store";
6165 export * from "./workspace-execution-environment";
6266 export * from "./source-inspection";
@@ -64,21 +68,21 @@ export * from "./source-discovery"; · 1 unmodified line
6468 export * from "./execution-service";
6569
6670 export type RuntimeControlPlaneOptions = {
67− actorResolver?: ControlPlaneActorResolverShape;
6871 executionResolver?: ResolveExecutionEnvironment;
6972 resolveSecretMaterial?: ResolveSecretMaterial;
7073 getLocalServerBaseUrl?: () => string | undefined;
74+ workspaceRoot?: string;
7175 };
7276
7377 const detailsFromCause = (cause: unknown): string =>
7478 cause instanceof Error ? cause.message : String(cause);
7579
76−const toLocalInstallationBootstrapError = (
80+const toLocalRuntimeBootstrapError = (
7781 cause: unknown,
7882 ): SqlPersistenceBootstrapError => {
7983 const details = detailsFromCause(cause);
8084 return new SqlPersistenceBootstrapError({
81− message: `Failed provisioning local installation: ${details}`,
85+ message: `Failed initializing local runtime: ${details}`,
8286 details,
8387 });
8488 };
@@ -93,18 +97,18 @@ export type RuntimeControlPlaneLayer = Layer.Layer< · 8 unmodified lines
9397 >;
9498
9599 const runtimeContextTags = [
96− ControlPlaneActorResolver,
97100 ControlPlaneStore,
98101 LiveExecutionManagerService,
102+ RuntimeLocalWorkspaceService,
99103 RuntimeSourceAuthServiceTag,
100104 RuntimeExecutionResolverService,
101105 ] as const;
102106
103107 const createRuntimeLayerFromContext = (
104108 context: Context.Context<
105− ControlPlaneActorResolver
106− | ControlPlaneStore
109+ ControlPlaneStore
107110 | LiveExecutionManagerService
111+ | RuntimeLocalWorkspaceService
108112 | RuntimeSourceAuthServiceTag
109113 | RuntimeExecutionResolverService
110114 >,
@@ -112,9 +116,9 @@ const createRuntimeLayerFromContext = ( · 1 unmodified line
112116 const runtimeContext = context.pipe(
113117 Context.pick(...runtimeContextTags),
114118 ) as Context.Context<
115− ControlPlaneActorResolver
116− | ControlPlaneStore
119+ ControlPlaneStore
117120 | LiveExecutionManagerService
121+ | RuntimeLocalWorkspaceService
118122 | RuntimeSourceAuthServiceTag
119123 | RuntimeExecutionResolverService
120124 >;
@@ -140,54 +144,133 @@ export const createRuntimeControlPlaneLayer = ( · 19 unmodified lines
140144
141145 return Layer.mergeAll(
142146 ControlPlaneStoreLive,
143− RuntimeActorResolverLive(options.actorResolver),
144147 liveExecutionManagerLayer,
145148 sourceAuthLayer,
146149 executionResolverLayer,
147150 );
148151 };
149152
150−export type SqlControlPlaneRuntime = {
153+export type ControlPlaneRuntime = {
151154 persistence: SqlControlPlanePersistence;
152155 localInstallation: LocalInstallation;
153156 runtimeLayer: RuntimeControlPlaneLayer;
154157 close: () => Promise<void>;
155158 };
156159
157−export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions
160+export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions
158161 & RuntimeControlPlaneOptions;
159162
160−export const createSqlControlPlaneRuntime = (
161− options: CreateSqlControlPlaneRuntimeOptions,
162−): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> =>
163+export const createControlPlaneRuntime = (
164+ options: CreateControlPlaneRuntimeOptions,
165+): Effect.Effect<ControlPlaneRuntime, SqlPersistenceBootstrapError> =>
163166 Effect.gen(function* () {
164167 const scope = yield* Scope.make();
165168 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(
166169 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),
167170 );
168− const runtimeLayer = createRuntimeControlPlaneLayer(options).pipe(
169− Layer.provideMerge(persistenceAndRowsLayer),
171+ const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe(
172+ Effect.catchAll((error) =>
173+ closeScope(scope).pipe(
174+ Effect.zipRight(Effect.fail(error)),
175+ )),
170176 );
171177
172− const context = yield* Layer.buildWithScope(runtimeLayer, scope).pipe(
178+ const persistence = Context.get(baseContext, SqlControlPlanePersistenceService);
179+ const rows = Context.get(baseContext, SqlControlPlaneRowsService);
180+
181+ const localWorkspaceContext = yield* resolveLocalWorkspaceContext({
182+ workspaceRoot: options.workspaceRoot,
183+ }).pipe(
184+ Effect.mapError(toLocalRuntimeBootstrapError),
173185 Effect.catchAll((error) =>
174186 closeScope(scope).pipe(
175187 Effect.zipRight(Effect.fail(error)),
176188 )),
177189 );
178190
179− const persistence = Context.get(context, SqlControlPlanePersistenceService);
180− const store = Context.get(context, ControlPlaneStore);
181− const concreteRuntimeLayer = createRuntimeLayerFromContext(context);
191+ const localInstallation = yield* getOrProvisionLocalInstallation({
192+ context: localWorkspaceContext,
193+ }).pipe(
194+ Effect.mapError(toLocalRuntimeBootstrapError),
195+ Effect.catchAll((error) =>
196+ closeScope(scope).pipe(
197+ Effect.zipRight(Effect.fail(error)),
198+ )),
199+ );
182200
183− const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe(
184− Effect.mapError(toLocalInstallationBootstrapError),
201+ const loadedLocalConfig = yield* loadLocalExecutorConfig(localWorkspaceContext).pipe(
202+ Effect.mapError(toLocalRuntimeBootstrapError),
185203 Effect.catchAll((error) =>
186204 closeScope(scope).pipe(
187205 Effect.zipRight(Effect.fail(error)),
188206 )),
189207 );
190208
209+ const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({
210+ context: localWorkspaceContext,
211+ loadedConfig: loadedLocalConfig,
212+ }).pipe(
213+ Effect.mapError(toLocalRuntimeBootstrapError),
214+ Effect.catchAll((error) =>
215+ closeScope(scope).pipe(
216+ Effect.zipRight(Effect.fail(error)),
217+ )),
218+ );
219+
220+ const resolveSecretMaterial =
221+ options.resolveSecretMaterial
222+ ?? createDefaultSecretMaterialResolver({
223+ rows,
224+ localConfig: effectiveLocalConfig,
225+ workspaceRoot: localWorkspaceContext.workspaceRoot,
226+ });
227+
228+ const liveExecutionManager = createLiveExecutionManager();
229+ const sourceAuthService = createRuntimeSourceAuthService({
230+ rows,
231+ liveExecutionManager,
232+ getLocalServerBaseUrl: options.getLocalServerBaseUrl,
233+ localConfig: effectiveLocalConfig,
234+ workspaceRoot: localWorkspaceContext.workspaceRoot,
235+ localWorkspaceState: {
236+ context: localWorkspaceContext,
237+ installation: {
238+ workspaceId: localInstallation.workspaceId,
239+ accountId: localInstallation.accountId,
240+ },
241+ loadedConfig: {
242+ ...loadedLocalConfig,
243+ config: effectiveLocalConfig,
244+ },
245+ },
246+ });
247+ const executionResolver =
248+ options.executionResolver
249+ ?? createWorkspaceExecutionEnvironmentResolver({
250+ rows,
251+ sourceAuthService,
252+ resolveSecretMaterial,
253+ });
254+
255+ const runtimeContext = Context.empty().pipe(
256+ Context.add(ControlPlaneStore, rows),
257+ Context.add(LiveExecutionManagerService, liveExecutionManager),
258+ Context.add(RuntimeLocalWorkspaceService, {
259+ context: localWorkspaceContext,
260+ installation: {
261+ workspaceId: localInstallation.workspaceId,
262+ accountId: localInstallation.accountId,
263+ },
264+ loadedConfig: {
265+ ...loadedLocalConfig,
266+ config: effectiveLocalConfig,
267+ },
268+ }),
269+ Context.add(RuntimeSourceAuthServiceTag, sourceAuthService),
270+ Context.add(RuntimeExecutionResolverService, executionResolver),
271+ );
272+ const concreteRuntimeLayer = createRuntimeLayerFromContext(runtimeContext);
273+
191274 return {
192275 persistence,
193276 localInstallation,
packages/control-plane/src/runtime/invocation-policy-engine.test.ts+25−58
@@ -1,9 +1,7 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import type { Policy } from "#schema";
3+import type { LocalWorkspacePolicy } from "#schema";
44 import {
5− AccountIdSchema,
6− OrganizationIdSchema,
75 PolicyIdSchema,
86 SourceIdSchema,
97 WorkspaceIdSchema,
@@ -15,7 +13,6 @@ import { · 5 unmodified lines
1513 } from "./invocation-policy-engine";
1614
1715 const workspaceId = WorkspaceIdSchema.make("ws_policy_engine");
18−const organizationId = OrganizationIdSchema.make("org_policy_engine");
1916 const sourceId = SourceIdSchema.make("src_policy_engine");
2017
2118 const now = 1_700_000_000_000;
@@ -32,20 +29,14 @@ const baseDescriptor: InvocationDescriptor = { · 10 unmodified lines
3229 };
3330
3431 const basePolicy = (
35− patch: Partial<Policy> = {},
36−): Policy => ({
32+ patch: Partial<LocalWorkspacePolicy> = {},
33+): LocalWorkspacePolicy => ({
3734 id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`),
38− scopeType: "workspace",
39− organizationId,
35+ key: "vercel-dns",
4036 workspaceId,
41− targetAccountId: null,
42− clientId: null,
43− resourceType: "tool_path",
4437 resourcePattern: "vercel.api.dns.createRecord",
45− matchType: "exact",
4638 effect: "allow",
4739 approvalMode: "auto",
48− argumentConditionsJson: null,
4940 priority: 0,
5041 enabled: true,
5142 createdAt: now,
@@ -54,7 +45,7 @@ const basePolicy = ( · 2 unmodified lines
5445 });
5546
5647 describe("invocation-policy-engine", () => {
57− it("allows GET requests by default", () => {
48+ it("allows read requests by default", () => {
5849 const decision = evaluateInvocationPolicy({
5950 descriptor: {
6051 ...baseDescriptor,
@@ -67,107 +58,83 @@ describe("invocation-policy-engine", () => { · 6 unmodified lines
6758 policies: [],
6859 context: {
6960 workspaceId,
70− organizationId,
71− accountId: null,
72− clientId: null,
7361 },
7462 });
7563
7664 expect(decision.kind).toBe("allow");
7765 });
7866
79− it("requires interaction for mutating OpenAPI requests by default", () => {
67+ it("requires interaction for mutating requests by default", () => {
8068 const decision = evaluateInvocationPolicy({
8169 descriptor: baseDescriptor,
8270 args: { domain: "testing.executor.sh" },
8371 policies: [],
8472 context: {
8573 workspaceId,
86− organizationId,
87− accountId: null,
88− clientId: null,
8974 },
9075 });
9176
9277 expect(decision.kind).toBe("require_interaction");
9378 });
9479
95− it("lets an organization policy allow a mutating request", () => {
80+ it("allows a mutating request when a matching policy exists", () => {
9681 const decision = evaluateInvocationPolicy({
9782 descriptor: baseDescriptor,
9883 args: { domain: "testing.executor.sh" },
9984 policies: [basePolicy({
100− id: PolicyIdSchema.make("pol_org_allow"),
101− scopeType: "organization",
102− workspaceId: null,
85+ id: PolicyIdSchema.make("pol_allow"),
10386 })],
10487 context: {
10588 workspaceId,
106− organizationId,
107− accountId: null,
108− clientId: null,
10989 },
11090 });
11191
11292 expect(decision.kind).toBe("allow");
113− expect(decision.matchedPolicyId).toBe("pol_org_allow");
93+ expect(decision.matchedPolicyId).toBe("pol_allow");
11494 });
11595
116− it("prefers a more specific workspace deny over an organization allow", () => {
96+ it("prefers a more specific deny over a broad allow", () => {
11797 const decision = evaluateInvocationPolicy({
11898 descriptor: baseDescriptor,
11999 args: { domain: "testing.executor.sh" },
120100 policies: [
121101 basePolicy({
122− id: PolicyIdSchema.make("pol_org_allow"),
123− scopeType: "organization",
124− workspaceId: null,
125− approvalMode: "auto",
102+ id: PolicyIdSchema.make("pol_allow_all_vercel"),
103+ key: "allow-all-vercel",
104+ resourcePattern: "vercel.api.*",
126105 effect: "allow",
106+ priority: 0,
127107 }),
128108 basePolicy({
129− id: PolicyIdSchema.make("pol_ws_deny"),
109+ id: PolicyIdSchema.make("pol_deny_create_record"),
110+ key: "deny-create-record",
130111 effect: "deny",
131− priority: 10,
112+ priority: 1,
132113 }),
133114 ],
134115 context: {
135116 workspaceId,
136− organizationId,
137− accountId: null,
138− clientId: null,
139117 },
140118 });
141119
142120 expect(decision.kind).toBe("deny");
143− expect(decision.matchedPolicyId).toBe("pol_ws_deny");
121+ expect(decision.matchedPolicyId).toBe("pol_deny_create_record");
144122 });
145123
146− it("prefers an account-targeted workspace allow over a generic workspace policy", () => {
124+ it("ignores disabled policies", () => {
147125 const decision = evaluateInvocationPolicy({
148126 descriptor: baseDescriptor,
149127 args: { domain: "testing.executor.sh" },
150− policies: [
151− basePolicy({
152− id: PolicyIdSchema.make("pol_ws_gate"),
153− approvalMode: "required",
154− }),
155− basePolicy({
156− id: PolicyIdSchema.make("pol_ws_user_allow"),
157− targetAccountId: AccountIdSchema.make("acc_user"),
158− approvalMode: "auto",
159− priority: 1,
160− }),
161− ],
128+ policies: [basePolicy({
129+ id: PolicyIdSchema.make("pol_disabled"),
130+ enabled: false,
131+ })],
162132 context: {
163133 workspaceId,
164− organizationId,
165− accountId: AccountIdSchema.make("acc_user"),
166− clientId: null,
167134 },
168135 });
169136
170− expect(decision.kind).toBe("allow");
171− expect(decision.matchedPolicyId).toBe("pol_ws_user_allow");
137+ expect(decision.kind).toBe("require_interaction");
138+ expect(decision.matchedPolicyId).toBeNull();
172139 });
173140 });
packages/control-plane/src/runtime/invocation-policy-engine.ts+18−195
@@ -1,6 +1,5 @@
11 import type {
2− Policy,
3− PolicyArgumentCondition,
2+ LocalWorkspacePolicy,
43 Source,
54 } from "#schema";
65
@@ -23,201 +22,24 @@ export type InvocationDescriptor = { · 16 unmodified lines
2322 };
2423
2524 export type InvocationPolicyContext = {
26− workspaceId: Exclude<Policy["workspaceId"], null>;
27− organizationId: Policy["organizationId"];
28− accountId?: Policy["targetAccountId"];
29− clientId?: Policy["clientId"];
25+ workspaceId: LocalWorkspacePolicy["workspaceId"];
3026 };
3127
3228 export type InvocationAuthorizationDecision = {
3329 kind: "allow" | "deny" | "require_interaction";
3430 reason: string;
35− matchedPolicyId: Policy["id"] | null;
31+ matchedPolicyId: LocalWorkspacePolicy["id"] | null;
3632 };
3733
38−const namespaceFromToolPath = (toolPath: string): string | null => {
39− const parts = toolPath.split(".").filter(Boolean);
40− if (parts.length <= 1) {
41− return null;
42− }
43−
44− return parts.slice(0, -1).join(".");
45−};
46−
47−const matchesPattern = (
48− pattern: string,
49− value: string,
50− matchType: Policy["matchType"],
51−): boolean => {
52− if (matchType === "exact") {
53− return pattern === value;
54− }
55−
34+const matchesGlob = (pattern: string, value: string): boolean => {
5635 const escaped = pattern
5736 .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
5837 .replace(/\*/g, ".*");
5938 return new RegExp(`^${escaped}$`).test(value);
6039 };
6140
62−const parseArgumentConditions = (
63− argumentConditionsJson: Policy["argumentConditionsJson"],
64−): ReadonlyArray<PolicyArgumentCondition> | null => {
65− if (argumentConditionsJson === null) {
66− return [];
67− }
68−
69− try {
70− const parsed = JSON.parse(argumentConditionsJson) as unknown;
71− if (!Array.isArray(parsed)) {
72− return null;
73− }
74−
75− return parsed.flatMap((item) => {
76− if (!item || typeof item !== "object" || Array.isArray(item)) {
77− return [];
78− }
79−
80− const candidate = item as Record<string, unknown>;
81− return typeof candidate.key === "string"
82− && typeof candidate.operator === "string"
83− && typeof candidate.value === "string"
84− ? [{
85− key: candidate.key,
86− operator: candidate.operator as PolicyArgumentCondition["operator"],
87− value: candidate.value,
88− } satisfies PolicyArgumentCondition]
89− : [];
90− });
91− } catch {
92− return null;
93− }
94−};
95−
96−const asString = (value: unknown): string => value == null ? "" : String(value);
97−
98−const matchesArgumentCondition = (
99− condition: PolicyArgumentCondition,
100− args: Record<string, unknown>,
101−): boolean => {
102− const value = asString(args[condition.key]);
103− switch (condition.operator) {
104− case "equals":
105− return value === condition.value;
106− case "not_equals":
107− return value !== condition.value;
108− case "contains":
109− return value.includes(condition.value);
110− case "starts_with":
111− return value.startsWith(condition.value);
112− default:
113− return false;
114− }
115−};
116−
117−const matchesArgumentConditions = (
118− policy: Policy,
119− args: unknown,
120−): boolean => {
121− const conditions = parseArgumentConditions(policy.argumentConditionsJson);
122− if (conditions === null) {
123− return false;
124− }
125− if (conditions.length === 0) {
126− return true;
127− }
128− if (!args || typeof args !== "object" || Array.isArray(args)) {
129− return false;
130− }
131−
132− const record = args as Record<string, unknown>;
133− return conditions.every((condition) => matchesArgumentCondition(condition, record));
134−};
135−
136−const matchesScope = (
137− policy: Policy,
138− context: InvocationPolicyContext,
139−): boolean => {
140− if (policy.organizationId !== context.organizationId) {
141− return false;
142− }
143− if (policy.scopeType === "workspace" && policy.workspaceId !== context.workspaceId) {
144− return false;
145− }
146− if (policy.scopeType === "organization" && policy.workspaceId !== null) {
147− return false;
148− }
149− if (policy.targetAccountId && policy.targetAccountId !== context.accountId) {
150− return false;
151− }
152− if (policy.clientId && policy.clientId !== context.clientId) {
153− return false;
154− }
155−
156− return true;
157−};
158−
159−const matchesResource = (
160− policy: Policy,
161− descriptor: InvocationDescriptor,
162−): boolean => {
163− if (policy.resourceType === "all_tools") {
164− return true;
165− }
166−
167− if (policy.resourceType === "source") {
168− return [descriptor.sourceId, `source:${descriptor.sourceId}`].some((candidate) =>
169− matchesPattern(policy.resourcePattern, candidate, policy.matchType));
170− }
171−
172− if (policy.resourceType === "namespace") {
173− const namespace = descriptor.sourceNamespace ?? namespaceFromToolPath(descriptor.toolPath);
174− return namespace === null
175− ? false
176− : matchesPattern(policy.resourcePattern, namespace, policy.matchType);
177− }
178−
179− return matchesPattern(policy.resourcePattern, descriptor.toolPath, policy.matchType);
180−};
181−
182−const policySpecificity = (
183− policy: Policy,
184− context: InvocationPolicyContext,
185−): number => {
186− let score = 0;
187−
188− score += policy.scopeType === "workspace" ? 64 : 32;
189− if (policy.targetAccountId && policy.targetAccountId === context.accountId) {
190− score += 128;
191− }
192− if (policy.clientId && policy.clientId === context.clientId) {
193− score += 16;
194− }
195−
196− switch (policy.resourceType) {
197− case "tool_path":
198− score += 24;
199− break;
200− case "namespace":
201− score += 18;
202− break;
203− case "source":
204− score += 12;
205− break;
206− default:
207− break;
208− }
209−
210− if (policy.matchType === "exact") {
211− score += 4;
212− }
213− if (policy.argumentConditionsJson !== null) {
214− score += 32;
215− }
216−
217− score += policy.priority;
218− score += Math.max(1, policy.resourcePattern.replace(/\*/g, "").length);
219− return score;
220−};
41+const policySpecificity = (policy: LocalWorkspacePolicy): number =>
42+ policy.priority + Math.max(1, policy.resourcePattern.replace(/\*/g, "").length);
22143
22244 const defaultDecisionForInvocation = (
22345 descriptor: InvocationDescriptor,
@@ -238,7 +60,7 @@ const defaultDecisionForInvocation = ( · 14 unmodified lines
23860 };
23961
24062 const resolvePolicyDecision = (
241− policy: Policy,
63+ policy: LocalWorkspacePolicy,
24264 ): InvocationAuthorizationDecision => {
24365 if (policy.effect === "deny") {
24466 return {
@@ -266,18 +88,19 @@ const resolvePolicyDecision = ( · 21 unmodified lines
26688 export const evaluateInvocationPolicy = (input: {
26789 descriptor: InvocationDescriptor;
26890 args: unknown;
269− policies: ReadonlyArray<Policy>;
91+ policies: ReadonlyArray<LocalWorkspacePolicy>;
27092 context: InvocationPolicyContext;
27193 }): InvocationAuthorizationDecision => {
272− const candidates = input.policies
273− .filter((policy) => policy.enabled)
274− .filter((policy) => matchesScope(policy, input.context))
275− .filter((policy) => matchesResource(policy, input.descriptor))
276− .filter((policy) => matchesArgumentConditions(policy, input.args))
94+ const matchingPolicies = input.policies
95+ .filter((policy) =>
96+ policy.enabled
97+ && policy.workspaceId === input.context.workspaceId
98+ && matchesGlob(policy.resourcePattern, input.descriptor.toolPath))
27799 .sort((left, right) =>
278− policySpecificity(right, input.context) - policySpecificity(left, input.context));
100+ policySpecificity(right) - policySpecificity(left)
101+ || left.updatedAt - right.updatedAt,
102+ );
279103
280− return candidates[0]
281− ? resolvePolicyDecision(candidates[0])
282− : defaultDecisionForInvocation(input.descriptor);
104+ const matched = matchingPolicies[0];
105+ return matched ? resolvePolicyDecision(matched) : defaultDecisionForInvocation(input.descriptor);
283106 };
packages/control-plane/src/runtime/local-config-secrets.ts+9−0
@@ -0,0 +1,9 @@
1+export const CONFIG_SECRET_PROVIDER_PREFIX = "config:";
2+
3+export const toConfigSecretProviderId = (providerAlias: string): string =>
4+ `${CONFIG_SECRET_PROVIDER_PREFIX}${providerAlias}`;
5+
6+export const fromConfigSecretProviderId = (providerId: string): string | null =>
7+ providerId.startsWith(CONFIG_SECRET_PROVIDER_PREFIX)
8+ ? providerId.slice(CONFIG_SECRET_PROVIDER_PREFIX.length)
9+ : null;
packages/control-plane/src/runtime/local-config.test.ts+128−0
@@ -0,0 +1,128 @@
1+import { mkdtempSync } from "node:fs";
2+import { mkdir, writeFile } from "node:fs/promises";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import { describe, expect, it } from "@effect/vitest";
6+import * as Effect from "effect/Effect";
7+
8+import {
9+ loadLocalExecutorConfig,
10+ resolveDefaultHomeConfigCandidates,
11+ resolveHomeConfigPath,
12+ resolveLocalWorkspaceContext,
13+} from "./local-config";
14+
15+const makeWorkspaceRoot = () =>
16+ mkdtempSync(join(tmpdir(), "executor-local-config-"));
17+
18+describe("local-config", () => {
19+ it.effect("parses jsonc project config with comments and trailing commas", () =>
20+ Effect.gen(function* () {
21+ const workspaceRoot = makeWorkspaceRoot();
22+ const configDirectory = join(workspaceRoot, ".executor");
23+ yield* Effect.promise(() => mkdir(configDirectory, { recursive: true }));
24+ yield* Effect.promise(() =>
25+ writeFile(
26+ join(configDirectory, "executor.jsonc"),
27+ `{
28+ // local workspace config
29+ "sources": {
30+ "github": {
31+ "kind": "openapi",
32+ "connection": {
33+ "endpoint": "https://api.github.com",
34+ },
35+ "binding": {
36+ "specUrl": "https://example.com/openapi.json",
37+ },
38+ },
39+ },
40+}
41+`,
42+ "utf8",
43+ ),
44+ );
45+
46+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
47+ const loaded = yield* loadLocalExecutorConfig(context);
48+
49+ expect(loaded.config?.sources?.github?.kind).toBe("openapi");
50+ expect(loaded.config?.sources?.github?.connection.endpoint).toBe(
51+ "https://api.github.com",
52+ );
53+ }),
54+ );
55+
56+ it.effect("reports jsonc syntax errors with line and column details", () =>
57+ Effect.gen(function* () {
58+ const workspaceRoot = makeWorkspaceRoot();
59+ const configDirectory = join(workspaceRoot, ".executor");
60+ yield* Effect.promise(() => mkdir(configDirectory, { recursive: true }));
61+ yield* Effect.promise(() =>
62+ writeFile(
63+ join(configDirectory, "executor.jsonc"),
64+ `{
65+ "sources": {
66+ "github": {
67+ "kind": "openapi"
68+ "connection": {
69+ "endpoint": "https://api.github.com"
70+ }
71+ }
72+ }
73+}
74+`,
75+ "utf8",
76+ ),
77+ );
78+
79+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
80+ const failure = yield* Effect.flip(loadLocalExecutorConfig(context));
81+
82+ expect(failure.message).toContain("Invalid executor config");
83+ expect(failure.message).toContain("line 5, column 7");
84+ }),
85+ );
86+
87+ it("uses platform-standard home config candidates", () => {
88+ const linuxCandidates = resolveDefaultHomeConfigCandidates({
89+ platform: "linux",
90+ homeDirectory: "/home/alice",
91+ env: {},
92+ });
93+ const macCandidates = resolveDefaultHomeConfigCandidates({
94+ platform: "darwin",
95+ homeDirectory: "/Users/alice",
96+ env: {},
97+ });
98+
99+ expect(linuxCandidates[0]).toBe("/home/alice/.config/executor/executor.jsonc");
100+ expect(macCandidates[0]).toBe(
101+ "/Users/alice/Library/Application Support/Executor/executor.jsonc",
102+ );
103+ expect(macCandidates[2]).toBe("/Users/alice/.config/executor/executor.jsonc");
104+ });
105+
106+ it.effect("prefers an existing legacy home config path before the canonical path", () =>
107+ Effect.gen(function* () {
108+ const homeDirectory = makeWorkspaceRoot();
109+ const legacyConfigDirectory = join(homeDirectory, ".config", "executor");
110+ yield* Effect.promise(() => mkdir(legacyConfigDirectory, { recursive: true }));
111+ yield* Effect.promise(() =>
112+ writeFile(
113+ join(legacyConfigDirectory, "executor.jsonc"),
114+ "{\n \"sources\": {}\n}\n",
115+ "utf8",
116+ ),
117+ );
118+
119+ const resolvedPath = yield* resolveHomeConfigPath({
120+ platform: "darwin",
121+ homeDirectory,
122+ env: {},
123+ });
124+
125+ expect(resolvedPath).toBe(join(legacyConfigDirectory, "executor.jsonc"));
126+ }),
127+ );
128+});
packages/control-plane/src/runtime/local-config.ts+450−0
@@ -0,0 +1,450 @@
1+import { homedir } from "node:os";
2+import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3+import { FileSystem } from "@effect/platform";
4+import { NodeFileSystem } from "@effect/platform-node";
5+import {
6+ type ParseError as JsoncParseError,
7+ parse as parseJsoncDocument,
8+ printParseErrorCode,
9+} from "jsonc-parser";
10+
11+import {
12+ LocalExecutorConfigSchema,
13+ type LocalExecutorConfig,
14+ type LocalConfigPolicy,
15+ type LocalConfigSecretProvider,
16+ type LocalConfigSource,
17+} from "#schema";
18+import * as Effect from "effect/Effect";
19+import * as Schema from "effect/Schema";
20+import {
21+ LocalExecutorConfigDecodeError,
22+ LocalFileSystemError,
23+ unknownLocalErrorDetails,
24+} from "./local-errors";
25+
26+const decodeLocalExecutorConfig = Schema.decodeUnknownSync(LocalExecutorConfigSchema);
27+
28+const PROJECT_CONFIG_BASENAME = "executor.jsonc";
29+const PROJECT_CONFIG_FALLBACK_BASENAME = "executor.json";
30+const PROJECT_CONFIG_DIRECTORY = ".executor";
31+const EXECUTOR_CONFIG_DIR_ENV = "EXECUTOR_CONFIG_DIR";
32+
33+const provideNodeFileSystem = <A, E, R>(
34+ effect: Effect.Effect<A, E, R | FileSystem.FileSystem>,
35+): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> =>
36+ effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect<
37+ A,
38+ E,
39+ Exclude<R, FileSystem.FileSystem>
40+ >;
41+
42+const mapFileSystemError = (path: string, action: string) => (cause: unknown) =>
43+ new LocalFileSystemError({
44+ message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`,
45+ action,
46+ path,
47+ details: unknownLocalErrorDetails(cause),
48+ });
49+
50+const trimOrUndefined = (value: string | undefined | null): string | undefined => {
51+ const trimmed = value?.trim();
52+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
53+};
54+
55+const defaultExecutorConfigDirectory = (input: {
56+ env?: NodeJS.ProcessEnv;
57+ platform?: NodeJS.Platform;
58+ homeDirectory?: string;
59+} = {}): string => {
60+ const env = input.env ?? process.env;
61+ const platform = input.platform ?? process.platform;
62+ const homeDirectory = input.homeDirectory ?? homedir();
63+ const explicitConfigDirectory = trimOrUndefined(env[EXECUTOR_CONFIG_DIR_ENV]);
64+
65+ if (explicitConfigDirectory) {
66+ return explicitConfigDirectory;
67+ }
68+
69+ if (platform === "win32") {
70+ return join(
71+ trimOrUndefined(env.LOCALAPPDATA) ?? join(homeDirectory, "AppData", "Local"),
72+ "Executor",
73+ );
74+ }
75+
76+ if (platform === "darwin") {
77+ return join(homeDirectory, "Library", "Application Support", "Executor");
78+ }
79+
80+ return join(
81+ trimOrUndefined(env.XDG_CONFIG_HOME) ?? join(homeDirectory, ".config"),
82+ "executor",
83+ );
84+};
85+
86+const legacyExecutorConfigDirectory = (input: {
87+ homeDirectory?: string;
88+} = {}): string =>
89+ join(input.homeDirectory ?? homedir(), ".config", "executor");
90+
91+export const resolveDefaultHomeConfigCandidates = (input: {
92+ env?: NodeJS.ProcessEnv;
93+ platform?: NodeJS.Platform;
94+ homeDirectory?: string;
95+} = {}): string[] => {
96+ const homeDirectory = input.homeDirectory ?? homedir();
97+ const canonicalDirectory = defaultExecutorConfigDirectory({
98+ env: input.env,
99+ platform: input.platform,
100+ homeDirectory,
101+ });
102+ const legacyDirectory = legacyExecutorConfigDirectory({ homeDirectory });
103+ const directories = canonicalDirectory === legacyDirectory
104+ ? [canonicalDirectory]
105+ : [canonicalDirectory, legacyDirectory];
106+
107+ return directories.flatMap((directory) => [
108+ join(directory, PROJECT_CONFIG_BASENAME),
109+ join(directory, PROJECT_CONFIG_FALLBACK_BASENAME),
110+ ]);
111+};
112+
113+export const resolveHomeConfigPath = (input: {
114+ env?: NodeJS.ProcessEnv;
115+ platform?: NodeJS.Platform;
116+ homeDirectory?: string;
117+} = {}): Effect.Effect<string, LocalFileSystemError> =>
118+ provideNodeFileSystem(Effect.gen(function* () {
119+ const fs = yield* FileSystem.FileSystem;
120+ const candidates = resolveDefaultHomeConfigCandidates(input);
121+
122+ for (const candidate of candidates) {
123+ const exists = yield* fs.exists(candidate).pipe(
124+ Effect.mapError(mapFileSystemError(candidate, "check config path")),
125+ );
126+ if (exists) {
127+ return candidate;
128+ }
129+ }
130+
131+ return candidates[0]!;
132+ }));
133+
134+const formatJsoncParseErrors = (content: string, errors: readonly JsoncParseError[]): string => {
135+ const lines = content.split("\n");
136+
137+ return errors
138+ .map((error) => {
139+ const beforeOffset = content.slice(0, error.offset).split("\n");
140+ const line = beforeOffset.length;
141+ const column = beforeOffset[beforeOffset.length - 1]?.length ?? 0;
142+ const lineText = lines[line - 1];
143+ const location = `line ${line}, column ${column + 1}`;
144+ const detail = printParseErrorCode(error.error);
145+
146+ if (!lineText) {
147+ return `${detail} at ${location}`;
148+ }
149+
150+ return `${detail} at ${location}\n${lineText}`;
151+ })
152+ .join("\n");
153+};
154+
155+const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => {
156+ const errors: JsoncParseError[] = [];
157+
158+ try {
159+ const parsed = parseJsoncDocument(input.content, errors, {
160+ allowTrailingComma: true,
161+ });
162+ if (errors.length > 0) {
163+ throw new LocalExecutorConfigDecodeError({
164+ message: `Invalid executor config at ${input.path}: ${formatJsoncParseErrors(input.content, errors)}`,
165+ path: input.path,
166+ details: formatJsoncParseErrors(input.content, errors),
167+ });
168+ }
169+
170+ return decodeLocalExecutorConfig(parsed);
171+ } catch (cause) {
172+ if (cause instanceof LocalExecutorConfigDecodeError) {
173+ throw cause;
174+ }
175+ throw new LocalExecutorConfigDecodeError({
176+ message: `Invalid executor config at ${input.path}: ${unknownLocalErrorDetails(cause)}`,
177+ path: input.path,
178+ details: unknownLocalErrorDetails(cause),
179+ });
180+ }
181+};
182+
183+const mergeSourceMaps = (
184+ base: Record<string, LocalConfigSource> | undefined,
185+ extra: Record<string, LocalConfigSource> | undefined,
186+): Record<string, LocalConfigSource> | undefined => {
187+ if (!base && !extra) {
188+ return undefined;
189+ }
190+ return {
191+ ...(base ?? {}),
192+ ...(extra ?? {}),
193+ };
194+};
195+
196+const mergePolicyMaps = (
197+ base: Record<string, LocalConfigPolicy> | undefined,
198+ extra: Record<string, LocalConfigPolicy> | undefined,
199+): Record<string, LocalConfigPolicy> | undefined => {
200+ if (!base && !extra) {
201+ return undefined;
202+ }
203+ return {
204+ ...(base ?? {}),
205+ ...(extra ?? {}),
206+ };
207+};
208+
209+const mergeSecretProviderMaps = (
210+ base: Record<string, LocalConfigSecretProvider> | undefined,
211+ extra: Record<string, LocalConfigSecretProvider> | undefined,
212+): Record<string, LocalConfigSecretProvider> | undefined => {
213+ if (!base && !extra) {
214+ return undefined;
215+ }
216+ return {
217+ ...(base ?? {}),
218+ ...(extra ?? {}),
219+ };
220+};
221+
222+export const mergeLocalExecutorConfigs = (
223+ base: LocalExecutorConfig | null,
224+ extra: LocalExecutorConfig | null,
225+): LocalExecutorConfig | null => {
226+ if (!base && !extra) {
227+ return null;
228+ }
229+
230+ return decodeLocalExecutorConfig({
231+ workspace: {
232+ ...(base?.workspace ?? {}),
233+ ...(extra?.workspace ?? {}),
234+ },
235+ sources: mergeSourceMaps(base?.sources, extra?.sources),
236+ policies: mergePolicyMaps(base?.policies, extra?.policies),
237+ secrets: {
238+ providers: mergeSecretProviderMaps(
239+ base?.secrets?.providers,
240+ extra?.secrets?.providers,
241+ ),
242+ defaults: {
243+ ...(base?.secrets?.defaults ?? {}),
244+ ...(extra?.secrets?.defaults ?? {}),
245+ },
246+ },
247+ });
248+};
249+
250+const resolveProjectConfigPathEffect = (workspaceRoot: string) =>
251+ Effect.gen(function* () {
252+ const fs = yield* FileSystem.FileSystem;
253+ const jsoncPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME);
254+ const jsoncExists = yield* fs.exists(jsoncPath).pipe(
255+ Effect.mapError(mapFileSystemError(jsoncPath, "check project config path")),
256+ );
257+ if (jsoncExists) {
258+ return jsoncPath;
259+ }
260+
261+ const jsonPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_FALLBACK_BASENAME);
262+ const jsonExists = yield* fs.exists(jsonPath).pipe(
263+ Effect.mapError(mapFileSystemError(jsonPath, "check project config path")),
264+ );
265+ return jsonExists ? jsonPath : jsoncPath;
266+ });
267+
268+const hasProjectConfigEffect = (workspaceRoot: string) =>
269+ Effect.gen(function* () {
270+ const fs = yield* FileSystem.FileSystem;
271+ const jsoncPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME);
272+ const jsonPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_FALLBACK_BASENAME);
273+ const [jsoncExists, jsonExists] = yield* Effect.all([
274+ fs.exists(jsoncPath).pipe(
275+ Effect.mapError(mapFileSystemError(jsoncPath, "check project config path")),
276+ ),
277+ fs.exists(jsonPath).pipe(
278+ Effect.mapError(mapFileSystemError(jsonPath, "check project config path")),
279+ ),
280+ ]);
281+ return jsoncExists || jsonExists;
282+ });
283+
284+const resolveWorkspaceRootFromCwdEffect = (cwd: string) =>
285+ Effect.gen(function* () {
286+ const fs = yield* FileSystem.FileSystem;
287+ let current = resolve(cwd);
288+ let nearestProjectConfigRoot: string | null = null;
289+ let nearestGitRoot: string | null = null;
290+
291+ while (true) {
292+ if (nearestProjectConfigRoot === null) {
293+ const hasProjectConfig = yield* hasProjectConfigEffect(current);
294+ if (hasProjectConfig) {
295+ nearestProjectConfigRoot = current;
296+ }
297+ }
298+
299+ if (nearestGitRoot === null) {
300+ const gitPath = join(current, ".git");
301+ const gitExists = yield* fs.exists(gitPath).pipe(
302+ Effect.mapError(mapFileSystemError(gitPath, "check git root")),
303+ );
304+ if (gitExists) {
305+ nearestGitRoot = current;
306+ }
307+ }
308+
309+ const parent = dirname(current);
310+ if (parent === current) {
311+ break;
312+ }
313+ current = parent;
314+ }
315+
316+ return nearestProjectConfigRoot ?? nearestGitRoot ?? resolve(cwd);
317+ });
318+
319+export type ResolvedLocalWorkspaceContext = {
320+ cwd: string;
321+ workspaceRoot: string;
322+ workspaceName: string;
323+ configDirectory: string;
324+ projectConfigPath: string;
325+ homeConfigPath: string;
326+ artifactsDirectory: string;
327+ stateDirectory: string;
328+};
329+
330+export type LoadedLocalExecutorConfig = {
331+ config: LocalExecutorConfig | null;
332+ homeConfig: LocalExecutorConfig | null;
333+ projectConfig: LocalExecutorConfig | null;
334+ homeConfigPath: string;
335+ projectConfigPath: string;
336+};
337+
338+export const resolveLocalWorkspaceContext = (input: {
339+ cwd?: string;
340+ workspaceRoot?: string;
341+} = {}): Effect.Effect<ResolvedLocalWorkspaceContext, LocalFileSystemError> =>
342+ provideNodeFileSystem(Effect.gen(function* () {
343+ const cwd = resolve(input.cwd ?? process.cwd());
344+ const workspaceRoot = resolve(
345+ input.workspaceRoot ?? (yield* resolveWorkspaceRootFromCwdEffect(cwd)),
346+ );
347+ const workspaceName = basename(workspaceRoot) || "workspace";
348+ const projectConfigPath = yield* resolveProjectConfigPathEffect(workspaceRoot);
349+ const homeConfigPath = yield* resolveHomeConfigPath();
350+
351+ return {
352+ cwd,
353+ workspaceRoot,
354+ workspaceName,
355+ configDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY),
356+ projectConfigPath,
357+ homeConfigPath,
358+ artifactsDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, "artifacts"),
359+ stateDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, "state"),
360+ };
361+ }));
362+
363+export const readOptionalLocalExecutorConfig = (
364+ path: string,
365+): Effect.Effect<LocalExecutorConfig | null, LocalFileSystemError | LocalExecutorConfigDecodeError> =>
366+ provideNodeFileSystem(Effect.gen(function* () {
367+ const fs = yield* FileSystem.FileSystem;
368+ const exists = yield* fs.exists(path).pipe(
369+ Effect.mapError(mapFileSystemError(path, "check config path")),
370+ );
371+ if (!exists) {
372+ return null;
373+ }
374+
375+ const content = yield* fs.readFileString(path, "utf8").pipe(
376+ Effect.mapError(mapFileSystemError(path, "read config")),
377+ );
378+ return yield* Effect.try({
379+ try: () => parseJsonc({ path, content }),
380+ catch: (cause) =>
381+ cause instanceof LocalExecutorConfigDecodeError
382+ ? cause
383+ : new LocalExecutorConfigDecodeError({
384+ message: `Invalid executor config at ${path}: ${unknownLocalErrorDetails(cause)}`,
385+ path,
386+ details: unknownLocalErrorDetails(cause),
387+ }),
388+ });
389+ }));
390+
391+export const loadLocalExecutorConfig = (
392+ context: ResolvedLocalWorkspaceContext,
393+): Effect.Effect<
394+ LoadedLocalExecutorConfig,
395+ LocalFileSystemError | LocalExecutorConfigDecodeError
396+> =>
397+ Effect.gen(function* () {
398+ const [homeConfig, projectConfig] = yield* Effect.all([
399+ readOptionalLocalExecutorConfig(context.homeConfigPath),
400+ readOptionalLocalExecutorConfig(context.projectConfigPath),
401+ ]);
402+
403+ return {
404+ config: mergeLocalExecutorConfigs(homeConfig, projectConfig),
405+ homeConfig,
406+ projectConfig,
407+ homeConfigPath: context.homeConfigPath,
408+ projectConfigPath: context.projectConfigPath,
409+ };
410+ });
411+
412+export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>
413+ `${JSON.stringify(config, null, 2)}\n`;
414+
415+export const writeProjectLocalExecutorConfig = (input: {
416+ context: ResolvedLocalWorkspaceContext;
417+ config: LocalExecutorConfig;
418+}): Effect.Effect<void, LocalFileSystemError> =>
419+ provideNodeFileSystem(Effect.gen(function* () {
420+ const fs = yield* FileSystem.FileSystem;
421+ yield* fs.makeDirectory(input.context.configDirectory, { recursive: true }).pipe(
422+ Effect.mapError(mapFileSystemError(input.context.configDirectory, "create config directory")),
423+ );
424+ yield* fs.writeFileString(
425+ input.context.projectConfigPath,
426+ encodeLocalExecutorConfig(input.config),
427+ ).pipe(
428+ Effect.mapError(mapFileSystemError(input.context.projectConfigPath, "write config")),
429+ );
430+ }));
431+
432+export const resolveConfigRelativePath = (input: {
433+ path: string;
434+ workspaceRoot: string;
435+}): string => {
436+ const trimmed = input.path.trim();
437+ if (trimmed.startsWith("~/")) {
438+ return join(homedir(), trimmed.slice(2));
439+ }
440+ if (trimmed === "~") {
441+ return homedir();
442+ }
443+ if (isAbsolute(trimmed)) {
444+ return trimmed;
445+ }
446+ return resolve(input.workspaceRoot, trimmed);
447+};
448+
449+export const defaultWorkspaceDisplayName = (context: ResolvedLocalWorkspaceContext): string =>
450+ trimOrUndefined(context.workspaceName) ?? "workspace";
packages/control-plane/src/runtime/local-errors.ts+54−0
@@ -0,0 +1,54 @@
1+import * as Data from "effect/Data";
2+
3+export const unknownLocalErrorDetails = (cause: unknown): string =>
4+ cause instanceof Error ? cause.message : String(cause);
5+
6+export class LocalFileSystemError extends Data.TaggedError("LocalFileSystemError")<{
7+ readonly message: string;
8+ readonly action: string;
9+ readonly path: string;
10+ readonly details: string;
11+}> {}
12+
13+export class LocalExecutorConfigDecodeError extends Data.TaggedError("LocalExecutorConfigDecodeError")<{
14+ readonly message: string;
15+ readonly path: string;
16+ readonly details: string;
17+}> {}
18+
19+export class LocalWorkspaceStateDecodeError extends Data.TaggedError("LocalWorkspaceStateDecodeError")<{
20+ readonly message: string;
21+ readonly path: string;
22+ readonly details: string;
23+}> {}
24+
25+export class LocalSourceArtifactDecodeError extends Data.TaggedError("LocalSourceArtifactDecodeError")<{
26+ readonly message: string;
27+ readonly path: string;
28+ readonly details: string;
29+}> {}
30+
31+export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError("RuntimeLocalWorkspaceUnavailableError")<{
32+ readonly message: string;
33+}> {}
34+
35+export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError("RuntimeLocalWorkspaceMismatchError")<{
36+ readonly message: string;
37+ readonly requestedWorkspaceId: string;
38+ readonly activeWorkspaceId: string;
39+}> {}
40+
41+export class LocalConfiguredSourceNotFoundError extends Data.TaggedError("LocalConfiguredSourceNotFoundError")<{
42+ readonly message: string;
43+ readonly sourceId: string;
44+}> {}
45+
46+export class LocalSourceArtifactMissingError extends Data.TaggedError("LocalSourceArtifactMissingError")<{
47+ readonly message: string;
48+ readonly sourceId: string;
49+}> {}
50+
51+export class LocalUnsupportedSourceKindError extends Data.TaggedError("LocalUnsupportedSourceKindError")<{
52+ readonly message: string;
53+ readonly kind: string;
54+}> {}
packages/control-plane/src/runtime/local-installation.test.ts+22−18
@@ -1,45 +1,49 @@
1+import { existsSync, mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
14 import { describe, expect, it } from "@effect/vitest";
2−import { assertTrue } from "@effect/vitest/utils";
35 import * as Effect from "effect/Effect";
46
5−import { createSqlControlPlaneRuntime } from "./index";
7+import { createControlPlaneRuntime } from "./index";
68 import { getOrProvisionLocalInstallation } from "./local-installation";
9+import { resolveLocalWorkspaceContext } from "./local-config";
10+
11+const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));
712
813 const makeRuntime = Effect.acquireRelease(
9− createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),
14+ createControlPlaneRuntime({
15+ localDataDir: ":memory:",
16+ workspaceRoot: TEST_WORKSPACE_ROOT,
17+ }),
1018 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
1119 );
1220
1321 describe("local-installation", () => {
14− it.scoped("provisions a local account, organization, and workspace on first boot", () =>
22+ it.scoped("derives a stable local identity on first boot", () =>
1523 Effect.gen(function* () {
1624 const runtime = yield* makeRuntime;
1725 const installation = runtime.localInstallation;
1826
19− const account = yield* runtime.persistence.rows.accounts.getById(installation.accountId);
20− const organization = yield* runtime.persistence.rows.organizations.getById(
21− installation.organizationId,
22− );
23− const workspace = yield* runtime.persistence.rows.workspaces.getById(
24− installation.workspaceId,
25− );
26−
27− assertTrue(account._tag === "Some");
28− assertTrue(organization._tag === "Some");
29− assertTrue(workspace._tag === "Some");
27+ expect(installation.accountId).toBe("acc_local_default");
28+ expect(installation.workspaceId.startsWith("ws_local_")).toBe(true);
29+ expect(existsSync(join(TEST_WORKSPACE_ROOT, ".executor", "executor.jsonc"))).toBe(false);
3030 }),
31+ 60_000,
3132 );
3233
3334 it.scoped("is idempotent when loading the default local installation", () =>
3435 Effect.gen(function* () {
3536 const runtime = yield* makeRuntime;
37+ const context = yield* resolveLocalWorkspaceContext({
38+ workspaceRoot: TEST_WORKSPACE_ROOT,
39+ });
3640
3741 const first = runtime.localInstallation;
38− const second = yield* getOrProvisionLocalInstallation(runtime.persistence.rows);
42+ const second = yield* getOrProvisionLocalInstallation({
43+ context,
44+ });
3945
40− expect(second.id).toBe(first.id);
4146 expect(second.accountId).toBe(first.accountId);
42− expect(second.organizationId).toBe(first.organizationId);
4347 expect(second.workspaceId).toBe(first.workspaceId);
4448 }),
4549 );
packages/control-plane/src/runtime/local-installation.ts+25−104
@@ -1,119 +1,40 @@
1−import { type SqlControlPlaneRows } from "#persistence";
1+import { createHash } from "node:crypto";
2+
23 import {
34 AccountIdSchema,
4− InstallationIdSchema,
5− OrganizationIdSchema,
6− OrganizationMemberIdSchema,
75 WorkspaceIdSchema,
8− type Account,
96 type LocalInstallation,
10− type Organization,
11− type OrganizationMembership,
12− type Workspace,
137 } from "#schema";
148 import * as Effect from "effect/Effect";
15−import * as Option from "effect/Option";
169
17−const LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");
10+import type { ResolvedLocalWorkspaceContext } from "./local-config";
1811
19−const buildAccount = (now: number): Account => {
20− const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`);
12+const LOCAL_ACCOUNT_ID = AccountIdSchema.make("acc_local_default");
2113
22− return {
23− id,
24− provider: "local",
25− subject: `local:${id}`,
26− email: null,
27− displayName: "Local User",
28− createdAt: now,
29− updatedAt: now,
30− };
31−};
14+const stableHash = (value: string): string =>
15+ createHash("sha256").update(value).digest("hex").slice(0, 16);
3216
33−const buildOrganization = (
34− accountId: Account["id"],
35− now: number,
36−): Organization => ({
37− id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
38− slug: "personal",
39− name: "Personal",
40− status: "active",
41− createdByAccountId: accountId,
42− createdAt: now,
43− updatedAt: now,
44−});
17+const normalizeSlashPath = (value: string): string =>
18+ value.replaceAll("\\", "/");
4519
46−const buildOwnerMembership = (
47− organizationId: Organization["id"],
48− accountId: Account["id"],
49− now: number,
50−): OrganizationMembership => ({
51− id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
52− organizationId,
53− accountId,
54− role: "owner",
55− status: "active",
56− billable: true,
57− invitedByAccountId: null,
58− joinedAt: now,
59− createdAt: now,
60− updatedAt: now,
61−});
20+const deriveWorkspaceId = (context: ResolvedLocalWorkspaceContext) =>
21+ WorkspaceIdSchema.make(
22+ `ws_local_${stableHash(normalizeSlashPath(context.workspaceRoot))}`,
23+ );
6224
63−const buildWorkspace = (
64− organizationId: Organization["id"],
65− accountId: Account["id"],
66− now: number,
67−): Workspace => ({
68− id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
69− organizationId,
70− name: "Default",
71− createdByAccountId: accountId,
72− createdAt: now,
73− updatedAt: now,
25+export const deriveLocalInstallation = (
26+ context: ResolvedLocalWorkspaceContext,
27+): LocalInstallation => ({
28+ accountId: LOCAL_ACCOUNT_ID,
29+ workspaceId: deriveWorkspaceId(context),
7430 });
7531
7632 export const loadLocalInstallation = (
77− rows: SqlControlPlaneRows,
78−): Effect.Effect<LocalInstallation | null, unknown> =>
79− rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe(
80− Effect.map((result) => (Option.isSome(result) ? result.value : null)),
81− );
82−
83−export const provisionLocalInstallation = (
84− rows: SqlControlPlaneRows,
85−): Effect.Effect<LocalInstallation, unknown> =>
86− Effect.gen(function* () {
87− const existing = yield* rows.localInstallations.getById(LOCAL_INSTALLATION_ID);
88− if (Option.isSome(existing)) {
89− return existing.value;
90− }
91−
92− const now = Date.now();
93− const account = buildAccount(now);
94− const organization = buildOrganization(account.id, now);
95− const membership = buildOwnerMembership(organization.id, account.id, now);
96− const workspace = buildWorkspace(organization.id, account.id, now);
97− const installation: LocalInstallation = {
98− id: LOCAL_INSTALLATION_ID,
99− accountId: account.id,
100− organizationId: organization.id,
101− workspaceId: workspace.id,
102− createdAt: now,
103− updatedAt: now,
104− };
105−
106− yield* rows.accounts.upsert(account);
107− yield* rows.organizations.insertWithOwnerMembership(organization, membership);
108− yield* rows.workspaces.insert(workspace);
109− yield* rows.localInstallations.upsert(installation);
110−
111− return installation;
112− });
113−
114−export const getOrProvisionLocalInstallation = (
115− rows: SqlControlPlaneRows,
116−): Effect.Effect<LocalInstallation, unknown> =>
117− Effect.flatMap(loadLocalInstallation(rows), (existing) =>
118− existing ? Effect.succeed(existing) : provisionLocalInstallation(rows)
119− );
33+ context: ResolvedLocalWorkspaceContext,
34+): Effect.Effect<LocalInstallation, never> =>
35+ Effect.succeed(deriveLocalInstallation(context));
36+
37+export const getOrProvisionLocalInstallation = (input: {
38+ context: ResolvedLocalWorkspaceContext;
39+}): Effect.Effect<LocalInstallation, never> =>
40+ loadLocalInstallation(input.context);
packages/control-plane/src/runtime/local-operations.ts+13−22
@@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines
1717 import {
1818 operationErrors,
1919 } from "./operation-errors";
20+import { resolveLocalWorkspaceContext } from "./local-config";
21+import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context";
2022 import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
2123 import {
2224 createSourceCredentialSelectionBearerContent,
@@ -180,29 +182,18 @@ const loadSourceCredentialInteraction = (input: { · 157 unmodified lines
180182
181183 export const getLocalInstallation = () =>
182184 Effect.gen(function* () {
183− const store = yield* ControlPlaneStore;
184−
185− const installation = yield* loadLocalInstallation(store).pipe(
186− Effect.mapError((error) =>
187− error instanceof ControlPlanePersistenceError
188− ? localOps.installation.storage(error)
189− : localOps.installation.unknownStorage(
190− error,
191− "Failed loading local installation",
192− ),
193− ),
194− );
195−
196− if (installation === null) {
197− return yield* Effect.fail(
198− localOps.installation.notFound(
199− "Local installation not found",
200− "No local installation has been provisioned",
201− ),
202− );
203− }
185+ const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
186+ const context = runtimeLocalWorkspace?.context
187+ ?? (yield* resolveLocalWorkspaceContext().pipe(
188+ Effect.mapError((error) =>
189+ localOps.installation.unknownStorage(
190+ error,
191+ "Failed resolving local workspace context",
192+ ),
193+ ),
194+ ));
204195
205− return installation;
196+ return yield* loadLocalInstallation(context);
206197 });
207198
208199 export const getSourceCredentialInteraction = (input: {
packages/control-plane/src/runtime/local-runtime-context.ts+71−0
@@ -0,0 +1,71 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+
5+import type { AccountId, WorkspaceId } from "#schema";
6+import type {
7+ LoadedLocalExecutorConfig,
8+ ResolvedLocalWorkspaceContext,
9+} from "./local-config";
10+import {
11+ RuntimeLocalWorkspaceMismatchError,
12+ RuntimeLocalWorkspaceUnavailableError,
13+} from "./local-errors";
14+
15+export type RuntimeLocalWorkspaceState = {
16+ context: ResolvedLocalWorkspaceContext;
17+ installation: {
18+ workspaceId: WorkspaceId;
19+ accountId: AccountId;
20+ };
21+ loadedConfig: LoadedLocalExecutorConfig;
22+};
23+
24+export class RuntimeLocalWorkspaceService extends Context.Tag(
25+ "#runtime/RuntimeLocalWorkspaceService",
26+)<RuntimeLocalWorkspaceService, RuntimeLocalWorkspaceState>() {}
27+
28+export const getRuntimeLocalWorkspaceOption = () =>
29+ Effect.contextWith((context) =>
30+ Context.getOption(context, RuntimeLocalWorkspaceService),
31+ ).pipe(
32+ Effect.map((option) => (Option.isSome(option) ? option.value : null)),
33+ ) as Effect.Effect<RuntimeLocalWorkspaceState | null, never, never>;
34+
35+export const requireRuntimeLocalWorkspace = (
36+ workspaceId?: WorkspaceId,
37+): Effect.Effect<
38+ RuntimeLocalWorkspaceState,
39+ RuntimeLocalWorkspaceUnavailableError | RuntimeLocalWorkspaceMismatchError,
40+ never
41+> =>
42+ Effect.gen(function* () {
43+ const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
44+ if (runtimeLocalWorkspace === null) {
45+ return yield* Effect.fail(
46+ new RuntimeLocalWorkspaceUnavailableError({
47+ message: "Runtime local workspace is unavailable",
48+ }),
49+ );
50+ }
51+
52+ if (
53+ workspaceId !== undefined
54+ && runtimeLocalWorkspace.installation.workspaceId !== workspaceId
55+ ) {
56+ return yield* Effect.fail(
57+ new RuntimeLocalWorkspaceMismatchError({
58+ message: `Workspace ${workspaceId} is not the active local workspace ${runtimeLocalWorkspace.installation.workspaceId}`,
59+ requestedWorkspaceId: workspaceId,
60+ activeWorkspaceId: runtimeLocalWorkspace.installation.workspaceId,
61+ }),
62+ );
63+ }
64+
65+ return runtimeLocalWorkspace;
66+ });
67+
68+export const requireRuntimeLocalAccountId = (workspaceId?: WorkspaceId) =>
69+ requireRuntimeLocalWorkspace(workspaceId).pipe(
70+ Effect.map((runtimeLocalWorkspace) => runtimeLocalWorkspace.installation.accountId),
71+ );
packages/control-plane/src/runtime/local-source-artifacts.ts+230−0
@@ -0,0 +1,230 @@
1+import { join } from "node:path";
2+import { FileSystem } from "@effect/platform";
3+import { NodeFileSystem } from "@effect/platform-node";
4+
5+import {
6+ SourceIdSchema,
7+ SourceRecipeIdSchema,
8+ StoredSourceRecipeDocumentRecordSchema,
9+ StoredSourceRecipeOperationRecordSchema,
10+ StoredSourceRecipeRevisionRecordSchema,
11+ StoredSourceRecipeSchemaBundleRecordSchema,
12+ TimestampMsSchema,
13+ type Source,
14+ type SourceRecipeId,
15+} from "#schema";
16+import * as Effect from "effect/Effect";
17+import * as Schema from "effect/Schema";
18+
19+import type { ResolvedLocalWorkspaceContext } from "./local-config";
20+import {
21+ LocalFileSystemError,
22+ LocalSourceArtifactDecodeError,
23+ unknownLocalErrorDetails,
24+} from "./local-errors";
25+import {
26+ createSourceRecipeRevisionRecord,
27+ stableSourceRecipeId,
28+} from "./source-definitions";
29+import {
30+ contentHash,
31+ type SourceRecipeMaterialization,
32+} from "./source-recipe-support";
33+
34+const LOCAL_SOURCE_ARTIFACT_VERSION = 1 as const;
35+
36+export const LocalSourceArtifactSchema = Schema.Struct({
37+ version: Schema.Literal(LOCAL_SOURCE_ARTIFACT_VERSION),
38+ sourceId: SourceIdSchema,
39+ recipeId: SourceRecipeIdSchema,
40+ generatedAt: TimestampMsSchema,
41+ revision: StoredSourceRecipeRevisionRecordSchema,
42+ documents: Schema.Array(StoredSourceRecipeDocumentRecordSchema),
43+ schemaBundles: Schema.Array(StoredSourceRecipeSchemaBundleRecordSchema),
44+ operations: Schema.Array(StoredSourceRecipeOperationRecordSchema),
45+});
46+
47+export type LocalSourceArtifact = typeof LocalSourceArtifactSchema.Type;
48+
49+const decodeLocalSourceArtifact = Schema.decodeUnknownSync(LocalSourceArtifactSchema);
50+
51+const provideNodeFileSystem = <A, E, R>(
52+ effect: Effect.Effect<A, E, R | FileSystem.FileSystem>,
53+): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> =>
54+ effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect<
55+ A,
56+ E,
57+ Exclude<R, FileSystem.FileSystem>
58+ >;
59+
60+const mapFileSystemError = (path: string, action: string) => (cause: unknown) =>
61+ new LocalFileSystemError({
62+ message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`,
63+ action,
64+ path,
65+ details: unknownLocalErrorDetails(cause),
66+ });
67+
68+const localSourceArtifactPath = (input: {
69+ context: ResolvedLocalWorkspaceContext;
70+ sourceId: string;
71+}): string =>
72+ join(
73+ input.context.artifactsDirectory,
74+ "sources",
75+ `${input.sourceId}.json`,
76+ );
77+
78+const canonicalMaterializationHash = (input: {
79+ materialization: SourceRecipeMaterialization;
80+}): string => {
81+ const documents = [...input.materialization.documents]
82+ .map((document) => ({
83+ documentKind: document.documentKind,
84+ documentKey: document.documentKey,
85+ contentHash: document.contentHash,
86+ }))
87+ .sort((left, right) =>
88+ left.documentKind.localeCompare(right.documentKind)
89+ || left.documentKey.localeCompare(right.documentKey)
90+ || left.contentHash.localeCompare(right.contentHash)
91+ );
92+ const schemaBundles = [...input.materialization.schemaBundles]
93+ .map((bundle) => ({
94+ bundleKind: bundle.bundleKind,
95+ contentHash: bundle.contentHash,
96+ }))
97+ .sort((left, right) =>
98+ left.bundleKind.localeCompare(right.bundleKind)
99+ || left.contentHash.localeCompare(right.contentHash)
100+ );
101+ const operations = [...input.materialization.operations]
102+ .map((operation) => ({
103+ operationKey: operation.operationKey,
104+ transportKind: operation.transportKind,
105+ toolId: operation.toolId,
106+ title: operation.title,
107+ description: operation.description,
108+ operationKind: operation.operationKind,
109+ searchText: operation.searchText,
110+ inputSchemaJson: operation.inputSchemaJson,
111+ outputSchemaJson: operation.outputSchemaJson,
112+ providerKind: operation.providerKind,
113+ providerDataJson: operation.providerDataJson,
114+ }))
115+ .sort((left, right) => left.operationKey.localeCompare(right.operationKey));
116+
117+ return contentHash(JSON.stringify({
118+ schemaVersion: 1,
119+ manifestHash: input.materialization.manifestHash,
120+ manifestJson: input.materialization.manifestJson,
121+ documents,
122+ schemaBundles,
123+ operations,
124+ }));
125+};
126+
127+const bindRevisionId = <T extends { recipeRevisionId: string }>(
128+ items: readonly T[],
129+ recipeRevisionId: string,
130+): T[] =>
131+ items.map((item) => ({
132+ ...item,
133+ recipeRevisionId,
134+ }));
135+
136+export const buildLocalSourceArtifact = (input: {
137+ source: Source;
138+ materialization: SourceRecipeMaterialization;
139+}): LocalSourceArtifact => {
140+ const recipeId: SourceRecipeId = stableSourceRecipeId(input.source);
141+ const now = Date.now();
142+ const revision = createSourceRecipeRevisionRecord({
143+ source: input.source,
144+ recipeId,
145+ revisionNumber: 1,
146+ manifestJson: input.materialization.manifestJson,
147+ manifestHash: input.materialization.manifestHash,
148+ materializationHash: canonicalMaterializationHash({
149+ materialization: input.materialization,
150+ }),
151+ });
152+
153+ return {
154+ version: LOCAL_SOURCE_ARTIFACT_VERSION,
155+ sourceId: input.source.id,
156+ recipeId,
157+ generatedAt: now,
158+ revision,
159+ documents: bindRevisionId(input.materialization.documents, revision.id),
160+ schemaBundles: bindRevisionId(input.materialization.schemaBundles, revision.id),
161+ operations: bindRevisionId(input.materialization.operations, revision.id),
162+ };
163+};
164+
165+export const readLocalSourceArtifact = (input: {
166+ context: ResolvedLocalWorkspaceContext;
167+ sourceId: string;
168+}): Effect.Effect<
169+ LocalSourceArtifact | null,
170+ LocalFileSystemError | LocalSourceArtifactDecodeError
171+> =>
172+ provideNodeFileSystem(Effect.gen(function* () {
173+ const fs = yield* FileSystem.FileSystem;
174+ const path = localSourceArtifactPath(input);
175+ const exists = yield* fs.exists(path).pipe(
176+ Effect.mapError(mapFileSystemError(path, "check source artifact path")),
177+ );
178+ if (!exists) {
179+ return null;
180+ }
181+
182+ const content = yield* fs.readFileString(path, "utf8").pipe(
183+ Effect.mapError(mapFileSystemError(path, "read source artifact")),
184+ );
185+ return yield* Effect.try({
186+ try: () => decodeLocalSourceArtifact(JSON.parse(content) as unknown),
187+ catch: (cause) => {
188+ return new LocalSourceArtifactDecodeError({
189+ message: `Invalid local source artifact at ${path}: ${unknownLocalErrorDetails(cause)}`,
190+ path,
191+ details: unknownLocalErrorDetails(cause),
192+ });
193+ },
194+ });
195+ }));
196+
197+export const writeLocalSourceArtifact = (input: {
198+ context: ResolvedLocalWorkspaceContext;
199+ sourceId: string;
200+ artifact: LocalSourceArtifact;
201+}): Effect.Effect<void, LocalFileSystemError> =>
202+ provideNodeFileSystem(Effect.gen(function* () {
203+ const fs = yield* FileSystem.FileSystem;
204+ const directory = join(input.context.artifactsDirectory, "sources");
205+ const path = localSourceArtifactPath(input);
206+ yield* fs.makeDirectory(directory, { recursive: true }).pipe(
207+ Effect.mapError(mapFileSystemError(directory, "create source artifact directory")),
208+ );
209+ yield* fs.writeFileString(path, `${JSON.stringify(input.artifact, null, 2)}\n`).pipe(
210+ Effect.mapError(mapFileSystemError(path, "write source artifact")),
211+ );
212+ }));
213+
214+export const removeLocalSourceArtifact = (input: {
215+ context: ResolvedLocalWorkspaceContext;
216+ sourceId: string;
217+}): Effect.Effect<void, LocalFileSystemError> =>
218+ provideNodeFileSystem(Effect.gen(function* () {
219+ const fs = yield* FileSystem.FileSystem;
220+ const path = localSourceArtifactPath(input);
221+ const exists = yield* fs.exists(path).pipe(
222+ Effect.mapError(mapFileSystemError(path, "check source artifact path")),
223+ );
224+ if (!exists) {
225+ return;
226+ }
227+ yield* fs.remove(path).pipe(
228+ Effect.mapError(mapFileSystemError(path, "remove source artifact")),
229+ );
230+ }));
packages/control-plane/src/runtime/local-workspace-state.ts+124−0
@@ -0,0 +1,124 @@
1+import { join } from "node:path";
2+import { FileSystem } from "@effect/platform";
3+import { NodeFileSystem } from "@effect/platform-node";
4+
5+import {
6+ PolicyIdSchema,
7+ SourceStatusSchema,
8+ TimestampMsSchema,
9+} from "#schema";
10+import * as Effect from "effect/Effect";
11+import * as Schema from "effect/Schema";
12+
13+import type { ResolvedLocalWorkspaceContext } from "./local-config";
14+import {
15+ LocalFileSystemError,
16+ LocalWorkspaceStateDecodeError,
17+ unknownLocalErrorDetails,
18+} from "./local-errors";
19+
20+const WORKSPACE_STATE_BASENAME = "workspace-state.json";
21+
22+const LocalWorkspaceSourceStateSchema = Schema.Struct({
23+ status: SourceStatusSchema,
24+ lastError: Schema.NullOr(Schema.String),
25+ sourceHash: Schema.NullOr(Schema.String),
26+ createdAt: TimestampMsSchema,
27+ updatedAt: TimestampMsSchema,
28+});
29+
30+const LocalWorkspacePolicyStateSchema = Schema.Struct({
31+ id: PolicyIdSchema,
32+ createdAt: TimestampMsSchema,
33+ updatedAt: TimestampMsSchema,
34+});
35+
36+export const LocalWorkspaceStateSchema = Schema.Struct({
37+ version: Schema.Literal(1),
38+ sources: Schema.Record({
39+ key: Schema.String,
40+ value: LocalWorkspaceSourceStateSchema,
41+ }),
42+ policies: Schema.Record({
43+ key: Schema.String,
44+ value: LocalWorkspacePolicyStateSchema,
45+ }),
46+});
47+
48+export type LocalWorkspaceSourceState = typeof LocalWorkspaceSourceStateSchema.Type;
49+export type LocalWorkspacePolicyState = typeof LocalWorkspacePolicyStateSchema.Type;
50+export type LocalWorkspaceState = typeof LocalWorkspaceStateSchema.Type;
51+
52+const decodeLocalWorkspaceState = Schema.decodeUnknownSync(LocalWorkspaceStateSchema);
53+
54+const provideNodeFileSystem = <A, E, R>(
55+ effect: Effect.Effect<A, E, R | FileSystem.FileSystem>,
56+): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> =>
57+ effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect<
58+ A,
59+ E,
60+ Exclude<R, FileSystem.FileSystem>
61+ >;
62+
63+const mapFileSystemError = (path: string, action: string) => (cause: unknown) =>
64+ new LocalFileSystemError({
65+ message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`,
66+ action,
67+ path,
68+ details: unknownLocalErrorDetails(cause),
69+ });
70+
71+const defaultLocalWorkspaceState = (): LocalWorkspaceState => ({
72+ version: 1,
73+ sources: {},
74+ policies: {},
75+});
76+
77+export const localWorkspaceStatePath = (
78+ context: ResolvedLocalWorkspaceContext,
79+): string => join(context.stateDirectory, WORKSPACE_STATE_BASENAME);
80+
81+export const loadLocalWorkspaceState = (
82+ context: ResolvedLocalWorkspaceContext,
83+): Effect.Effect<LocalWorkspaceState, LocalFileSystemError | LocalWorkspaceStateDecodeError> =>
84+ provideNodeFileSystem(Effect.gen(function* () {
85+ const fs = yield* FileSystem.FileSystem;
86+ const path = localWorkspaceStatePath(context);
87+ const exists = yield* fs.exists(path).pipe(
88+ Effect.mapError(mapFileSystemError(path, "check workspace state path")),
89+ );
90+ if (!exists) {
91+ return defaultLocalWorkspaceState();
92+ }
93+
94+ const content = yield* fs.readFileString(path, "utf8").pipe(
95+ Effect.mapError(mapFileSystemError(path, "read workspace state")),
96+ );
97+ return yield* Effect.try({
98+ try: () => decodeLocalWorkspaceState(JSON.parse(content) as unknown),
99+ catch: (cause) => {
100+ return new LocalWorkspaceStateDecodeError({
101+ message: `Invalid local workspace state at ${path}: ${unknownLocalErrorDetails(cause)}`,
102+ path,
103+ details: unknownLocalErrorDetails(cause),
104+ });
105+ },
106+ });
107+ }));
108+
109+export const writeLocalWorkspaceState = (input: {
110+ context: ResolvedLocalWorkspaceContext;
111+ state: LocalWorkspaceState;
112+}): Effect.Effect<void, LocalFileSystemError> =>
113+ provideNodeFileSystem(Effect.gen(function* () {
114+ const fs = yield* FileSystem.FileSystem;
115+ yield* fs.makeDirectory(input.context.stateDirectory, { recursive: true }).pipe(
116+ Effect.mapError(mapFileSystemError(input.context.stateDirectory, "create state directory")),
117+ );
118+ yield* fs.writeFileString(
119+ localWorkspaceStatePath(input.context),
120+ `${JSON.stringify(input.state, null, 2)}\n`,
121+ ).pipe(
122+ Effect.mapError(mapFileSystemError(localWorkspaceStatePath(input.context), "write workspace state")),
123+ );
124+ }));
packages/control-plane/src/runtime/local-workspace-sync.ts+93−0
@@ -0,0 +1,93 @@
1+import * as Effect from "effect/Effect";
2+
3+import {
4+ type LoadedLocalExecutorConfig,
5+ type ResolvedLocalWorkspaceContext,
6+} from "./local-config";
7+import {
8+ loadLocalWorkspaceState,
9+ writeLocalWorkspaceState,
10+ type LocalWorkspaceState,
11+} from "./local-workspace-state";
12+
13+const trimOrNull = (value: string | null | undefined): string | null => {
14+ if (value == null) {
15+ return null;
16+ }
17+ const trimmed = value.trim();
18+ return trimmed.length > 0 ? trimmed : null;
19+};
20+
21+export const derivePolicyConfigKey = (
22+ policy: {
23+ resourcePattern: string;
24+ effect: "allow" | "deny";
25+ approvalMode: "auto" | "required";
26+ },
27+ used: Set<string>,
28+): string => {
29+ const base =
30+ trimOrNull(policy.resourcePattern)
31+ ?? `${policy.effect}-${policy.approvalMode}`;
32+ let candidate = base;
33+ let counter = 2;
34+ while (used.has(candidate)) {
35+ candidate = `${base}-${counter}`;
36+ counter += 1;
37+ }
38+ used.add(candidate);
39+ return candidate;
40+};
41+
42+const pruneLocalWorkspaceState = (input: {
43+ context: ResolvedLocalWorkspaceContext;
44+ loadedConfig: LoadedLocalExecutorConfig;
45+}): Effect.Effect<LocalWorkspaceState, Error, never> =>
46+ Effect.gen(function* () {
47+ const currentState = yield* loadLocalWorkspaceState(input.context);
48+
49+ const configuredSourceIds = new Set(
50+ Object.keys(input.loadedConfig.config?.sources ?? {}),
51+ );
52+ const configuredPolicyKeys = new Set(
53+ Object.keys(input.loadedConfig.config?.policies ?? {}),
54+ );
55+
56+ const nextState: LocalWorkspaceState = {
57+ ...currentState,
58+ sources: Object.fromEntries(
59+ Object.entries(currentState.sources).filter(([sourceId]) =>
60+ configuredSourceIds.has(sourceId)
61+ ),
62+ ),
63+ policies: Object.fromEntries(
64+ Object.entries(currentState.policies).filter(([policyKey]) =>
65+ configuredPolicyKeys.has(policyKey)
66+ ),
67+ ),
68+ };
69+
70+ if (JSON.stringify(nextState) === JSON.stringify(currentState)) {
71+ return currentState;
72+ }
73+
74+ yield* writeLocalWorkspaceState({
75+ context: input.context,
76+ state: nextState,
77+ });
78+
79+ return nextState;
80+ });
81+
82+export const synchronizeLocalWorkspaceState = (input: {
83+ context: ResolvedLocalWorkspaceContext;
84+ loadedConfig: LoadedLocalExecutorConfig;
85+}): Effect.Effect<LoadedLocalExecutorConfig["config"], Error, never> =>
86+ Effect.gen(function* () {
87+ yield* pruneLocalWorkspaceState({
88+ context: input.context,
89+ loadedConfig: input.loadedConfig,
90+ });
91+
92+ return input.loadedConfig.config;
93+ });
packages/control-plane/src/runtime/operation-errors.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
22 ControlPlaneBadRequestError,
33 ControlPlaneNotFoundError,
44 ControlPlaneStorageError,
5−} from "#api";
5+} from "../api/errors";
66 import { ControlPlanePersistenceError } from "#persistence";
77 import * as Effect from "effect/Effect";
88
packages/control-plane/src/runtime/operations-shared.ts+1−56
@@ -1,21 +1,14 @@
11 import {
22 ControlPlaneBadRequestError,
3− ControlPlaneNotFoundError,
43 ControlPlaneStorageError,
5−} from "#api";
4+} from "../api/errors";
65 import { ControlPlanePersistenceError } from "#persistence";
7−import type { Organization } from "#schema";
86 import * as Effect from "effect/Effect";
9−import * as Option from "effect/Option";
107
118 import {
129 asOperationErrors,
13− operationErrors,
1410 type OperationErrorsLike,
1511 } from "./operation-errors";
16−import type { ControlPlaneStoreShape } from "./store";
17−import { slugify } from "./slug";
18−
1912 export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
2013
2114 const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>
@@ -59,51 +52,3 @@ export const parseJsonString = ( · 37 unmodified lines
5952 );
6053 },
6154 });
62−
63−export const ensureUniqueOrganizationSlug = (
64− store: ControlPlaneStoreShape,
65− baseName: string,
66− operation = operationErrors("organizations.create"),
67−): Effect.Effect<string, ControlPlaneStorageError> =>
68− Effect.gen(function* () {
69− const normalized = slugify(baseName);
70− const seed = normalized.length > 0 ? normalized : "item";
71−
72− let counter = 0;
73− while (true) {
74− const candidate = counter === 0 ? seed : `${seed}-${counter + 1}`;
75−
76− const existing = yield* operation.child("slug_lookup").mapStorage(
77− store.organizations.getBySlug(candidate as Organization["slug"]),
78− );
79−
80− if (Option.isNone(existing)) {
81− return candidate;
82− }
83−
84− counter += 1;
85− }
86− });
87−
88−export const ensureOrganizationExists = (
89− store: ControlPlaneStoreShape,
90− operation: OperationErrorsLike,
91− organizationId: Organization["id"],
92−): Effect.Effect<Organization, ControlPlaneNotFoundError | ControlPlaneStorageError> =>
93− Effect.gen(function* () {
94− const errors = asOperationErrors(operation);
95− const organization = yield* errors.child("organization_lookup").mapStorage(
96− store.organizations.getById(organizationId),
97− );
98−
99− if (Option.isNone(organization)) {
100− return yield* Effect.fail(
101− errors.notFound(
102− "Organization not found",
103− `organizationId=${organizationId}`,
104− ),
105− );
106− }
107−
108− return organization.value;
109− });
packages/control-plane/src/runtime/organizations-operations.ts+0−434
@@ -1,434 +0,0 @@
1−import type {
2− CreateMembershipPayload,
3− UpdateMembershipPayload,
4−} from "../api/memberships/api";
5−import type {
6− CreateOrganizationPayload,
7− UpdateOrganizationPayload,
8−} from "../api/organizations/api";
9−import type {
10− CreateWorkspacePayload,
11− UpdateWorkspacePayload,
12−} from "../api/workspaces/api";
13−import {
14− OrganizationIdSchema,
15− OrganizationMemberIdSchema,
16− WorkspaceIdSchema,
17− type AccountId,
18− type Organization,
19− type OrganizationId,
20− type OrganizationMembership,
21− type Workspace,
22− type WorkspaceId,
23−} from "#schema";
24−import * as Effect from "effect/Effect";
25−import * as Option from "effect/Option";
26−
27−import {
28− ensureOrganizationExists,
29− ensureUniqueOrganizationSlug,
30− mapPersistenceError,
31− type Mutable,
32−} from "./operations-shared";
33−import {
34− operationErrors,
35−} from "./operation-errors";
36−import {
37− ControlPlaneStore,
38− type ControlPlaneStoreShape,
39−} from "./store";
40−
41−const organizationOps = {
42− list: operationErrors("organizations.list"),
43− create: operationErrors("organizations.create"),
44− get: operationErrors("organizations.get"),
45− update: operationErrors("organizations.update"),
46− remove: operationErrors("organizations.remove"),
47−} as const;
48−
49−const membershipOps = {
50− list: operationErrors("memberships.list"),
51− create: operationErrors("memberships.create"),
52− update: operationErrors("memberships.update"),
53− remove: operationErrors("memberships.remove"),
54−} as const;
55−
56−const workspaceOps = {
57− list: operationErrors("workspaces.list"),
58− create: operationErrors("workspaces.create"),
59− get: operationErrors("workspaces.get"),
60− update: operationErrors("workspaces.update"),
61− remove: operationErrors("workspaces.remove"),
62−} as const;
63−
64−const listOrganizationsWithStore = (
65− store: ControlPlaneStoreShape,
66− input: { accountId: AccountId },
67−) =>
68− Effect.gen(function* () {
69− const memberships = yield* organizationOps.list.child("memberships").mapStorage(
70− store.organizationMemberships.listByAccountId(input.accountId),
71− );
72−
73− const activeOrganizationIds = Array.from(
74− new Set(
75− memberships
76− .filter((membership) => membership.status === "active")
77− .map((membership) => membership.organizationId),
78− ),
79− );
80−
81− const organizations = yield* Effect.forEach(activeOrganizationIds, (organizationId) =>
82− organizationOps.list.child("organization").mapStorage(
83− store.organizations.getById(organizationId),
84− ).pipe(
85− Effect.map((result) => (Option.isSome(result) ? result.value : null)),
86− ));
87−
88− return organizations.filter((organization): organization is Organization => organization !== null);
89− });
90−
91−export const listOrganizations = (input: { accountId: AccountId }) =>
92− Effect.flatMap(ControlPlaneStore, (store) => listOrganizationsWithStore(store, input));
93−
94−export const createOrganization = (input: {
95− payload: CreateOrganizationPayload;
96− createdByAccountId?: Organization["createdByAccountId"];
97−}) =>
98− Effect.flatMap(ControlPlaneStore, (store) =>
99− Effect.gen(function* () {
100− const name = input.payload.name;
101− const now = Date.now();
102−
103− const slug = input.payload.slug
104− ? input.payload.slug
105− : yield* ensureUniqueOrganizationSlug(store, name, organizationOps.create);
106−
107− const organization: Organization = {
108− id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
109− slug,
110− name,
111− status: "active",
112− createdByAccountId: input.createdByAccountId ?? null,
113− createdAt: now,
114− updatedAt: now,
115− };
116−
117− const ownerMembership: OrganizationMembership | null = input.createdByAccountId
118− ? {
119− id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
120− organizationId: organization.id,
121− accountId: input.createdByAccountId,
122− role: "owner",
123− status: "active",
124− billable: true,
125− invitedByAccountId: null,
126− joinedAt: now,
127− createdAt: now,
128− updatedAt: now,
129− }
130− : null;
131−
132− yield* mapPersistenceError(
133− organizationOps.create,
134− store.organizations.insertWithOwnerMembership(organization, ownerMembership),
135− );
136−
137− return organization;
138− }));
139−
140−export const getOrganization = (input: {
141− organizationId: OrganizationId;
142− accountId: AccountId;
143−}) =>
144− Effect.flatMap(ControlPlaneStore, (store) =>
145− Effect.gen(function* () {
146− const membership = yield* organizationOps.get.child("membership").mapStorage(
147− store.organizationMemberships.getByOrganizationAndAccount(
148− input.organizationId,
149− input.accountId,
150− ),
151− );
152−
153− if (Option.isNone(membership) || membership.value.status !== "active") {
154− return yield* Effect.fail(
155− organizationOps.get.notFound(
156− "Organization not found",
157− `organizationId=${input.organizationId}`,
158− ),
159− );
160− }
161−
162− const existing = yield* organizationOps.get.mapStorage(
163− store.organizations.getById(input.organizationId),
164− );
165−
166− if (Option.isNone(existing)) {
167− return yield* Effect.fail(
168− organizationOps.get.notFound(
169− "Organization not found",
170− `organizationId=${input.organizationId}`,
171− ),
172− );
173− }
174−
175− return existing.value;
176− }));
177−
178−export const updateOrganization = (input: {
179− organizationId: OrganizationId;
180− payload: UpdateOrganizationPayload;
181−}) =>
182− Effect.flatMap(ControlPlaneStore, (store) =>
183− Effect.gen(function* () {
184− const patch: Partial<Omit<Mutable<Organization>, "id" | "createdAt">> = {
185− updatedAt: Date.now(),
186− };
187−
188− if (input.payload.name !== undefined) {
189− patch.name = input.payload.name;
190− }
191− if (input.payload.status !== undefined) {
192− patch.status = input.payload.status;
193− }
194−
195− const updated = yield* mapPersistenceError(
196− organizationOps.update,
197− store.organizations.update(input.organizationId, patch),
198− );
199−
200− if (Option.isNone(updated)) {
201− return yield* Effect.fail(
202− organizationOps.update.notFound(
203− "Organization not found",
204− `organizationId=${input.organizationId}`,
205− ),
206− );
207− }
208−
209− return updated.value;
210− }));
211−
212−export const removeOrganization = (input: { organizationId: OrganizationId }) =>
213− Effect.flatMap(ControlPlaneStore, (store) =>
214− organizationOps.remove.mapStorage(
215− store.organizations.removeTreeById(input.organizationId),
216− ).pipe(Effect.map((removed) => ({ removed })))
217− );
218−
219−export const listMemberships = (organizationId: OrganizationId) =>
220− Effect.flatMap(ControlPlaneStore, (store) =>
221− Effect.gen(function* () {
222− yield* ensureOrganizationExists(store, membershipOps.list, organizationId);
223−
224− return yield* membershipOps.list.mapStorage(
225− store.organizationMemberships.listByOrganizationId(organizationId),
226− );
227− }));
228−
229−export const createMembership = (input: {
230− organizationId: OrganizationId;
231− payload: CreateMembershipPayload;
232−}) =>
233− Effect.flatMap(ControlPlaneStore, (store) =>
234− Effect.gen(function* () {
235− yield* ensureOrganizationExists(store, membershipOps.create, input.organizationId);
236−
237− const now = Date.now();
238− const membership: OrganizationMembership = {
239− id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
240− organizationId: input.organizationId,
241− accountId: input.payload.accountId,
242− role: input.payload.role,
243− status: input.payload.status ?? "active",
244− billable: input.payload.billable ?? true,
245− invitedByAccountId: input.payload.invitedByAccountId ?? null,
246− joinedAt: (input.payload.status ?? "active") === "active" ? now : null,
247− createdAt: now,
248− updatedAt: now,
249− };
250−
251− yield* mapPersistenceError(
252− membershipOps.create,
253− store.organizationMemberships.upsert(membership),
254− );
255−
256− const stored = yield* membershipOps.create.mapStorage(
257− store.organizationMemberships.getByOrganizationAndAccount(
258− input.organizationId,
259− input.payload.accountId,
260− ),
261− );
262− if (Option.isNone(stored)) {
263− return yield* Effect.fail(
264− membershipOps.create.badRequest(
265− "Membership was not persisted",
266− `organizationId=${input.organizationId} accountId=${input.payload.accountId}`,
267− ),
268− );
269− }
270−
271− return stored.value;
272− }));
273−
274−export const updateMembership = (input: {
275− organizationId: OrganizationId;
276− accountId: AccountId;
277− payload: UpdateMembershipPayload;
278−}) =>
279− Effect.flatMap(ControlPlaneStore, (store) =>
280− Effect.gen(function* () {
281− yield* ensureOrganizationExists(store, membershipOps.update, input.organizationId);
282−
283− const existing = yield* membershipOps.update.mapStorage(
284− store.organizationMemberships.getByOrganizationAndAccount(
285− input.organizationId,
286− input.accountId,
287− ),
288− );
289−
290− if (Option.isNone(existing)) {
291− return yield* Effect.fail(
292− membershipOps.update.badRequest(
293− "Membership not found",
294− `organizationId=${input.organizationId} accountId=${input.accountId}`,
295− ),
296− );
297− }
298−
299− const current = existing.value;
300− const now = Date.now();
301− const next: OrganizationMembership = {
302− ...current,
303− role: input.payload.role ?? current.role,
304− status: input.payload.status ?? current.status,
305− billable: input.payload.billable ?? current.billable,
306− joinedAt:
307− (input.payload.status ?? current.status) === "active"
308− ? (current.joinedAt ?? now)
309− : current.joinedAt,
310− updatedAt: now,
311− };
312−
313− yield* mapPersistenceError(
314− membershipOps.update,
315− store.organizationMemberships.upsert(next),
316− );
317−
318− return next;
319− }));
320−
321−export const removeMembership = (input: {
322− organizationId: OrganizationId;
323− accountId: AccountId;
324−}) =>
325− Effect.flatMap(ControlPlaneStore, (store) =>
326− Effect.gen(function* () {
327− yield* ensureOrganizationExists(store, membershipOps.remove, input.organizationId);
328−
329− const removed = yield* membershipOps.remove.mapStorage(
330− store.organizationMemberships.removeByOrganizationAndAccount(
331− input.organizationId,
332− input.accountId,
333− ),
334− );
335−
336− return { removed };
337− }));
338−
339−export const listWorkspaces = (organizationId: OrganizationId) =>
340− Effect.flatMap(ControlPlaneStore, (store) =>
341− Effect.gen(function* () {
342− yield* ensureOrganizationExists(store, workspaceOps.list, organizationId);
343−
344− return yield* workspaceOps.list.mapStorage(
345− store.workspaces.listByOrganizationId(organizationId),
346− );
347− }));
348−
349−export const createWorkspace = (input: {
350− organizationId: OrganizationId;
351− payload: CreateWorkspacePayload;
352− createdByAccountId?: Workspace["createdByAccountId"];
353−}) =>
354− Effect.flatMap(ControlPlaneStore, (store) =>
355− Effect.gen(function* () {
356− yield* ensureOrganizationExists(store, workspaceOps.create, input.organizationId);
357− const name = input.payload.name;
358− const now = Date.now();
359−
360− const workspace: Workspace = {
361− id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
362− organizationId: input.organizationId,
363− name,
364− createdByAccountId: input.createdByAccountId ?? null,
365− createdAt: now,
366− updatedAt: now,
367− };
368−
369− yield* mapPersistenceError(
370− workspaceOps.create,
371− store.workspaces.insert(workspace),
372− );
373−
374− return workspace;
375− }));
376−
377−export const getWorkspace = (workspaceId: WorkspaceId) =>
378− Effect.flatMap(ControlPlaneStore, (store) =>
379− Effect.gen(function* () {
380− const existing = yield* workspaceOps.get.mapStorage(
381− store.workspaces.getById(workspaceId),
382− );
383−
384− if (Option.isNone(existing)) {
385− return yield* Effect.fail(
386− workspaceOps.get.notFound(
387− "Workspace not found",
388− `workspaceId=${workspaceId}`,
389− ),
390− );
391− }
392−
393− return existing.value;
394− }));
395−
396−export const updateWorkspace = (input: {
397− workspaceId: WorkspaceId;
398− payload: UpdateWorkspacePayload;
399−}) =>
400− Effect.flatMap(ControlPlaneStore, (store) =>
401− Effect.gen(function* () {
402− const patch: Partial<
403− Omit<Mutable<Workspace>, "id" | "organizationId" | "createdAt">
404− > = {
405− updatedAt: Date.now(),
406− };
407−
408− if (input.payload.name !== undefined) {
409− patch.name = input.payload.name;
410− }
411−
412− const updated = yield* mapPersistenceError(
413− workspaceOps.update,
414− store.workspaces.update(input.workspaceId, patch),
415− );
416−
417− if (Option.isNone(updated)) {
418− return yield* Effect.fail(
419− workspaceOps.update.notFound(
420− "Workspace not found",
421− `workspaceId=${input.workspaceId}`,
422− ),
423− );
424− }
425−
426− return updated.value;
427− }));
428−
429−export const removeWorkspace = (input: { workspaceId: WorkspaceId }) =>
430− Effect.flatMap(ControlPlaneStore, (store) =>
431− workspaceOps.remove.mapStorage(
432− store.workspaces.removeById(input.workspaceId),
433− ).pipe(Effect.map((removed) => ({ removed })))
434− );
packages/control-plane/src/runtime/policies-operations.ts+297−306
@@ -1,27 +1,37 @@
1+import { createHash } from "node:crypto";
2+
13 import type {
24 CreatePolicyPayload,
35 UpdatePolicyPayload,
46 } from "../api/policies/api";
57 import {
68 PolicyIdSchema,
7− type OrganizationId,
8− type Policy,
9+ type LocalExecutorConfig,
10+ type LocalWorkspacePolicy,
911 type PolicyId,
1012 type WorkspaceId,
1113 } from "#schema";
1214 import * as Effect from "effect/Effect";
13−import * as Option from "effect/Option";
1415
1516 import {
16− mapPersistenceError,
17− parseJsonString,
18− type Mutable,
19−} from "./operations-shared";
17+ loadLocalExecutorConfig,
18+ writeProjectLocalExecutorConfig,
19+} from "./local-config";
20+import {
21+ requireRuntimeLocalWorkspace,
22+} from "./local-runtime-context";
23+import {
24+ loadLocalWorkspaceState,
25+ type LocalWorkspaceState,
26+ writeLocalWorkspaceState,
27+} from "./local-workspace-state";
28+import {
29+ derivePolicyConfigKey,
30+} from "./local-workspace-sync";
2031 import {
2132 type OperationErrors,
2233 operationErrors,
2334 } from "./operation-errors";
24−import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
2535
2636 const policyOps = {
2737 list: operationErrors("policies.list"),
@@ -31,336 +41,317 @@ const policyOps = { · 3 unmodified lines
3141 remove: operationErrors("policies.remove"),
3242 } as const;
3343
34−type PolicyScopeContext = {
35− scopeType: Policy["scopeType"];
36− organizationId: Policy["organizationId"];
37− workspaceId: Policy["workspaceId"];
38−};
44+const cloneJson = <T>(value: T): T =>
45+ JSON.parse(JSON.stringify(value)) as T;
3946
40−const loadWorkspacePolicyContext = (
41− store: ControlPlaneStoreShape,
42− operation: OperationErrors,
43− workspaceId: WorkspaceId,
44−) =>
47+const localPolicyIdForKey = (input: {
48+ workspaceRoot: string;
49+ key: string;
50+}): PolicyId =>
51+ PolicyIdSchema.make(
52+ `pol_local_${createHash("sha256").update(`${input.workspaceRoot}:${input.key}`).digest("hex").slice(0, 16)}`,
53+ );
54+
55+const toLocalWorkspacePolicy = (input: {
56+ workspaceId: WorkspaceId;
57+ workspaceRoot: string;
58+ key: string;
59+ policyConfig: NonNullable<LocalExecutorConfig["policies"]>[string];
60+ state: LocalWorkspaceState["policies"][string] | undefined;
61+}): LocalWorkspacePolicy => ({
62+ id: input.state?.id ?? localPolicyIdForKey({
63+ workspaceRoot: input.workspaceRoot,
64+ key: input.key,
65+ }),
66+ key: input.key,
67+ workspaceId: input.workspaceId,
68+ resourcePattern: input.policyConfig.match.trim(),
69+ effect: input.policyConfig.action,
70+ approvalMode: input.policyConfig.approval === "manual" ? "required" : "auto",
71+ priority: input.policyConfig.priority ?? 0,
72+ enabled: input.policyConfig.enabled ?? true,
73+ createdAt: input.state?.createdAt ?? Date.now(),
74+ updatedAt: input.state?.updatedAt ?? Date.now(),
75+});
76+
77+export const loadRuntimeLocalWorkspacePolicies = (workspaceId: WorkspaceId) =>
4578 Effect.gen(function* () {
46− const workspace = yield* operation.child("workspace").mapStorage(
47− store.workspaces.getById(workspaceId),
48− );
49− if (Option.isNone(workspace)) {
50− return yield* Effect.fail(
51− operation.notFound(
52− "Workspace not found",
53− `workspaceId=${workspaceId}`,
54− ),
55− );
56− }
79+ const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId);
80+ const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context);
81+ const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
82+
83+ const policies = Object.entries(loadedConfig.config?.policies ?? {}).map(([key, policyConfig]) =>
84+ toLocalWorkspacePolicy({
85+ workspaceId,
86+ workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
87+ key,
88+ policyConfig,
89+ state: workspaceState.policies[key],
90+ }));
5791
5892 return {
59− scopeType: "workspace",
60− organizationId: workspace.value.organizationId,
61− workspaceId,
62− } satisfies PolicyScopeContext;
93+ runtimeLocalWorkspace,
94+ loadedConfig,
95+ workspaceState,
96+ policies,
97+ };
6398 });
6499
65−const loadOrganizationPolicyContext = (
66− store: ControlPlaneStoreShape,
100+const writeLocalPolicyFiles = (input: {
101+ operation: OperationErrors;
102+ context: Parameters<typeof writeProjectLocalExecutorConfig>[0]["context"];
103+ projectConfig: LocalExecutorConfig;
104+ workspaceState: LocalWorkspaceState;
105+}) =>
106+ Effect.all([
107+ writeProjectLocalExecutorConfig({
108+ context: input.context,
109+ config: input.projectConfig,
110+ }),
111+ writeLocalWorkspaceState({
112+ context: input.context,
113+ state: input.workspaceState,
114+ }),
115+ ], { discard: true }).pipe(
116+ Effect.mapError((cause) =>
117+ input.operation.unknownStorage(
118+ cause,
119+ "Failed writing local workspace policy files",
120+ ),
121+ ),
122+ );
123+
124+const loadWorkspacePolicyContext = (
67125 operation: OperationErrors,
68− organizationId: OrganizationId,
126+ workspaceId: WorkspaceId,
69127 ) =>
128+ requireRuntimeLocalWorkspace(workspaceId).pipe(
129+ Effect.mapError((cause) =>
130+ operation.notFound(
131+ "Workspace not found",
132+ cause instanceof Error ? cause.message : String(cause),
133+ ),
134+ ),
135+ );
136+
137+export const listPolicies = (workspaceId: WorkspaceId) =>
70138 Effect.gen(function* () {
71− const organization = yield* operation.child("organization").mapStorage(
72− store.organizations.getById(organizationId),
73− );
74− if (Option.isNone(organization)) {
75− return yield* Effect.fail(
76− operation.notFound(
77− "Organization not found",
78− `organizationId=${organizationId}`,
139+ yield* loadWorkspacePolicyContext(policyOps.list, workspaceId);
140+ const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(workspaceId).pipe(
141+ Effect.mapError((cause) =>
142+ policyOps.list.unknownStorage(
143+ cause,
144+ "Failed loading local workspace policies",
79145 ),
80− );
81− }
82−
83− return {
84− scopeType: "organization",
85− organizationId,
86− workspaceId: null,
87− } satisfies PolicyScopeContext;
146+ ),
147+ );
148+ return localWorkspace.policies;
88149 });
89150
90−const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean =>
91− policy.scopeType === scope.scopeType
92− && policy.organizationId === scope.organizationId
93− && policy.workspaceId === scope.workspaceId;
94−
95−const createScopedPolicy = (input: {
96− scope: PolicyScopeContext;
151+export const createPolicy = (input: {
152+ workspaceId: WorkspaceId;
97153 payload: CreatePolicyPayload;
98154 }) =>
99− Effect.flatMap(ControlPlaneStore, (store) =>
100− Effect.gen(function* () {
101− const now = Date.now();
102− const operation = policyOps.create;
103−
104− const policy: Policy = {
105− id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),
106− scopeType: input.scope.scopeType,
107− organizationId: input.scope.organizationId,
108− workspaceId: input.scope.workspaceId,
109− targetAccountId: input.payload.targetAccountId ?? null,
110− clientId: input.payload.clientId ?? null,
111− resourceType: input.payload.resourceType ?? "tool_path",
155+ Effect.gen(function* () {
156+ const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.create, input.workspaceId);
157+ const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
158+ Effect.mapError((cause) =>
159+ policyOps.create.unknownStorage(
160+ cause,
161+ "Failed loading local workspace policies",
162+ ),
163+ ),
164+ );
165+ const now = Date.now();
166+ const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
167+ const policies = { ...(projectConfig.policies ?? {}) };
168+ const key = derivePolicyConfigKey(
169+ {
112170 resourcePattern: input.payload.resourcePattern ?? "*",
113− matchType: input.payload.matchType ?? "glob",
114171 effect: input.payload.effect ?? "allow",
115172 approvalMode: input.payload.approvalMode ?? "auto",
116− argumentConditionsJson: input.payload.argumentConditionsJson ?? null,
117− priority: input.payload.priority ?? 0,
118− enabled: input.payload.enabled ?? true,
119− createdAt: now,
120− updatedAt: now,
121− };
122−
123− if (policy.argumentConditionsJson !== null) {
124− yield* parseJsonString(
125− operation,
126− "argumentConditionsJson",
127− policy.argumentConditionsJson,
128− );
129− }
130−
131− yield* mapPersistenceError(
132− operation,
133− store.policies.insert(policy),
134− );
135−
136− return policy;
137− }));
138−
139−const getScopedPolicy = (input: {
140− scope: PolicyScopeContext;
141− policyId: PolicyId;
142−}) =>
143− Effect.flatMap(ControlPlaneStore, (store) =>
144− Effect.gen(function* () {
145− const existing = yield* policyOps.get.mapStorage(
146− store.policies.getById(input.policyId),
147− );
148−
149− if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) {
150− return yield* Effect.fail(
151− policyOps.get.notFound(
152− "Policy not found",
153− `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
154− ),
155− );
156− }
157−
158− return existing.value;
159− }));
160−
161−const updateScopedPolicy = (input: {
162− scope: PolicyScopeContext;
163− policyId: PolicyId;
164− payload: UpdatePolicyPayload;
165−}) =>
166− Effect.flatMap(ControlPlaneStore, (store) =>
167− Effect.gen(function* () {
168− const existing = yield* policyOps.update.mapStorage(
169− store.policies.getById(input.policyId),
170− );
171− if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) {
172− return yield* Effect.fail(
173− policyOps.update.notFound(
174− "Policy not found",
175− `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
176− ),
177− );
178− }
179−
180− const patch: Partial<
181− Omit<Mutable<Policy>, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">
182− > = {
183− updatedAt: Date.now(),
184− };
185−
186− if (input.payload.targetAccountId !== undefined) {
187− patch.targetAccountId = input.payload.targetAccountId;
188− }
189− if (input.payload.clientId !== undefined) {
190− patch.clientId = input.payload.clientId;
191− }
192− if (input.payload.resourceType !== undefined) {
193− patch.resourceType = input.payload.resourceType;
194− }
195− if (input.payload.resourcePattern !== undefined) {
196− patch.resourcePattern = input.payload.resourcePattern;
197− }
198− if (input.payload.matchType !== undefined) {
199− patch.matchType = input.payload.matchType;
200− }
201− if (input.payload.effect !== undefined) {
202− patch.effect = input.payload.effect;
203− }
204− if (input.payload.approvalMode !== undefined) {
205− patch.approvalMode = input.payload.approvalMode;
206− }
207− if (input.payload.argumentConditionsJson !== undefined) {
208− if (input.payload.argumentConditionsJson !== null) {
209− yield* parseJsonString(
210− policyOps.update,
211− "argumentConditionsJson",
212− input.payload.argumentConditionsJson,
213− );
214− }
215− patch.argumentConditionsJson = input.payload.argumentConditionsJson;
216− }
217− if (input.payload.priority !== undefined) {
218− patch.priority = input.payload.priority;
219− }
220− if (input.payload.enabled !== undefined) {
221− patch.enabled = input.payload.enabled;
222− }
223−
224− const updated = yield* mapPersistenceError(
225− policyOps.update,
226− store.policies.update(input.policyId, patch),
227− );
228− if (Option.isNone(updated)) {
229− return yield* Effect.fail(
230− policyOps.update.notFound(
231− "Policy not found",
232− `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
233− ),
234− );
235− }
236−
237− return updated.value;
238− }));
239−
240−const removeScopedPolicy = (input: {
241− scope: PolicyScopeContext;
242− policyId: PolicyId;
243−}) =>
244− Effect.flatMap(ControlPlaneStore, (store) =>
245− Effect.gen(function* () {
246− const existing = yield* policyOps.remove.mapStorage(
247− store.policies.getById(input.policyId),
248− );
249− if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) {
250− return { removed: false };
251− }
252−
253− const removed = yield* policyOps.remove.mapStorage(
254− store.policies.removeById(input.policyId),
255− );
256−
257− return { removed };
258− }));
259−
260−export const listOrganizationPolicies = (organizationId: OrganizationId) =>
261− Effect.flatMap(ControlPlaneStore, (store) =>
262− Effect.gen(function* () {
263− yield* loadOrganizationPolicyContext(store, policyOps.list, organizationId);
264− return yield* policyOps.list.mapStorage(
265− store.policies.listByOrganizationId(organizationId),
266− );
267− }));
268−
269−export const listPolicies = (workspaceId: WorkspaceId) =>
270− Effect.flatMap(ControlPlaneStore, (store) =>
271− Effect.gen(function* () {
272− yield* loadWorkspacePolicyContext(store, policyOps.list, workspaceId);
273− return yield* policyOps.list.mapStorage(
274− store.policies.listByWorkspaceId(workspaceId),
275− );
276− }));
277−
278−export const createOrganizationPolicy = (input: {
279− organizationId: OrganizationId;
280− payload: CreatePolicyPayload;
281−}) =>
282− Effect.flatMap(ControlPlaneStore, (store) =>
283− Effect.flatMap(
284− loadOrganizationPolicyContext(store, policyOps.create, input.organizationId),
285− (scope) => createScopedPolicy({ scope, payload: input.payload }),
286− ));
287−
288−export const createPolicy = (input: {
289− workspaceId: WorkspaceId;
290− payload: CreatePolicyPayload;
291−}) =>
292− Effect.flatMap(ControlPlaneStore, (store) =>
293− Effect.flatMap(
294− loadWorkspacePolicyContext(store, policyOps.create, input.workspaceId),
295− (scope) => createScopedPolicy({ scope, payload: input.payload }),
296− ));
173+ },
174+ new Set(Object.keys(policies)),
175+ );
297176
298−export const getOrganizationPolicy = (input: {
299− organizationId: OrganizationId;
300− policyId: PolicyId;
301−}) =>
302− Effect.flatMap(ControlPlaneStore, (store) =>
303− Effect.flatMap(
304− loadOrganizationPolicyContext(store, policyOps.get, input.organizationId),
305− (scope) => getScopedPolicy({ scope, policyId: input.policyId }),
306− ));
177+ policies[key] = {
178+ match: input.payload.resourcePattern ?? "*",
179+ action: input.payload.effect ?? "allow",
180+ approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto",
181+ ...(input.payload.enabled === false ? { enabled: false } : {}),
182+ ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}),
183+ };
184+
185+ const existingState = localWorkspace.workspaceState.policies[key];
186+ const workspaceState: LocalWorkspaceState = {
187+ ...localWorkspace.workspaceState,
188+ policies: {
189+ ...localWorkspace.workspaceState.policies,
190+ [key]: {
191+ id: existingState?.id ?? localPolicyIdForKey({
192+ workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
193+ key,
194+ }),
195+ createdAt: existingState?.createdAt ?? now,
196+ updatedAt: now,
197+ },
198+ },
199+ };
200+
201+ yield* writeLocalPolicyFiles({
202+ operation: policyOps.create,
203+ context: runtimeLocalWorkspace.context,
204+ projectConfig: {
205+ ...projectConfig,
206+ policies,
207+ },
208+ workspaceState,
209+ });
210+
211+ return toLocalWorkspacePolicy({
212+ workspaceId: input.workspaceId,
213+ workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
214+ key,
215+ policyConfig: policies[key]!,
216+ state: workspaceState.policies[key],
217+ });
218+ });
307219
308220 export const getPolicy = (input: {
309221 workspaceId: WorkspaceId;
310222 policyId: PolicyId;
311223 }) =>
312− Effect.flatMap(ControlPlaneStore, (store) =>
313− Effect.flatMap(
314− loadWorkspacePolicyContext(store, policyOps.get, input.workspaceId),
315− (scope) => getScopedPolicy({ scope, policyId: input.policyId }),
316− ));
317−
318−export const updateOrganizationPolicy = (input: {
319− organizationId: OrganizationId;
320− policyId: PolicyId;
321− payload: UpdatePolicyPayload;
322−}) =>
323− Effect.flatMap(ControlPlaneStore, (store) =>
324− Effect.flatMap(
325− loadOrganizationPolicyContext(store, policyOps.update, input.organizationId),
326− (scope) => updateScopedPolicy({
327− scope,
328− policyId: input.policyId,
329− payload: input.payload,
330− }),
331− ));
224+ Effect.gen(function* () {
225+ yield* loadWorkspacePolicyContext(policyOps.get, input.workspaceId);
226+ const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
227+ Effect.mapError((cause) =>
228+ policyOps.get.unknownStorage(
229+ cause,
230+ "Failed loading local workspace policies",
231+ ),
232+ ),
233+ );
234+ const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
235+ if (policy === null) {
236+ return yield* Effect.fail(
237+ policyOps.get.notFound(
238+ "Policy not found",
239+ `workspaceId=${input.workspaceId} policyId=${input.policyId}`,
240+ ),
241+ );
242+ }
243+ return policy;
244+ });
332245
333246 export const updatePolicy = (input: {
334247 workspaceId: WorkspaceId;
335248 policyId: PolicyId;
336249 payload: UpdatePolicyPayload;
337250 }) =>
338− Effect.flatMap(ControlPlaneStore, (store) =>
339− Effect.flatMap(
340− loadWorkspacePolicyContext(store, policyOps.update, input.workspaceId),
341− (scope) => updateScopedPolicy({
342− scope,
343− policyId: input.policyId,
344− payload: input.payload,
345− }),
346− ));
251+ Effect.gen(function* () {
252+ const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.update, input.workspaceId);
253+ const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
254+ Effect.mapError((cause) =>
255+ policyOps.update.unknownStorage(
256+ cause,
257+ "Failed loading local workspace policies",
258+ ),
259+ ),
260+ );
261+ const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
262+ if (existing === null) {
263+ return yield* Effect.fail(
264+ policyOps.update.notFound(
265+ "Policy not found",
266+ `workspaceId=${input.workspaceId} policyId=${input.policyId}`,
267+ ),
268+ );
269+ }
347270
348−export const removeOrganizationPolicy = (input: {
349− organizationId: OrganizationId;
350− policyId: PolicyId;
351−}) =>
352− Effect.flatMap(ControlPlaneStore, (store) =>
353− Effect.flatMap(
354− loadOrganizationPolicyContext(store, policyOps.remove, input.organizationId),
355− (scope) => removeScopedPolicy({ scope, policyId: input.policyId }),
356− ));
271+ const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
272+ const policies = { ...(projectConfig.policies ?? {}) };
273+ const existingConfig = policies[existing.key]!;
274+
275+ policies[existing.key] = {
276+ ...existingConfig,
277+ ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}),
278+ ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}),
279+ ...(input.payload.approvalMode !== undefined
280+ ? { approval: input.payload.approvalMode === "required" ? "manual" : "auto" }
281+ : {}),
282+ ...(input.payload.enabled !== undefined ? { enabled: input.payload.enabled } : {}),
283+ ...(input.payload.priority !== undefined ? { priority: input.payload.priority } : {}),
284+ };
285+
286+ const updatedAt = Date.now();
287+ const existingState = localWorkspace.workspaceState.policies[existing.key];
288+ const workspaceState: LocalWorkspaceState = {
289+ ...localWorkspace.workspaceState,
290+ policies: {
291+ ...localWorkspace.workspaceState.policies,
292+ [existing.key]: {
293+ id: existing.id,
294+ createdAt: existingState?.createdAt ?? existing.createdAt,
295+ updatedAt,
296+ },
297+ },
298+ };
299+
300+ yield* writeLocalPolicyFiles({
301+ operation: policyOps.update,
302+ context: runtimeLocalWorkspace.context,
303+ projectConfig: {
304+ ...projectConfig,
305+ policies,
306+ },
307+ workspaceState,
308+ });
309+
310+ return toLocalWorkspacePolicy({
311+ workspaceId: input.workspaceId,
312+ workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
313+ key: existing.key,
314+ policyConfig: policies[existing.key]!,
315+ state: workspaceState.policies[existing.key],
316+ });
317+ });
357318
358319 export const removePolicy = (input: {
359320 workspaceId: WorkspaceId;
360321 policyId: PolicyId;
361322 }) =>
362− Effect.flatMap(ControlPlaneStore, (store) =>
363− Effect.flatMap(
364− loadWorkspacePolicyContext(store, policyOps.remove, input.workspaceId),
365− (scope) => removeScopedPolicy({ scope, policyId: input.policyId }),
366− ));
323+ Effect.gen(function* () {
324+ const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.remove, input.workspaceId);
325+ const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
326+ Effect.mapError((cause) =>
327+ policyOps.remove.unknownStorage(
328+ cause,
329+ "Failed loading local workspace policies",
330+ ),
331+ ),
332+ );
333+ const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
334+ if (existing === null) {
335+ return { removed: false };
336+ }
337+
338+ const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
339+ const policies = { ...(projectConfig.policies ?? {}) };
340+ delete policies[existing.key];
341+
342+ const { [existing.key]: _removedPolicy, ...remainingPolicies } = localWorkspace.workspaceState.policies;
343+ yield* writeLocalPolicyFiles({
344+ operation: policyOps.remove,
345+ context: runtimeLocalWorkspace.context,
346+ projectConfig: {
347+ ...projectConfig,
348+ policies,
349+ },
350+ workspaceState: {
351+ ...localWorkspace.workspaceState,
352+ policies: remainingPolicies,
353+ },
354+ });
355+
356+ return { removed: true };
357+ });
packages/control-plane/src/runtime/secret-material-providers.ts+178−1
@@ -1,8 +1,13 @@
11 import { randomUUID } from "node:crypto";
22 import { spawn } from "node:child_process";
3+import { promises as fs } from "node:fs";
4+import { lstatSync, realpathSync } from "node:fs";
5+import { isAbsolute } from "node:path";
36
47 import type { SqlControlPlaneRows } from "#persistence";
58 import {
9+ type LocalConfigSecretProvider,
10+ type LocalExecutorConfig,
611 type SecretMaterialPurpose,
712 SecretMaterialIdSchema,
813 type SecretRef,
@@ -10,6 +15,9 @@ import { · 1 unmodified line
1015 import * as Effect from "effect/Effect";
1116 import * as Option from "effect/Option";
1217
18+import { resolveConfigRelativePath } from "./local-config";
19+import { fromConfigSecretProviderId } from "./local-config-secrets";
20+
1321 export const ENV_SECRET_PROVIDER_ID = "env";
1422 export const PARAMS_SECRET_PROVIDER_ID = "params";
1523 export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
@@ -43,6 +51,8 @@ type SecretMaterialProviderRuntime = { · 27 unmodified lines
4351 env: NodeJS.ProcessEnv;
4452 dangerouslyAllowEnvSecrets: boolean;
4553 keychainServiceName: string;
54+ localConfig: LocalExecutorConfig | null;
55+ workspaceRoot: string | null;
4656 };
4757
4858 type SecretMaterialProvider = {
@@ -125,6 +135,7 @@ const runCommand = (input: { · 76 unmodified lines
125135 command: string;
126136 args: ReadonlyArray<string>;
127137 stdin?: string;
138+ env?: NodeJS.ProcessEnv;
128139 operation: string;
129140 }): Effect.Effect<SpawnResult, Error, never> =>
130141 Effect.tryPromise({
@@ -132,6 +143,7 @@ const runCommand = (input: { · 1 unmodified line
132143 new Promise<SpawnResult>((resolve, reject) => {
133144 const child = spawn(input.command, [...input.args], {
134145 stdio: "pipe",
146+ env: input.env,
135147 });
136148
137149 let stdout = "";
@@ -474,17 +486,169 @@ const createSecretMaterialProviderRuntime = (input: { · 336 unmodified lines
474486 rows: SqlControlPlaneRows;
475487 dangerouslyAllowEnvSecrets?: boolean;
476488 keychainServiceName?: string;
489+ localConfig?: LocalExecutorConfig | null;
490+ workspaceRoot?: string | null;
477491 }): SecretMaterialProviderRuntime => ({
478492 rows: input.rows,
479493 env: process.env,
480494 dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets),
481495 keychainServiceName: resolveKeychainServiceName(input.keychainServiceName),
496+ localConfig: input.localConfig ?? null,
497+ workspaceRoot: input.workspaceRoot ?? null,
482498 });
483499
500+const isRegularFilePath = (path: string, allowSymlink: boolean): boolean => {
501+ const stat = lstatSync(path);
502+ if (stat.isSymbolicLink()) {
503+ if (!allowSymlink) {
504+ return false;
505+ }
506+ const targetStat = lstatSync(realpathSync(path));
507+ return targetStat.isFile();
508+ }
509+ return stat.isFile();
510+};
511+
512+const ensureTrustedDir = (path: string, trustedDirs: readonly string[] | undefined): boolean => {
513+ if (!trustedDirs || trustedDirs.length === 0) {
514+ return true;
515+ }
516+
517+ const real = realpathSync(path);
518+ return trustedDirs.some((dir) => {
519+ const trusted = realpathSync(dir);
520+ return real === trusted || real.startsWith(`${trusted}/`);
521+ });
522+};
523+
524+const readFileSecretValue = (input: {
525+ provider: Extract<LocalConfigSecretProvider, { source: "file" }>;
526+ ref: SecretRef;
527+ workspaceRoot: string;
528+}): Effect.Effect<string, Error, never> =>
529+ Effect.tryPromise({
530+ try: async () => {
531+ const resolvedPath = resolveConfigRelativePath({
532+ path: input.provider.path,
533+ workspaceRoot: input.workspaceRoot,
534+ });
535+ const raw = await fs.readFile(resolvedPath, "utf8");
536+ const mode = input.provider.mode ?? "singleValue";
537+ if (mode === "singleValue") {
538+ return raw.trim();
539+ }
540+
541+ const parsed = JSON.parse(raw) as unknown;
542+ if (input.ref.handle.startsWith("/")) {
543+ const segments = input.ref.handle
544+ .split("/")
545+ .slice(1)
546+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
547+ let current: unknown = parsed;
548+ for (const segment of segments) {
549+ if (typeof current !== "object" || current === null || !(segment in current)) {
550+ throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`);
551+ }
552+ current = (current as Record<string, unknown>)[segment];
553+ }
554+ if (typeof current !== "string" || current.trim().length === 0) {
555+ throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`);
556+ }
557+ return current;
558+ }
559+
560+ if (typeof parsed !== "object" || parsed === null) {
561+ throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`);
562+ }
563+ const value = (parsed as Record<string, unknown>)[input.ref.handle];
564+ if (typeof value !== "string" || value.trim().length === 0) {
565+ throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`);
566+ }
567+ return value;
568+ },
569+ catch: (cause) =>
570+ cause instanceof Error ? cause : new Error(String(cause)),
571+ });
572+
573+const resolveConfiguredSecretProvider = (input: {
574+ ref: SecretRef;
575+ runtime: SecretMaterialProviderRuntime;
576+}): Effect.Effect<string, Error, never> => {
577+ const providerAlias = fromConfigSecretProviderId(input.ref.providerId);
578+ if (providerAlias === null) {
579+ return Effect.fail(new Error(`Unsupported secret provider: ${input.ref.providerId}`));
580+ }
581+
582+ const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias];
583+ if (!provider) {
584+ return Effect.fail(
585+ new Error(`Config secret provider "${providerAlias}" is not configured`),
586+ );
587+ }
588+ if (input.runtime.workspaceRoot === null) {
589+ return Effect.fail(
590+ new Error(`Config secret provider "${providerAlias}" requires a workspace root`),
591+ );
592+ }
593+
594+ if (provider.source === "env") {
595+ const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]);
596+ return value === null
597+ ? Effect.fail(new Error(`Environment variable ${input.ref.handle} is not set`))
598+ : Effect.succeed(value);
599+ }
600+
601+ if (provider.source === "file") {
602+ return readFileSecretValue({
603+ provider,
604+ ref: input.ref,
605+ workspaceRoot: input.runtime.workspaceRoot,
606+ });
607+ }
608+
609+ const command = provider.command.trim();
610+ if (!isAbsolute(command)) {
611+ return Effect.fail(
612+ new Error(`Exec secret provider command must be absolute: ${command}`),
613+ );
614+ }
615+ if (!isRegularFilePath(command, provider.allowSymlinkCommand ?? false)) {
616+ return Effect.fail(
617+ new Error(`Exec secret provider command is not an allowed regular file: ${command}`),
618+ );
619+ }
620+ if (!ensureTrustedDir(command, provider.trustedDirs)) {
621+ return Effect.fail(
622+ new Error(`Exec secret provider command is outside trustedDirs: ${command}`),
623+ );
624+ }
625+
626+ return runCommand({
627+ command,
628+ args: [...(provider.args ?? []), input.ref.handle],
629+ env: {
630+ ...input.runtime.env,
631+ ...(provider.env ?? {}),
632+ },
633+ operation: `config-secret.get:${providerAlias}`,
634+ }).pipe(
635+ Effect.flatMap((result) =>
636+ ensureCommandSuccess({
637+ result,
638+ operation: `config-secret.get:${providerAlias}`,
639+ message: "Failed resolving configured exec secret",
640+ }),
641+ ),
642+ Effect.map((result) => result.stdout.trimEnd()),
643+ );
644+};
645+
484646 export const createDefaultSecretMaterialResolver = (input: {
485647 rows: SqlControlPlaneRows;
486648 dangerouslyAllowEnvSecrets?: boolean;
487649 keychainServiceName?: string;
650+ localConfig?: LocalExecutorConfig | null;
651+ workspaceRoot?: string | null;
488652 }): ResolveSecretMaterial => {
489653 const providers = createSecretMaterialProviderRegistry();
490654 const runtime = createSecretMaterialProviderRuntime(input);
@@ -494,7 +658,20 @@ export const createDefaultSecretMaterialResolver = (input: { · 3 unmodified lines
494658 const provider = yield* getSecretMaterialProvider({
495659 providers,
496660 providerId: ref.providerId,
497− });
661+ }).pipe(
662+ Effect.catchAll(() =>
663+ fromConfigSecretProviderId(ref.providerId) !== null
664+ ? Effect.succeed(null)
665+ : Effect.fail(new Error(`Unsupported secret provider: ${ref.providerId}`)),
666+ ),
667+ );
668+
669+ if (provider === null) {
670+ return yield* resolveConfiguredSecretProvider({
671+ ref,
672+ runtime,
673+ });
674+ }
498675
499676 return yield* provider.resolve({
500677 ref,
packages/control-plane/src/runtime/source-adapters/mcp.ts+1−1
@@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines
187187 });
188188 });
189189
190−const materializationFromMcpManifestEntries = (input: {
190+export const materializationFromMcpManifestEntries = (input: {
191191 recipeRevisionId: SourceRecipeRevisionId;
192192 endpoint: string;
193193 manifestEntries: readonly McpToolManifestEntry[];
packages/control-plane/src/runtime/source-auth-service.test.ts+67−60
@@ -1,4 +1,7 @@
1+import { mkdtempSync } from "node:fs";
12 import { createServer } from "node:http";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
25
36 import { describe, expect, it } from "vitest";
47 import * as Schema from "effect/Schema";
@@ -26,6 +29,17 @@ import { · 21 unmodified lines
2629 createTerminalSourceAuthSessionPatch,
2730 } from "./source-auth-service";
2831 import { createLiveExecutionManager } from "./live-execution";
32+import {
33+ loadLocalExecutorConfig,
34+ resolveLocalWorkspaceContext,
35+} from "./local-config";
36+import {
37+ readLocalSourceArtifact,
38+} from "./local-source-artifacts";
39+import {
40+ RuntimeLocalWorkspaceService,
41+ type RuntimeLocalWorkspaceState,
42+} from "./local-runtime-context";
2943 import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
3044 import { resolveSourceAuthMaterial } from "./source-auth-material";
3145
@@ -163,6 +177,29 @@ const makePersistence = async (): Promise<SqlControlPlanePersistence> => · 131 unmodified lines
163177 }),
164178 );
165179
180+const makeRuntimeLocalWorkspaceState = async (
181+ workspaceId: ReturnType<typeof WorkspaceIdSchema.make>,
182+ accountId: ReturnType<typeof AccountIdSchema.make>,
183+): Promise<RuntimeLocalWorkspaceState> =>
184+ Effect.runPromise(
185+ Effect.gen(function* () {
186+ const workspaceRoot = mkdtempSync(
187+ join(tmpdir(), "executor-source-auth-service-"),
188+ );
189+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
190+ const loadedConfig = yield* loadLocalExecutorConfig(context);
191+
192+ return {
193+ context,
194+ installation: {
195+ workspaceId,
196+ accountId,
197+ },
198+ loadedConfig,
199+ } satisfies RuntimeLocalWorkspaceState;
200+ }),
201+ );
202+
166203 describe("source-auth-service", () => {
167204 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
168205
@@ -225,36 +262,20 @@ describe("source-auth-service", () => { · 56 unmodified lines
225262
226263 const persistence = await makePersistence();
227264 try {
228− const now = Date.now();
229265 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth");
230266 const accountId = AccountIdSchema.make("acc_google_oauth");
231−
232− await Effect.runPromise(
233− persistence.rows.organizations.insert({
234− id: "org_google_oauth" as any,
235− slug: "google-oauth",
236− name: "Google OAuth",
237− status: "active",
238− createdByAccountId: accountId,
239− createdAt: now,
240− updatedAt: now,
241− }),
242− );
243− await Effect.runPromise(
244− persistence.rows.workspaces.insert({
245− id: workspaceId,
246− organizationId: "org_google_oauth" as any,
247− name: "Workspace",
248− createdByAccountId: accountId,
249− createdAt: now,
250− updatedAt: now,
251− }),
267+ const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState(
268+ workspaceId,
269+ accountId,
252270 );
253271
254272 const service = createRuntimeSourceAuthService({
255273 rows: persistence.rows,
256274 liveExecutionManager: createLiveExecutionManager(),
257275 getLocalServerBaseUrl: () => "http://localhost:8788",
276+ localConfig: runtimeLocalWorkspace.loadedConfig.config,
277+ workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
278+ localWorkspaceState: runtimeLocalWorkspace,
258279 });
259280
260281 await withGoogleAuthTestServer({
@@ -290,7 +311,12 @@ describe("source-auth-service", () => { · 29 unmodified lines
290311 clientId: "google-test-client",
291312 clientSecret: "google-test-secret",
292313 },
293− }),
314+ }).pipe(
315+ Effect.provideService(
316+ RuntimeLocalWorkspaceService,
317+ runtimeLocalWorkspace,
318+ ),
319+ ),
294320 );
295321
296322 expect(addResult.kind).toBe("oauth_required");
@@ -461,22 +487,14 @@ describe("source-auth-service", () => { · 164 unmodified lines
461487 expect(Option.isNone(oldSecret)).toBe(true);
462488 }
463489
464− const recipeOperations = await Effect.runPromise(
465− persistence.rows.sources.getByWorkspaceAndId(
466− workspaceId,
467− connectedSource.id,
468− ),
469− );
470− expect(Option.isSome(recipeOperations)).toBe(true);
471− const storedSource = Option.getOrNull(recipeOperations)!;
472− const materializedOperations = await Effect.runPromise(
473− persistence.rows.sourceRecipeOperations.listByRevisionId(
474− storedSource.recipeRevisionId,
475− ),
476− );
477− expect(materializedOperations.length).toBeGreaterThan(0);
490+ const localArtifact = await Effect.runPromise(readLocalSourceArtifact({
491+ context: runtimeLocalWorkspace.context,
492+ sourceId: connectedSource.id,
493+ }));
494+ expect(localArtifact).not.toBeNull();
495+ expect(localArtifact?.operations.length).toBeGreaterThan(0);
478496 expect(
479− materializedOperations.some(
497+ localArtifact?.operations.some(
480498 (operation) => operation.toolId === "spreadsheets.values.get",
481499 ),
482500 ).toBe(true);
@@ -495,36 +513,20 @@ describe("source-auth-service", () => { · 12 unmodified lines
495513
496514 const persistence = await makePersistence();
497515 try {
498− const now = Date.now();
499516 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web");
500517 const accountId = AccountIdSchema.make("acc_google_oauth_web");
501−
502− await Effect.runPromise(
503− persistence.rows.organizations.insert({
504− id: "org_google_oauth_web" as any,
505− slug: "google-oauth-web",
506− name: "Google OAuth Web",
507− status: "active",
508− createdByAccountId: accountId,
509− createdAt: now,
510− updatedAt: now,
511− }),
512− );
513− await Effect.runPromise(
514− persistence.rows.workspaces.insert({
515− id: workspaceId,
516− organizationId: "org_google_oauth_web" as any,
517− name: "Workspace",
518− createdByAccountId: accountId,
519− createdAt: now,
520− updatedAt: now,
521− }),
518+ const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState(
519+ workspaceId,
520+ accountId,
522521 );
523522
524523 const service = createRuntimeSourceAuthService({
525524 rows: persistence.rows,
526525 liveExecutionManager: createLiveExecutionManager(),
527526 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
527+ localConfig: runtimeLocalWorkspace.loadedConfig.config,
528+ workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
529+ localWorkspaceState: runtimeLocalWorkspace,
528530 });
529531
530532 await withGoogleAuthTestServer({
@@ -565,6 +567,11 @@ describe("source-auth-service", () => { · 34 unmodified lines
565567 {
566568 baseUrl: "https://app.executor.dev",
567569 },
570+ ).pipe(
571+ Effect.provideService(
572+ RuntimeLocalWorkspaceService,
573+ runtimeLocalWorkspace,
574+ ),
568575 ),
569576 );
570577
packages/control-plane/src/runtime/source-auth-service.ts+105−62
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 AccountId,
1212 type CredentialSlot,
1313 ExecutionIdSchema,
14+ type LocalExecutorConfig,
1415 McpSourceAuthSessionDataJsonSchema,
1516 type McpSourceAuthSessionData,
1617 OAuth2PkceSourceAuthSessionDataJsonSchema,
@@ -43,6 +44,10 @@ import { · 26 unmodified lines
4344 LiveExecutionManagerService,
4445 type LiveExecutionManager,
4546 } from "./live-execution";
47+import {
48+ RuntimeLocalWorkspaceService,
49+ type RuntimeLocalWorkspaceState,
50+} from "./local-runtime-context";
4651 import {
4752 exchangeMcpOAuthAuthorizationCode,
4853 startMcpOAuthAuthorization,
@@ -1634,13 +1639,35 @@ export const createRuntimeSourceAuthService = (input: { · 1585 unmodified lines
16341639 rows: SqlControlPlaneRows;
16351640 liveExecutionManager: LiveExecutionManager;
16361641 getLocalServerBaseUrl?: () => string | undefined;
1642+ localConfig?: LocalExecutorConfig | null;
1643+ workspaceRoot?: string | null;
1644+ localWorkspaceState?: RuntimeLocalWorkspaceState;
16371645 }) => {
16381646 const resolveSecretMaterial = createDefaultSecretMaterialResolver({
16391647 rows: input.rows,
1648+ localConfig: input.localConfig,
1649+ workspaceRoot: input.workspaceRoot,
16401650 });
16411651 const storeSecretMaterial = createDefaultSecretMaterialStorer({
16421652 rows: input.rows,
16431653 });
1654+ const mirrorLocalSourceResult = (
1655+ result: ExecutorSourceAddResult,
1656+ ): Effect.Effect<ExecutorSourceAddResult, Error, never> =>
1657+ Effect.succeed(result);
1658+ const mirrorLocalMcpSourceResult = (
1659+ result: McpSourceConnectResult,
1660+ ): Effect.Effect<McpSourceConnectResult, Error, never> =>
1661+ Effect.succeed(result);
1662+ const provideLocalWorkspace = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
1663+ input.localWorkspaceState
1664+ ? effect.pipe(
1665+ Effect.provideService(
1666+ RuntimeLocalWorkspaceService,
1667+ input.localWorkspaceState,
1668+ ),
1669+ )
1670+ : effect;
16441671
16451672 return {
16461673 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,
@@ -1652,68 +1679,78 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
16521679 }),
16531680
16541681 getSourceById: ({ workspaceId, sourceId, actorAccountId }) =>
1655− loadSourceById(input.rows, {
1656− workspaceId,
1657− sourceId,
1658− actorAccountId,
1659− }),
1682+ provideLocalWorkspace(
1683+ loadSourceById(input.rows, {
1684+ workspaceId,
1685+ sourceId,
1686+ actorAccountId,
1687+ }),
1688+ ),
16601689
16611690 addExecutorSource: (sourceInput, options = undefined) =>
1662− sourceInput.kind === "google_discovery"
1663− ? addExecutorGoogleDiscoverySource({
1664− rows: input.rows,
1665− sourceInput,
1666− storeSecretMaterial,
1667− resolveSecretMaterial,
1668− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1669− baseUrl: options?.baseUrl,
1670− })
1671− : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
1672− ? addExecutorHttpSource({
1673− rows: input.rows,
1674− sourceInput: sourceInput as Extract<
1675− ExecutorAddSourceInput,
1676− { kind: "openapi" | "graphql" }
1677− >,
1678− storeSecretMaterial,
1679− resolveSecretMaterial,
1680− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1681− baseUrl: options?.baseUrl,
1682− })
1683− : connectMcpSourceInternal({
1684− rows: input.rows,
1685− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1686− workspaceId: sourceInput.workspaceId,
1687− actorAccountId: sourceInput.actorAccountId,
1688− executionId: sourceInput.executionId,
1689− interactionId: sourceInput.interactionId,
1690− endpoint: sourceInput.endpoint,
1691− name: sourceInput.name,
1692− namespace: sourceInput.namespace,
1693− mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation,
1694− baseUrl: options?.baseUrl,
1695− resolveSecretMaterial,
1696− }),
1691+ provideLocalWorkspace(
1692+ (sourceInput.kind === "google_discovery"
1693+ ? addExecutorGoogleDiscoverySource({
1694+ rows: input.rows,
1695+ sourceInput,
1696+ storeSecretMaterial,
1697+ resolveSecretMaterial,
1698+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1699+ baseUrl: options?.baseUrl,
1700+ })
1701+ : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
1702+ ? addExecutorHttpSource({
1703+ rows: input.rows,
1704+ sourceInput: sourceInput as Extract<
1705+ ExecutorAddSourceInput,
1706+ { kind: "openapi" | "graphql" }
1707+ >,
1708+ storeSecretMaterial,
1709+ resolveSecretMaterial,
1710+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1711+ baseUrl: options?.baseUrl,
1712+ })
1713+ : connectMcpSourceInternal({
1714+ rows: input.rows,
1715+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1716+ workspaceId: sourceInput.workspaceId,
1717+ actorAccountId: sourceInput.actorAccountId,
1718+ executionId: sourceInput.executionId,
1719+ interactionId: sourceInput.interactionId,
1720+ endpoint: sourceInput.endpoint,
1721+ name: sourceInput.name,
1722+ namespace: sourceInput.namespace,
1723+ mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation,
1724+ baseUrl: options?.baseUrl,
1725+ resolveSecretMaterial,
1726+ })).pipe(
1727+ Effect.flatMap(mirrorLocalSourceResult),
1728+ ),
1729+ ),
16971730
16981731 connectMcpSource: (sourceInput) =>
1699− connectMcpSourceInternal({
1700− rows: input.rows,
1701− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1702− workspaceId: sourceInput.workspaceId,
1703− actorAccountId: sourceInput.actorAccountId,
1704− sourceId: sourceInput.sourceId,
1705− executionId: null,
1706− interactionId: null,
1707− endpoint: sourceInput.endpoint,
1708− name: sourceInput.name,
1709− namespace: sourceInput.namespace,
1710− enabled: sourceInput.enabled,
1711− transport: sourceInput.transport,
1712− queryParams: sourceInput.queryParams,
1713− headers: sourceInput.headers,
1714− baseUrl: sourceInput.baseUrl,
1715− resolveSecretMaterial,
1716− }),
1732+ provideLocalWorkspace(
1733+ connectMcpSourceInternal({
1734+ rows: input.rows,
1735+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1736+ workspaceId: sourceInput.workspaceId,
1737+ actorAccountId: sourceInput.actorAccountId,
1738+ sourceId: sourceInput.sourceId,
1739+ executionId: null,
1740+ interactionId: null,
1741+ endpoint: sourceInput.endpoint,
1742+ name: sourceInput.name,
1743+ namespace: sourceInput.namespace,
1744+ enabled: sourceInput.enabled,
1745+ transport: sourceInput.transport,
1746+ queryParams: sourceInput.queryParams,
1747+ headers: sourceInput.headers,
1748+ baseUrl: sourceInput.baseUrl,
1749+ resolveSecretMaterial,
1750+ }).pipe(
1751+ Effect.flatMap(mirrorLocalMcpSourceResult),
1752+ ),
1753+ ),
17171754
17181755 startSourceOAuthSession: (oauthInput) =>
17191756 Effect.gen(function* () {
@@ -1781,7 +1818,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines
17811818 error,
17821819 errorDescription,
17831820 }) =>
1784− Effect.gen(function* () {
1821+ provideLocalWorkspace(Effect.gen(function* () {
17851822 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
17861823 if (Option.isNone(sessionOption)) {
17871824 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));
@@ -1890,7 +1927,7 @@ export const createRuntimeSourceAuthService = (input: { · 102 unmodified lines
18901927 sessionId: session.id,
18911928 auth,
18921929 } satisfies CompleteSourceOAuthSessionResult;
1893− }),
1930+ })),
18941931
18951932 completeSourceCredentialSetup: ({
18961933 workspaceId,
@@ -1901,7 +1938,7 @@ export const createRuntimeSourceAuthService = (input: { · 4 unmodified lines
19011938 error,
19021939 errorDescription,
19031940 }) =>
1904− Effect.gen(function* () {
1941+ provideLocalWorkspace(Effect.gen(function* () {
19051942 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
19061943 if (Option.isNone(sessionOption)) {
19071944 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));
@@ -2164,7 +2201,7 @@ export const createRuntimeSourceAuthService = (input: { · 256 unmodified lines
21642201 });
21652202
21662203 return connectedSource;
2167− }),
2204+ })),
21682205 } satisfies RuntimeSourceAuthServiceShape;
21692206 };
21702207
@@ -2176,6 +2213,9 @@ export class RuntimeSourceAuthServiceTag extends Context.Tag( · 5 unmodified lines
21762213
21772214 export const RuntimeSourceAuthServiceLive = (input: {
21782215 getLocalServerBaseUrl?: () => string | undefined;
2216+ localConfig?: LocalExecutorConfig | null;
2217+ workspaceRoot?: string | null;
2218+ localWorkspaceState?: RuntimeLocalWorkspaceState;
21792219 } = {}) =>
21802220 Layer.effect(
21812221 RuntimeSourceAuthServiceTag,
@@ -2187,6 +2227,9 @@ export const RuntimeSourceAuthServiceLive = (input: { · 5 unmodified lines
21872227 rows,
21882228 liveExecutionManager,
21892229 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
2230+ localConfig: input.localConfig,
2231+ workspaceRoot: input.workspaceRoot,
2232+ localWorkspaceState: input.localWorkspaceState,
21902233 });
21912234 }),
21922235 );
packages/control-plane/src/runtime/source-inspection.test.ts+150−118
@@ -1,11 +1,14 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
15 import { describe, expect, it } from "vitest";
26 import * as Effect from "effect/Effect";
37
48 import {
59 AccountIdSchema,
6− OrganizationIdSchema,
10+ type LocalConfigSource,
711 SourceIdSchema,
8− SourceRecipeIdSchema,
912 SourceRecipeRevisionIdSchema,
1013 SourceRecipeSchemaBundleIdSchema,
1114 WorkspaceIdSchema,
@@ -17,11 +20,27 @@ import { · 5 unmodified lines
1720 type SqlControlPlanePersistence,
1821 } from "#persistence";
1922
23+import {
24+ loadLocalExecutorConfig,
25+ resolveLocalWorkspaceContext,
26+ writeProjectLocalExecutorConfig,
27+} from "./local-config";
28+import {
29+ buildLocalSourceArtifact,
30+ writeLocalSourceArtifact,
31+} from "./local-source-artifacts";
2032 import {
2133 getSourceInspection,
2234 getSourceInspectionSchemaBundle,
2335 getSourceInspectionToolDetail,
2436 } from "./source-inspection";
37+import {
38+ RuntimeLocalWorkspaceService,
39+ type RuntimeLocalWorkspaceState,
40+} from "./local-runtime-context";
41+import {
42+ writeLocalWorkspaceState,
43+} from "./local-workspace-state";
2544 import { ControlPlaneStore } from "./store";
2645
2746 const makePersistence = () =>
@@ -31,30 +50,28 @@ const makePersistence = () => · 3 unmodified lines
3150 }),
3251 );
3352
34−const seedWorkspace = async (input: {
35− persistence: SqlControlPlanePersistence;
36− workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
37− organizationId: ReturnType<typeof OrganizationIdSchema.make>;
38− accountId: ReturnType<typeof AccountIdSchema.make>;
39−}) => {
40− await Effect.runPromise(input.persistence.rows.organizations.insert({
41− id: input.organizationId,
42− slug: `org-${input.organizationId}`,
43− name: `Org ${input.organizationId}`,
44− status: "active",
45− createdByAccountId: input.accountId,
46− createdAt: 1000,
47− updatedAt: 1000,
48− }));
49− await Effect.runPromise(input.persistence.rows.workspaces.insert({
50− id: input.workspaceId,
51− organizationId: input.organizationId,
52− name: `Workspace ${input.workspaceId}`,
53− createdByAccountId: input.accountId,
54− createdAt: 1000,
55− updatedAt: 1000,
56− }));
57−};
53+const makeRuntimeLocalWorkspaceState = (
54+ workspaceId: ReturnType<typeof WorkspaceIdSchema.make>,
55+ accountId: ReturnType<typeof AccountIdSchema.make>,
56+) =>
57+ Effect.runPromise(
58+ Effect.gen(function* () {
59+ const workspaceRoot = mkdtempSync(
60+ join(tmpdir(), "executor-source-inspection-"),
61+ );
62+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
63+ const loadedConfig = yield* loadLocalExecutorConfig(context);
64+
65+ return {
66+ context,
67+ installation: {
68+ workspaceId,
69+ accountId,
70+ },
71+ loadedConfig,
72+ } satisfies RuntimeLocalWorkspaceState;
73+ }),
74+ );
5875
5976 const makeSource = (input: {
6077 workspaceId: Source["workspaceId"];
@@ -133,16 +150,39 @@ const makeOperation = ( · 72 unmodified lines
133150 ...overrides,
134151 });
135152
153+const configSourceFromSource = (source: Source): LocalConfigSource => ({
154+ kind: "openapi",
155+ ...(source.name !== source.id ? { name: source.name } : {}),
156+ ...(source.namespace && source.namespace !== source.id
157+ ? { namespace: source.namespace }
158+ : {}),
159+ connection: {
160+ endpoint: source.endpoint,
161+ },
162+ binding: {
163+ specUrl:
164+ typeof source.binding.specUrl === "string"
165+ ? source.binding.specUrl
166+ : "https://example.com/openapi.json",
167+ defaultHeaders:
168+ (source.binding.defaultHeaders as Record<string, string> | null | undefined)
169+ ?? null,
170+ },
171+});
172+
136173 describe("source-inspection", () => {
137174 it("returns a lightweight inspection bundle and loads rich detail on demand", async () => {
138175 const persistence = await makePersistence();
139176 try {
140177 const workspaceId = WorkspaceIdSchema.make("ws_source_inspection");
141− const organizationId = OrganizationIdSchema.make("org_source_inspection");
142178 const accountId = AccountIdSchema.make("acc_source_inspection");
143179 const sourceId = SourceIdSchema.make("src_source_inspection");
144− const recipeId = SourceRecipeIdSchema.make("src_recipe_inspection");
145− const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_inspection");
180+ const recipeRevisionId =
181+ SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization");
182+ const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState(
183+ workspaceId,
184+ accountId,
185+ );
146186 const hugeDocument = JSON.stringify({
147187 openapi: "3.0.3",
148188 info: {
@@ -162,102 +202,82 @@ describe("source-inspection", () => { · 13 unmodified lines
162202 ),
163203 });
164204
165− await seedWorkspace({
166− persistence,
205+ const source = makeSource({
167206 workspaceId,
168− organizationId,
169− accountId,
207+ sourceId,
170208 });
171− await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
172− id: recipeId,
173− kind: "http_api",
174− adapterKey: "openapi",
175− providerKey: "generic_http",
176− name: "Cloudflare API",
177− summary: null,
178− visibility: "workspace",
179− latestRevisionId: recipeRevisionId,
180− createdAt: 1000,
181− updatedAt: 1000,
209+ await Effect.runPromise(writeProjectLocalExecutorConfig({
210+ context: runtimeLocalWorkspace.context,
211+ config: {
212+ sources: {
213+ [sourceId]: configSourceFromSource(source),
214+ },
215+ },
182216 }));
183− await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({
184− id: recipeRevisionId,
185− recipeId,
186− revisionNumber: 1,
187− sourceConfigJson: JSON.stringify({
188− kind: "openapi",
189− endpoint: "https://api.cloudflare.com/client/v4",
190− specUrl: "https://example.com/openapi.json",
191− }),
192− manifestJson: JSON.stringify({
193− sourceHash: "manifest_hash",
217+ await Effect.runPromise(writeLocalSourceArtifact({
218+ context: runtimeLocalWorkspace.context,
219+ sourceId,
220+ artifact: buildLocalSourceArtifact({
221+ source: {
222+ ...source,
223+ sourceHash: "manifest_hash",
224+ },
225+ materialization: {
226+ manifestJson: JSON.stringify({
227+ sourceHash: "manifest_hash",
228+ }),
229+ manifestHash: "manifest_hash",
230+ sourceHash: "manifest_hash",
231+ documents: [{
232+ id: "src_recipe_doc_inspection",
233+ recipeRevisionId,
234+ documentKind: "openapi",
235+ documentKey: "https://example.com/openapi.json",
236+ contentText: hugeDocument,
237+ contentHash: "doc_hash",
238+ fetchedAt: 1000,
239+ createdAt: 1000,
240+ updatedAt: 1000,
241+ }],
242+ schemaBundles: [{
243+ id: SourceRecipeSchemaBundleIdSchema.make(
244+ "src_recipe_bundle_inspection",
245+ ),
246+ recipeRevisionId,
247+ bundleKind: "json_schema_ref_map",
248+ refsJson: JSON.stringify({
249+ "#/components/schemas/Pagination": {
250+ type: "object",
251+ properties: {
252+ page: { type: "number" },
253+ },
254+ },
255+ }),
256+ contentHash: "bundle_hash",
257+ createdAt: 1000,
258+ updatedAt: 1000,
259+ }],
260+ operations: [makeOperation({
261+ recipeRevisionId,
262+ })],
263+ },
194264 }),
195− manifestHash: "manifest_hash",
196− materializationHash: "materialization_hash",
197− createdAt: 1000,
198− updatedAt: 1000,
199265 }));
200− await Effect.runPromise(persistence.rows.sourceRecipeDocuments.replaceForRevision({
201− recipeRevisionId,
202− documents: [{
203− id: "src_recipe_doc_inspection",
204− recipeRevisionId,
205− documentKind: "openapi",
206− documentKey: "https://example.com/openapi.json",
207− contentText: hugeDocument,
208− contentHash: "doc_hash",
209− fetchedAt: 1000,
210− createdAt: 1000,
211− updatedAt: 1000,
212− }],
213− }));
214− await Effect.runPromise(persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
215− recipeRevisionId,
216− bundles: [{
217− id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_inspection"),
218− recipeRevisionId,
219− bundleKind: "json_schema_ref_map",
220− refsJson: JSON.stringify({
221− "#/components/schemas/Pagination": {
222− type: "object",
223− properties: {
224− page: { type: "number" },
225− },
226− },
227− }),
228− contentHash: "bundle_hash",
229− createdAt: 1000,
230− updatedAt: 1000,
231− }],
232− }));
233− await Effect.runPromise(persistence.rows.sourceRecipeOperations.replaceForRevision({
234− recipeRevisionId,
235− operations: [makeOperation()],
236− }));
237− await Effect.runPromise(persistence.rows.sources.insert({
238− id: sourceId,
239− workspaceId,
240− recipeId,
241− recipeRevisionId,
242− name: "Cloudflare API",
243− kind: "openapi",
244− endpoint: "https://api.cloudflare.com/client/v4",
245− status: "connected",
246− enabled: true,
247− namespace: "cloudflare.api",
248− importAuthPolicy: "reuse_runtime",
249− bindingConfigJson: JSON.stringify({
250− adapterKey: "openapi",
266+ await Effect.runPromise(writeLocalWorkspaceState({
267+ context: runtimeLocalWorkspace.context,
268+ state: {
251269 version: 1,
252− payload: {
253− specUrl: "https://example.com/openapi.json",
254− defaultHeaders: null,
270+ sources: {
271+ [sourceId]: {
272+ status: "connected",
273+ lastError: null,
274+ sourceHash: "manifest_hash",
275+ createdAt: 1000,
276+ updatedAt: 1000,
277+ },
255278 },
256− }),
257− sourceHash: null,
258− lastError: null,
259− createdAt: 1000,
260− updatedAt: 1000,
279+ policies: {},
280+ },
261281 }));
262282
263283 const inspection = await Effect.runPromise(
@@ -266,6 +286,10 @@ describe("source-inspection", () => { · 2 unmodified lines
266286 sourceId,
267287 }).pipe(
268288 Effect.provideService(ControlPlaneStore, persistence.rows),
289+ Effect.provideService(
290+ RuntimeLocalWorkspaceService,
291+ runtimeLocalWorkspace,
292+ ),
269293 ),
270294 );
271295
@@ -282,6 +306,10 @@ describe("source-inspection", () => { · 10 unmodified lines
282306 toolPath: "cloudflare.api.zones.listZones",
283307 }).pipe(
284308 Effect.provideService(ControlPlaneStore, persistence.rows),
309+ Effect.provideService(
310+ RuntimeLocalWorkspaceService,
311+ runtimeLocalWorkspace,
312+ ),
285313 ),
286314 );
287315
@@ -300,6 +328,10 @@ describe("source-inspection", () => { · 12 unmodified lines
300328 schemaBundleId: "src_recipe_bundle_inspection",
301329 }).pipe(
302330 Effect.provideService(ControlPlaneStore, persistence.rows),
331+ Effect.provideService(
332+ RuntimeLocalWorkspaceService,
333+ runtimeLocalWorkspace,
334+ ),
303335 ),
304336 );
305337
packages/control-plane/src/runtime/source-inspection.ts+40−51
@@ -16,7 +16,7 @@ import { SourceRecipeSchemaBundleIdSchema } from "#schema"; · 15 unmodified lines
1616 import {
1717 ControlPlaneNotFoundError,
1818 ControlPlaneStorageError,
19−} from "#api";
19+} from "../api/errors";
2020 import * as Effect from "effect/Effect";
2121 import * as Option from "effect/Option";
2222
@@ -26,6 +26,7 @@ import { · 3 unmodified lines
2626 getSourceAdapterForOperation,
2727 } from "./source-adapters";
2828 import {
29+ loadSourceWithRecipe,
2930 recipeToolMetadata,
3031 recipeToolPath,
3132 } from "./source-recipes-runtime";
@@ -110,41 +111,29 @@ const loadSourceRecipeRecord = (input: { · 78 unmodified lines
110111 }) =>
111112 Effect.flatMap(ControlPlaneStore, (store) =>
112113 Effect.gen(function* () {
113− const sourceRecord = yield* sourceInspectOps.bundle.child("record").mapStorage(
114− store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
115− );
116− if (Option.isNone(sourceRecord)) {
117− return yield* Effect.fail(
118− sourceInspectOps.bundle.notFound(
119− "Source not found",
120− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
121− ),
122− );
123− }
124−
125− const source = yield* loadSourceById(store, {
114+ const recipe = yield* loadSourceWithRecipe({
115+ rows: store,
126116 workspaceId: input.workspaceId,
127117 sourceId: input.sourceId,
128118 }).pipe(
129119 Effect.mapError((cause) =>
130− sourceInspectOps.bundle.unknownStorage(
131− cause,
132− "Failed loading source",
133− ),
120+ cause instanceof Error && cause.message.startsWith("Source not found:")
121+ ? sourceInspectOps.bundle.notFound(
122+ "Source not found",
123+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
124+ )
125+ : sourceInspectOps.bundle.unknownStorage(
126+ cause,
127+ "Failed loading source recipe",
128+ ),
134129 ),
135130 );
136− const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage(
137− store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),
138− );
139− const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage(
140− store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),
141− );
142131
143132 return {
144− source,
145− operations,
146− schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
147− ?? schemaBundles[0]?.id
133+ source: recipe.source,
134+ operations: recipe.operations,
135+ schemaBundleId: recipe.schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
136+ ?? recipe.schemaBundles[0]?.id
148137 ?? null,
149138 };
150139 }),
@@ -403,27 +392,27 @@ export const getSourceInspectionSchemaBundle = (input: { · 252 unmodified lines
403392 }) =>
404393 Effect.flatMap(ControlPlaneStore, (store) =>
405394 Effect.gen(function* () {
406− const sourceRecord = yield* sourceInspectOps.tool.child("bundle_record").mapStorage(
407− store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
408− );
409− if (Option.isNone(sourceRecord)) {
410− return yield* Effect.fail(
411− sourceInspectOps.tool.notFound(
412− "Source not found",
413− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
414− ),
415− );
416− }
417−
418− const schemaBundle = yield* sourceInspectOps.tool.child("schema_bundle").mapStorage(
419− store.sourceRecipeSchemaBundles.getById(
420− SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),
395+ const recipe = yield* loadSourceWithRecipe({
396+ rows: store,
397+ workspaceId: input.workspaceId,
398+ sourceId: input.sourceId,
399+ }).pipe(
400+ Effect.mapError((cause) =>
401+ cause instanceof Error && cause.message.startsWith("Source not found:")
402+ ? sourceInspectOps.tool.notFound(
403+ "Source not found",
404+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
405+ )
406+ : sourceInspectOps.tool.unknownStorage(
407+ cause,
408+ "Failed loading source recipe",
409+ ),
421410 ),
422411 );
423− if (
424− Option.isNone(schemaBundle)
425− || schemaBundle.value.recipeRevisionId !== sourceRecord.value.recipeRevisionId
426− ) {
412+ const schemaBundle = recipe.schemaBundles.find(
413+ (candidate) => candidate.id === SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),
414+ );
415+ if (!schemaBundle) {
427416 return yield* Effect.fail(
428417 sourceInspectOps.tool.notFound(
429418 "Schema bundle not found",
@@ -433,10 +422,10 @@ export const getSourceInspectionSchemaBundle = (input: { · 3 unmodified lines
433422 }
434423
435424 return {
436− id: schemaBundle.value.id,
437− kind: schemaBundle.value.bundleKind,
438− hash: schemaBundle.value.contentHash,
439− refsJson: schemaBundle.value.refsJson,
425+ id: schemaBundle.id,
426+ kind: schemaBundle.bundleKind,
427+ hash: schemaBundle.contentHash,
428+ refsJson: schemaBundle.refsJson,
440429 } satisfies SourceInspectionSchemaBundle;
441430 }),
442431 ).pipe(
packages/control-plane/src/runtime/source-materialization.ts+82−7
@@ -2,18 +2,33 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line
22 import type {
33 AccountId,
44 Source,
5+ SourceStatus,
56 } from "#schema";
67 import * as Effect from "effect/Effect";
78
9+import {
10+ buildLocalSourceArtifact,
11+ writeLocalSourceArtifact,
12+} from "./local-source-artifacts";
13+import {
14+ requireRuntimeLocalWorkspace,
15+} from "./local-runtime-context";
16+import {
17+ loadLocalWorkspaceState,
18+ writeLocalWorkspaceState,
19+ type LocalWorkspaceState,
20+} from "./local-workspace-state";
821 import { resolveSourceAuthMaterial } from "./source-auth-material";
922 import {
1023 getSourceAdapterForSource,
1124 } from "./source-adapters";
12−import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp";
25+import {
26+ materializationFromMcpManifestEntries,
27+ persistMcpRecipeRevisionFromManifestEntries,
28+} from "./source-adapters/mcp";
1329 import type {
1430 ResolveSecretMaterial as ResolveSourceSecretMaterial,
1531 } from "./secret-material-providers";
16−import { persistRecipeMaterialization } from "./source-recipe-support";
1732
1833 const shouldIndexSource = (source: Source): boolean =>
1934 source.enabled
@@ -27,7 +42,28 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines
2742 resolveSecretMaterial: ResolveSourceSecretMaterial;
2843 }): Effect.Effect<void, Error, never> =>
2944 Effect.gen(function* () {
45+ const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId);
46+
3047 if (!shouldIndexSource(input.source)) {
48+ const state = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
49+ const existingSourceState = state.sources[input.source.id];
50+ const nextState: LocalWorkspaceState = {
51+ ...state,
52+ sources: {
53+ ...state.sources,
54+ [input.source.id]: {
55+ status: (input.source.enabled ? input.source.status : "draft") as SourceStatus,
56+ lastError: null,
57+ sourceHash: input.source.sourceHash,
58+ createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
59+ updatedAt: Date.now(),
60+ },
61+ },
62+ };
63+ yield* writeLocalWorkspaceState({
64+ context: runtimeLocalWorkspace.context,
65+ state: nextState,
66+ });
3167 return;
3268 }
3369
@@ -44,10 +80,33 @@ export const syncSourceMaterialization = (input: { · 10 unmodified lines
4480 resolveSecretMaterial: input.resolveSecretMaterial,
4581 }),
4682 });
47− yield* persistRecipeMaterialization({
48− rows: input.rows,
49− source: input.source,
50− materialization,
83+ yield* writeLocalSourceArtifact({
84+ context: runtimeLocalWorkspace.context,
85+ sourceId: input.source.id,
86+ artifact: buildLocalSourceArtifact({
87+ source: input.source,
88+ materialization,
89+ }),
90+ });
91+
92+ const state = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
93+ const existingSourceState = state.sources[input.source.id];
94+ const nextState: LocalWorkspaceState = {
95+ ...state,
96+ sources: {
97+ ...state.sources,
98+ [input.source.id]: {
99+ status: "connected",
100+ lastError: null,
101+ sourceHash: materialization.sourceHash,
102+ createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
103+ updatedAt: Date.now(),
104+ },
105+ },
106+ };
107+ yield* writeLocalWorkspaceState({
108+ context: runtimeLocalWorkspace.context,
109+ state: nextState,
51110 });
52111 });
53112
@@ -58,4 +117,20 @@ export const persistMcpRecipeMaterializationFromManifest = (input: { · 4 unmodified lines
58117 typeof persistMcpRecipeRevisionFromManifestEntries
59118 >[0]["manifestEntries"];
60119 }): Effect.Effect<void, Error, never> =>
61− persistMcpRecipeRevisionFromManifestEntries(input);
120+ Effect.gen(function* () {
121+ const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId);
122+ const materialization = materializationFromMcpManifestEntries({
123+ recipeRevisionId: "src_recipe_rev_materialization" as never,
124+ endpoint: input.source.endpoint,
125+ manifestEntries: input.manifestEntries,
126+ });
127+
128+ yield* writeLocalSourceArtifact({
129+ context: runtimeLocalWorkspace.context,
130+ sourceId: input.source.id,
131+ artifact: buildLocalSourceArtifact({
132+ source: input.source,
133+ materialization,
134+ }),
135+ });
136+ });
packages/control-plane/src/runtime/source-recipes-runtime.test.ts+362−244
@@ -1,3 +1,7 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
15 import { describe, expect, it } from "vitest";
26 import * as Effect from "effect/Effect";
37 import {
@@ -8,8 +12,8 @@ import { · 4 unmodified lines
812
913 import {
1014 AccountIdSchema,
11− OrganizationIdSchema,
1215 SourceIdSchema,
16+ type LocalConfigSource,
1317 SourceRecipeIdSchema,
1418 SourceRecipeRevisionIdSchema,
1519 WorkspaceIdSchema,
@@ -21,6 +25,22 @@ import { · 5 unmodified lines
2125 type SqlControlPlanePersistence,
2226 } from "#persistence";
2327
28+import {
29+ loadLocalExecutorConfig,
30+ resolveLocalWorkspaceContext,
31+ writeProjectLocalExecutorConfig,
32+} from "./local-config";
33+import {
34+ buildLocalSourceArtifact,
35+ writeLocalSourceArtifact,
36+} from "./local-source-artifacts";
37+import {
38+ RuntimeLocalWorkspaceService,
39+ type RuntimeLocalWorkspaceState,
40+} from "./local-runtime-context";
41+import {
42+ writeLocalWorkspaceState,
43+} from "./local-workspace-state";
2444 import {
2545 expandRecipeTools,
2646 loadSourceWithRecipe,
@@ -38,6 +58,36 @@ const makePersistence = () => · 11 unmodified lines
3858 }),
3959 );
4060
61+const makeRuntimeLocalWorkspaceState = (
62+ workspaceId: ReturnType<typeof WorkspaceIdSchema.make>,
63+) =>
64+ Effect.runPromise(
65+ Effect.gen(function* () {
66+ const workspaceRoot = mkdtempSync(
67+ join(tmpdir(), "executor-source-recipes-runtime-"),
68+ );
69+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
70+ const loadedConfig = yield* loadLocalExecutorConfig(context);
71+
72+ return {
73+ context,
74+ installation: {
75+ workspaceId,
76+ accountId: AccountIdSchema.make("acc_local_source_recipes"),
77+ },
78+ loadedConfig,
79+ } satisfies RuntimeLocalWorkspaceState;
80+ }),
81+ );
82+
83+const withRuntimeLocalWorkspace = <A, E>(
84+ effect: Effect.Effect<A, E, never>,
85+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
86+) =>
87+ effect.pipe(
88+ Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
89+ );
90+
4191 const openApiBindingConfigJson = (specUrl: string): string =>
4292 JSON.stringify({
4393 adapterKey: "openapi",
@@ -221,29 +271,128 @@ const makeMcpOperation = ( · 177 unmodified lines
221271 ...overrides,
222272 });
223273
224−const seedWorkspace = async (input: {
225− persistence: SqlControlPlanePersistence;
226− workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
227− organizationId: ReturnType<typeof OrganizationIdSchema.make>;
228− accountId: ReturnType<typeof AccountIdSchema.make>;
274+const configSourceFromSource = (source: Source): LocalConfigSource => {
275+ switch (source.kind) {
276+ case "openapi":
277+ return {
278+ kind: "openapi" as const,
279+ ...(source.name !== source.id ? { name: source.name } : {}),
280+ ...(source.namespace && source.namespace !== source.id
281+ ? { namespace: source.namespace }
282+ : {}),
283+ ...(source.enabled ? {} : { enabled: false }),
284+ connection: {
285+ endpoint: source.endpoint,
286+ },
287+ binding: {
288+ specUrl:
289+ typeof source.binding.specUrl === "string"
290+ ? source.binding.specUrl
291+ : "https://example.com/openapi.json",
292+ defaultHeaders:
293+ (source.binding.defaultHeaders as Record<string, string> | null | undefined)
294+ ?? null,
295+ },
296+ };
297+ case "graphql":
298+ return {
299+ kind: "graphql" as const,
300+ ...(source.name !== source.id ? { name: source.name } : {}),
301+ ...(source.namespace && source.namespace !== source.id
302+ ? { namespace: source.namespace }
303+ : {}),
304+ ...(source.enabled ? {} : { enabled: false }),
305+ connection: {
306+ endpoint: source.endpoint,
307+ },
308+ binding: {
309+ defaultHeaders:
310+ (source.binding.defaultHeaders as Record<string, string> | null | undefined)
311+ ?? null,
312+ },
313+ };
314+ case "mcp":
315+ return {
316+ kind: "mcp" as const,
317+ ...(source.name !== source.id ? { name: source.name } : {}),
318+ ...(source.namespace && source.namespace !== source.id
319+ ? { namespace: source.namespace }
320+ : {}),
321+ ...(source.enabled ? {} : { enabled: false }),
322+ connection: {
323+ endpoint: source.endpoint,
324+ },
325+ binding: {
326+ transport:
327+ (source.binding.transport as "auto" | "streamable-http" | "sse" | null | undefined)
328+ ?? null,
329+ queryParams:
330+ (source.binding.queryParams as Record<string, string> | null | undefined)
331+ ?? null,
332+ headers:
333+ (source.binding.headers as Record<string, string> | null | undefined)
334+ ?? null,
335+ },
336+ };
337+ default:
338+ throw new Error(`Unsupported source kind in test fixture: ${source.kind}`);
339+ }
340+};
341+
342+const seedLocalWorkspaceSources = async (input: {
343+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState;
344+ fixtures: ReadonlyArray<{
345+ source: Source;
346+ materialization: {
347+ manifestJson: string | null;
348+ manifestHash: string | null;
349+ sourceHash: string | null;
350+ documents: ReadonlyArray<any>;
351+ schemaBundles: ReadonlyArray<any>;
352+ operations: ReadonlyArray<StoredSourceRecipeOperationRecord>;
353+ };
354+ }>;
229355 }) => {
230− const now = 1000;
231− await Effect.runPromise(input.persistence.rows.organizations.insert({
232− id: input.organizationId,
233− slug: `org-${input.organizationId}`,
234− name: `Org ${input.organizationId}`,
235− status: "active",
236− createdByAccountId: input.accountId,
237− createdAt: now,
238− updatedAt: now,
356+ await Effect.runPromise(writeProjectLocalExecutorConfig({
357+ context: input.runtimeLocalWorkspace.context,
358+ config: {
359+ sources: Object.fromEntries(
360+ input.fixtures.map(({ source }) => [source.id, configSourceFromSource(source)]),
361+ ),
362+ },
239363 }));
240− await Effect.runPromise(input.persistence.rows.workspaces.insert({
241− id: input.workspaceId,
242− organizationId: input.organizationId,
243− name: `Workspace ${input.workspaceId}`,
244− createdByAccountId: input.accountId,
245− createdAt: now,
246− updatedAt: now,
364+
365+ await Promise.all(
366+ input.fixtures.map(({ source, materialization }) =>
367+ Effect.runPromise(writeLocalSourceArtifact({
368+ context: input.runtimeLocalWorkspace.context,
369+ sourceId: source.id,
370+ artifact: buildLocalSourceArtifact({
371+ source,
372+ materialization,
373+ }),
374+ })),
375+ ),
376+ );
377+
378+ await Effect.runPromise(writeLocalWorkspaceState({
379+ context: input.runtimeLocalWorkspace.context,
380+ state: {
381+ version: 1,
382+ sources: Object.fromEntries(
383+ input.fixtures.map(({ source }) => [
384+ source.id,
385+ {
386+ status: source.status,
387+ lastError: source.lastError,
388+ sourceHash: source.sourceHash,
389+ createdAt: source.createdAt,
390+ updatedAt: source.updatedAt,
391+ },
392+ ]),
393+ ),
394+ policies: {},
395+ },
247396 }));
248397 };
249398
@@ -432,10 +581,8 @@ describe("source-recipes-runtime", () => { · 182 unmodified lines
432581 const persistence = await makePersistence();
433582 try {
434583 const workspaceId = WorkspaceIdSchema.make("ws_shared_revision");
435− const organizationId = OrganizationIdSchema.make("org_shared_revision");
436− const accountId = AccountIdSchema.make("acc_shared_revision");
437− const recipeId = SourceRecipeIdSchema.make("src_recipe_shared_revision");
438− const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_shared_revision");
584+ const runtimeLocalWorkspace =
585+ await makeRuntimeLocalWorkspaceState(workspaceId);
439586 const openApiDocument = JSON.stringify({
440587 openapi: "3.0.3",
441588 info: {
@@ -475,172 +622,115 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines
475622 definition,
476623 });
477624
478− await seedWorkspace({
479− persistence,
480− workspaceId,
481− organizationId,
482− accountId,
625+ await seedLocalWorkspaceSources({
626+ runtimeLocalWorkspace,
627+ fixtures: ["GitHub One", "GitHub Two"].map((name, index) => ({
628+ source: makeSource({
629+ id: SourceIdSchema.make(`src_shared_revision_${index}`),
630+ workspaceId,
631+ name,
632+ sourceHash: manifest.sourceHash,
633+ createdAt: 1000 + index,
634+ updatedAt: 1000 + index,
635+ }),
636+ materialization: {
637+ manifestJson: JSON.stringify(manifest),
638+ manifestHash: manifest.sourceHash,
639+ sourceHash: manifest.sourceHash,
640+ documents: [{
641+ id: `src_recipe_doc_shared_revision_${index}`,
642+ recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"),
643+ documentKind: "openapi",
644+ documentKey: "https://api.github.com/openapi.json",
645+ contentText: openApiDocument,
646+ contentHash: manifest.sourceHash,
647+ fetchedAt: 1000,
648+ createdAt: 1000,
649+ updatedAt: 1000,
650+ }],
651+ schemaBundles: [],
652+ operations: [makeOperation({
653+ id: `src_recipe_op_shared_revision_${index}`,
654+ recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"),
655+ operationKey: definition.toolId,
656+ toolId: definition.toolId,
657+ title: definition.name,
658+ description: definition.description,
659+ searchText: `${definition.toolId} ${definition.name}`.toLowerCase(),
660+ inputSchemaJson: presentation.inputSchemaJson ?? null,
661+ outputSchemaJson: presentation.outputSchemaJson ?? null,
662+ providerDataJson: presentation.providerDataJson,
663+ })],
664+ },
665+ })),
483666 });
484− await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
485− id: recipeId,
486− kind: "http_api",
487− adapterKey: "openapi",
488− providerKey: "generic_http",
489− name: "GitHub",
490− summary: null,
491− visibility: "workspace",
492− latestRevisionId: recipeRevisionId,
493− createdAt: 1000,
494− updatedAt: 1000,
495− }));
496− await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({
497− id: recipeRevisionId,
498− recipeId,
499− revisionNumber: 1,
500− sourceConfigJson: JSON.stringify({
501− kind: "openapi",
502− endpoint: "https://api.github.com",
503− specUrl: "https://api.github.com/openapi.json",
504− defaultHeaders: null,
505− }),
506− manifestJson: JSON.stringify(manifest),
507− manifestHash: manifest.sourceHash,
508− materializationHash: manifest.sourceHash,
509− createdAt: 1000,
510− updatedAt: 1000,
511− }));
512− await Effect.runPromise(persistence.rows.sourceRecipeDocuments.replaceForRevision({
513− recipeRevisionId,
514− documents: [{
515− id: "src_recipe_doc_shared_revision",
516− recipeRevisionId,
517− documentKind: "openapi",
518− documentKey: "https://api.github.com/openapi.json",
519− contentText: openApiDocument,
520− contentHash: manifest.sourceHash,
521− fetchedAt: 1000,
522− createdAt: 1000,
523− updatedAt: 1000,
524− }],
525− }));
526− await Effect.runPromise(persistence.rows.sourceRecipeOperations.replaceForRevision({
527− recipeRevisionId,
528− operations: [makeOperation({
529− id: "src_recipe_op_shared_revision",
530− recipeRevisionId,
531− operationKey: definition.toolId,
532− toolId: definition.toolId,
533− title: definition.name,
534− description: definition.description,
535− searchText: `${definition.toolId} ${definition.name}`.toLowerCase(),
536− inputSchemaJson: presentation.inputSchemaJson ?? null,
537− outputSchemaJson: presentation.outputSchemaJson ?? null,
538− providerDataJson: presentation.providerDataJson,
539− })],
540− }));
541667
542− for (const [index, name] of ["GitHub One", "GitHub Two"].entries()) {
543− await Effect.runPromise(persistence.rows.sources.insert({
544− id: SourceIdSchema.make(`src_shared_revision_${index}`),
545− workspaceId,
546− recipeId,
547− recipeRevisionId,
548− name,
549− kind: "openapi",
550− endpoint: "https://api.github.com",
551− status: "connected",
552− enabled: true,
553− namespace: "github",
554− importAuthPolicy: "reuse_runtime",
555− bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
556− sourceHash: manifest.sourceHash,
557− lastError: null,
558− createdAt: 1000 + index,
559− updatedAt: 1000 + index,
560− }));
561− }
562−
563− const recipes = await Effect.runPromise(loadWorkspaceSourceRecipes({
564− rows: persistence.rows,
565− workspaceId,
566− }));
668+ const recipes = await Effect.runPromise(
669+ withRuntimeLocalWorkspace(
670+ loadWorkspaceSourceRecipes({
671+ rows: persistence.rows,
672+ workspaceId,
673+ }),
674+ runtimeLocalWorkspace,
675+ ),
676+ );
567677
568678 expect(recipes).toHaveLength(2);
569679 expect(recipes[0]?.documents).toHaveLength(1);
570680 expect(recipes[0]?.operations).toHaveLength(1);
571− expect(recipes[0]?.documents).toBe(recipes[1]?.documents);
572− expect(recipes[0]?.operations).toBe(recipes[1]?.operations);
681+ expect(recipes[0]?.documents[0]?.contentText).toBe(
682+ recipes[1]?.documents[0]?.contentText,
683+ );
684+ expect(recipes[0]?.operations[0]?.providerDataJson).toBe(
685+ recipes[1]?.operations[0]?.providerDataJson,
686+ );
573687 } finally {
574688 await persistence.close();
575689 }
576− });
690+ }, 60_000);
577691
578692 it("loads sources with empty recipe documents and operations", async () => {
579693 const persistence = await makePersistence();
580694 try {
581695 const workspaceId = WorkspaceIdSchema.make("ws_empty_recipe_rows");
582− const organizationId = OrganizationIdSchema.make("org_empty_recipe_rows");
583− const accountId = AccountIdSchema.make("acc_empty_recipe_rows");
584696 const sourceId = SourceIdSchema.make("src_empty_recipe_rows");
585− const recipeId = SourceRecipeIdSchema.make("src_recipe_empty_recipe_rows");
586− const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_empty_recipe_rows");
587−
588− await seedWorkspace({
589− persistence,
590− workspaceId,
591− organizationId,
592− accountId,
697+ const runtimeLocalWorkspace =
698+ await makeRuntimeLocalWorkspaceState(workspaceId);
699+
700+ await seedLocalWorkspaceSources({
701+ runtimeLocalWorkspace,
702+ fixtures: [{
703+ source: makeSource({
704+ id: sourceId,
705+ workspaceId,
706+ name: "GraphQL Demo",
707+ kind: "graphql",
708+ endpoint: "https://example.com/graphql",
709+ namespace: "graphql",
710+ binding: {
711+ defaultHeaders: null,
712+ },
713+ }),
714+ materialization: {
715+ manifestJson: null,
716+ manifestHash: null,
717+ sourceHash: null,
718+ documents: [],
719+ schemaBundles: [],
720+ operations: [],
721+ },
722+ }],
593723 });
594− await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
595− id: recipeId,
596− kind: "http_api",
597− adapterKey: "graphql",
598− providerKey: "generic_graphql",
599− name: "GraphQL Demo",
600− summary: null,
601− visibility: "workspace",
602− latestRevisionId: recipeRevisionId,
603− createdAt: 1000,
604− updatedAt: 1000,
605− }));
606− await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({
607− id: recipeRevisionId,
608− recipeId,
609− revisionNumber: 1,
610− sourceConfigJson: JSON.stringify({
611− kind: "graphql",
612− endpoint: "https://example.com/graphql",
613− defaultHeaders: null,
614− }),
615− manifestJson: null,
616− manifestHash: null,
617− materializationHash: null,
618− createdAt: 1000,
619− updatedAt: 1000,
620− }));
621− await Effect.runPromise(persistence.rows.sources.insert({
622− id: sourceId,
623− workspaceId,
624− recipeId,
625− recipeRevisionId,
626− name: "GraphQL Demo",
627− kind: "graphql",
628− endpoint: "https://example.com/graphql",
629− status: "connected",
630− enabled: true,
631− namespace: "graphql",
632− importAuthPolicy: "reuse_runtime",
633− bindingConfigJson: graphqlBindingConfigJson(),
634− sourceHash: null,
635− lastError: null,
636− createdAt: 1000,
637− updatedAt: 1000,
638− }));
639724
640− const recipes = await Effect.runPromise(loadWorkspaceSourceRecipes({
641− rows: persistence.rows,
642− workspaceId,
643− }));
725+ const recipes = await Effect.runPromise(
726+ withRuntimeLocalWorkspace(
727+ loadWorkspaceSourceRecipes({
728+ rows: persistence.rows,
729+ workspaceId,
730+ }),
731+ runtimeLocalWorkspace,
732+ ),
733+ );
644734
645735 expect(recipes).toHaveLength(1);
646736 expect(recipes[0]?.documents).toEqual([]);
@@ -649,89 +739,117 @@ describe("source-recipes-runtime", () => { · 2 unmodified lines
649739 } finally {
650740 await persistence.close();
651741 }
652− });
742+ }, 60_000);
653743
654744 it("fails clearly when loading a missing source, missing revision, or invalid manifest", async () => {
655745 const persistence = await makePersistence();
656746 try {
657− await expect(Effect.runPromise(loadSourceWithRecipe({
658− rows: persistence.rows,
659− workspaceId: WorkspaceIdSchema.make("ws_missing_source"),
660− sourceId: SourceIdSchema.make("src_missing_source"),
661− }))).rejects.toThrow("Source not found");
747+ const missingWorkspaceId = WorkspaceIdSchema.make("ws_missing_source");
748+ const missingRuntimeLocalWorkspace =
749+ await makeRuntimeLocalWorkspaceState(missingWorkspaceId);
750+
751+ await expect(
752+ Effect.runPromise(
753+ withRuntimeLocalWorkspace(
754+ loadSourceWithRecipe({
755+ rows: persistence.rows,
756+ workspaceId: missingWorkspaceId,
757+ sourceId: SourceIdSchema.make("src_missing_source"),
758+ }),
759+ missingRuntimeLocalWorkspace,
760+ ),
761+ ),
762+ ).rejects.toThrow("Source not found");
662763
663764 const workspaceId = WorkspaceIdSchema.make("ws_bad_recipe_runtime");
664− const organizationId = OrganizationIdSchema.make("org_bad_recipe_runtime");
665− const accountId = AccountIdSchema.make("acc_bad_recipe_runtime");
666765 const sourceId = SourceIdSchema.make("src_bad_recipe_runtime");
667− const recipeId = SourceRecipeIdSchema.make("src_recipe_bad_recipe_runtime");
668− const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bad_recipe_runtime");
669−
670− await seedWorkspace({
671− persistence,
672− workspaceId,
673− organizationId,
674− accountId,
675− });
676− await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
677− id: recipeId,
678− kind: "http_api",
679− adapterKey: "openapi",
680− providerKey: "generic_http",
681− name: "Broken GitHub",
682− summary: null,
683− visibility: "workspace",
684− latestRevisionId: recipeRevisionId,
685− createdAt: 1000,
686− updatedAt: 1000,
766+ const runtimeLocalWorkspace =
767+ await makeRuntimeLocalWorkspaceState(workspaceId);
768+
769+ await Effect.runPromise(writeProjectLocalExecutorConfig({
770+ context: runtimeLocalWorkspace.context,
771+ config: {
772+ sources: {
773+ [sourceId]: configSourceFromSource(
774+ makeSource({
775+ id: sourceId,
776+ workspaceId,
777+ name: "Broken GitHub",
778+ }),
779+ ),
780+ },
781+ },
687782 }));
688− await Effect.runPromise(persistence.rows.sources.insert({
689− id: sourceId,
690− workspaceId,
691− recipeId,
692− recipeRevisionId,
693− name: "Broken GitHub",
694− kind: "openapi",
695− endpoint: "https://api.github.com",
696− status: "connected",
697− enabled: true,
698− namespace: "github",
699− importAuthPolicy: "reuse_runtime",
700− bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
701− sourceHash: null,
702− lastError: null,
703− createdAt: 1000,
704− updatedAt: 1000,
783+ await Effect.runPromise(writeLocalWorkspaceState({
784+ context: runtimeLocalWorkspace.context,
785+ state: {
786+ version: 1,
787+ sources: {
788+ [sourceId]: {
789+ status: "connected",
790+ lastError: null,
791+ sourceHash: null,
792+ createdAt: 1000,
793+ updatedAt: 1000,
794+ },
795+ },
796+ policies: {},
797+ },
705798 }));
706799
707− await expect(Effect.runPromise(loadSourceWithRecipe({
708− rows: persistence.rows,
800+ await expect(
801+ Effect.runPromise(
802+ withRuntimeLocalWorkspace(
803+ loadSourceWithRecipe({
804+ rows: persistence.rows,
805+ workspaceId,
806+ sourceId,
807+ }),
808+ runtimeLocalWorkspace,
809+ ),
810+ ),
811+ ).rejects.toThrow("Recipe artifact missing");
812+
813+ const invalidManifestSource = makeSource({
814+ id: sourceId,
709815 workspaceId,
816+ name: "Broken GitHub",
817+ });
818+ const invalidArtifact = buildLocalSourceArtifact({
819+ source: invalidManifestSource,
820+ materialization: {
821+ manifestJson: "{}",
822+ manifestHash: "manifest_hash_invalid",
823+ sourceHash: "manifest_hash_invalid",
824+ documents: [],
825+ schemaBundles: [],
826+ operations: [],
827+ },
828+ });
829+ await Effect.runPromise(writeLocalSourceArtifact({
830+ context: runtimeLocalWorkspace.context,
710831 sourceId,
711− }))).rejects.toThrow("Recipe revision missing");
712−
713− await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({
714− id: recipeRevisionId,
715− recipeId,
716− revisionNumber: 1,
717− sourceConfigJson: JSON.stringify({
718− kind: "openapi",
719− endpoint: "https://api.github.com",
720− specUrl: "https://api.github.com/openapi.json",
721− defaultHeaders: null,
722− }),
723− manifestJson: "{bad-json",
724− manifestHash: null,
725− materializationHash: null,
726− createdAt: 1000,
727− updatedAt: 1000,
832+ artifact: {
833+ ...invalidArtifact,
834+ revision: {
835+ ...invalidArtifact.revision,
836+ manifestJson: "{bad-json",
837+ },
838+ },
728839 }));
729840
730− await expect(Effect.runPromise(loadSourceWithRecipe({
731− rows: persistence.rows,
732− workspaceId,
733− sourceId,
734− }))).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`);
841+ await expect(
842+ Effect.runPromise(
843+ withRuntimeLocalWorkspace(
844+ loadSourceWithRecipe({
845+ rows: persistence.rows,
846+ workspaceId,
847+ sourceId,
848+ }),
849+ runtimeLocalWorkspace,
850+ ),
851+ ),
852+ ).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`);
735853 } finally {
736854 await persistence.close();
737855 }
packages/control-plane/src/runtime/source-recipes-runtime.ts+116−265
@@ -13,8 +13,14 @@ import type { · 12 unmodified lines
1313 WorkspaceId,
1414 } from "#schema";
1515 import * as Effect from "effect/Effect";
16−import * as Option from "effect/Option";
1716
17+import {
18+ readLocalSourceArtifact,
19+} from "./local-source-artifacts";
20+import { LocalSourceArtifactMissingError } from "./local-errors";
21+import {
22+ requireRuntimeLocalWorkspace,
23+} from "./local-runtime-context";
1824 import { namespaceFromSourceName } from "./source-names";
1925 import {
2026 getSourceAdapterForOperation,
@@ -156,71 +162,56 @@ export const loadWorkspaceSourceRecipes = (input: { · 135 unmodified lines
156162 actorAccountId?: AccountId | null;
157163 }): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> =>
158164 Effect.gen(function* () {
159− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
165+ const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId);
160166 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
161167 actorAccountId: input.actorAccountId,
162168 });
163−
164− const sourceById = new Map(sources.map((source) => [source.id, source]));
165− const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
166− const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
167−
168− const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds);
169− const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
170− const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);
171− const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds);
172− const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
173− for (const document of documents) {
174− const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
175− existing.push(document);
176− documentsByRevisionId.set(document.recipeRevisionId, existing);
177− }
178− const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
179− for (const schemaBundle of schemaBundles) {
180− const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
181− existing.push(schemaBundle);
182− schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
183− }
184− const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds);
185− const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
186− for (const operation of operations) {
187− const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];
188− existing.push(operation);
189− operationsByRevisionId.set(operation.recipeRevisionId, existing);
190− }
191−
192− return yield* Effect.forEach(relevantSourceRecords, (sourceRecord) =>
169+ const localRecipes = yield* Effect.forEach(sources, (source) =>
193170 Effect.gen(function* () {
194− const source = sourceById.get(sourceRecord.id);
195− if (!source) {
196− return yield* Effect.fail(
197− new Error(`Projected source missing for ${sourceRecord.id}`),
198− );
171+ const artifact = yield* readLocalSourceArtifact({
172+ context: runtimeLocalWorkspace.context,
173+ sourceId: source.id,
174+ });
175+ if (artifact === null) {
176+ return null;
199177 }
200178
201− const revision = revisionById.get(sourceRecord.recipeRevisionId);
202− if (!revision) {
203− return yield* Effect.fail(
204− new Error(`Recipe revision missing for source ${sourceRecord.id}`),
205− );
206− }
179+ const sourceRecord: StoredSourceRecord = {
180+ id: source.id,
181+ workspaceId: source.workspaceId,
182+ recipeId: artifact.recipeId,
183+ recipeRevisionId: artifact.revision.id,
184+ name: source.name,
185+ kind: source.kind,
186+ endpoint: source.endpoint,
187+ status: source.status,
188+ enabled: source.enabled,
189+ namespace: source.namespace,
190+ importAuthPolicy: source.importAuthPolicy,
191+ bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
192+ sourceHash: source.sourceHash,
193+ lastError: source.lastError,
194+ createdAt: source.createdAt,
195+ updatedAt: source.updatedAt,
196+ };
207197
208198 const manifest = yield* parseManifestForRecipe({
209199 source,
210− revision,
200+ revision: artifact.revision,
211201 });
212202
213203 return {
214204 source,
215205 sourceRecord,
216− revision,
217− documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
218− schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
219− operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
206+ revision: artifact.revision,
207+ documents: artifact.documents,
208+ schemaBundles: artifact.schemaBundles,
209+ operations: artifact.operations,
220210 manifest,
221211 } satisfies LoadedSourceRecipe;
222212 }),
223213 );
214+ return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null);
224215 });
225216
226217 export const loadSourceWithRecipe = (input: {
@@ -228,46 +219,57 @@ export const loadSourceWithRecipe = (input: { · 1 unmodified line
228219 workspaceId: WorkspaceId;
229220 sourceId: Source["id"];
230221 actorAccountId?: AccountId | null;
231−}): Effect.Effect<LoadedSourceRecipe, Error, never> =>
222+}): Effect.Effect<LoadedSourceRecipe, Error | LocalSourceArtifactMissingError, never> =>
232223 Effect.gen(function* () {
233− const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
234− input.workspaceId,
235− input.sourceId,
236− );
237− if (Option.isNone(sourceRecord)) {
238− return yield* Effect.fail(
239− new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
240− );
241− }
242−
224+ const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId);
243225 const source = yield* loadSourceById(input.rows, {
244226 workspaceId: input.workspaceId,
245227 sourceId: input.sourceId,
246228 actorAccountId: input.actorAccountId,
247229 });
248− const revision = yield* input.rows.sourceRecipeRevisions.getById(sourceRecord.value.recipeRevisionId);
249− if (Option.isNone(revision)) {
230+ const artifact = yield* readLocalSourceArtifact({
231+ context: runtimeLocalWorkspace.context,
232+ sourceId: source.id,
233+ });
234+ if (artifact === null) {
250235 return yield* Effect.fail(
251− new Error(`Recipe revision missing for source ${input.sourceId}`),
236+ new LocalSourceArtifactMissingError({
237+ message: `Recipe artifact missing for source ${input.sourceId}`,
238+ sourceId: input.sourceId,
239+ }),
252240 );
253241 }
254− const [documents, schemaBundles, operations, manifest] = yield* Effect.all([
255− input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId),
256− input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),
257− input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),
258− parseManifestForRecipe({
259− source,
260− revision: revision.value,
261− }),
262− ]);
242+
243+ const sourceRecord: StoredSourceRecord = {
244+ id: source.id,
245+ workspaceId: source.workspaceId,
246+ recipeId: artifact.recipeId,
247+ recipeRevisionId: artifact.revision.id,
248+ name: source.name,
249+ kind: source.kind,
250+ endpoint: source.endpoint,
251+ status: source.status,
252+ enabled: source.enabled,
253+ namespace: source.namespace,
254+ importAuthPolicy: source.importAuthPolicy,
255+ bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
256+ sourceHash: source.sourceHash,
257+ lastError: source.lastError,
258+ createdAt: source.createdAt,
259+ updatedAt: source.updatedAt,
260+ };
261+ const manifest = yield* parseManifestForRecipe({
262+ source,
263+ revision: artifact.revision,
264+ });
263265
264266 return {
265267 source,
266− sourceRecord: sourceRecord.value,
267− revision: revision.value,
268− documents,
269− schemaBundles,
270− operations,
268+ sourceRecord,
269+ revision: artifact.revision,
270+ documents: artifact.documents,
271+ schemaBundles: artifact.schemaBundles,
272+ operations: artifact.operations,
271273 manifest,
272274 } satisfies LoadedSourceRecipe;
273275 });
@@ -339,94 +341,26 @@ export const loadWorkspaceSourceRecipeToolIndex = (input: { · 65 unmodified lines
339341 includeSchemas: boolean;
340342 }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>
341343 Effect.gen(function* () {
342− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
343− const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
344+ const recipes = yield* loadWorkspaceSourceRecipes({
345+ rows: input.rows,
346+ workspaceId: input.workspaceId,
344347 actorAccountId: input.actorAccountId,
345348 });
346− const sourceById = new Map(sources.map((source) => [source.id, source]));
347− const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
348− const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
349− const [operations, schemaBundles] = yield* Effect.all([
350− input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
351− input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
352− ]);
353−
354− const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
355− for (const operation of operations) {
356− const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];
357− existing.push(operation);
358− operationsByRevisionId.set(operation.recipeRevisionId, existing);
359− }
360−
361− const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
362− for (const schemaBundle of schemaBundles) {
363− const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
364− existing.push(schemaBundle);
365− schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
366− }
367−
368− const entryGroups = yield* Effect.forEach(
369− relevantSourceRecords,
370− (sourceRecord) => {
371− const source = sourceById.get(sourceRecord.id);
372− if (!source) {
373− return Effect.succeed<readonly LoadedSourceRecipeToolIndexEntry[]>([]);
374− }
375−
376− return Effect.forEach(
377− operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
378− (operation) =>
379− Effect.gen(function* () {
380− const path = recipeToolPath({
381− source,
382− operation,
383− });
384− const searchNamespace = recipeToolSearchNamespace({
385− source,
386− path,
387− operation,
388− });
389− const schemaBundleId = primarySchemaBundleForRevision({
390− source,
391− schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
392− })?.id ?? null;
393− const metadata = yield* recipeToolMetadata({
394− source,
395− operation,
396− path,
397− });
398−
399− return {
400− path,
401− searchNamespace,
402− searchText: [
403− path,
404− searchNamespace,
405− source.name,
406− metadata.searchText,
407− ]
408− .filter((part) => part.length > 0)
409− .join(" ")
410− .toLowerCase(),
411− source,
412− sourceRecord,
413− operation,
414− metadata,
415− schemaBundleId,
416− descriptor: recipeToolDescriptor({
417− source,
418− operation,
419− path,
420− schemaBundleId,
421− includeSchemas: input.includeSchemas,
422− }),
423− } satisfies LoadedSourceRecipeToolIndexEntry;
424− }),
425− );
426− },
427− );
428−
429− return entryGroups.flat();
349+ const tools = yield* expandRecipeTools({
350+ recipes,
351+ includeSchemas: input.includeSchemas,
352+ });
353+ return tools.map((tool) => ({
354+ path: tool.path,
355+ searchNamespace: tool.searchNamespace,
356+ searchText: tool.searchText,
357+ source: tool.source,
358+ sourceRecord: tool.sourceRecord,
359+ operation: tool.operation,
360+ metadata: tool.metadata,
361+ schemaBundleId: tool.schemaBundleId,
362+ descriptor: tool.descriptor,
363+ }));
430364 });
431365
432366 export const loadWorkspaceSourceRecipeToolByPath = (input: {
@@ -437,110 +371,27 @@ export const loadWorkspaceSourceRecipeToolByPath = (input: { · 4 unmodified lines
437371 includeSchemas: boolean;
438372 }): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> =>
439373 Effect.gen(function* () {
440− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
441− const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
374+ const recipes = yield* loadWorkspaceSourceRecipes({
375+ rows: input.rows,
376+ workspaceId: input.workspaceId,
442377 actorAccountId: input.actorAccountId,
443378 });
444− const sourceById = new Map(sources.map((source) => [source.id, source]));
445− const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
446−
447− const candidates = relevantSourceRecords.flatMap((sourceRecord) => {
448− const source = sourceById.get(sourceRecord.id);
449− if (!source) {
450− return [];
451− }
452−
453− const namespace = source.namespace ?? namespaceFromSourceName(source.name);
454− if (namespace.length > 0) {
455− if (!input.path.startsWith(`${namespace}.`)) {
456− return [];
457− }
458−
459− return [{
460− source,
461− sourceRecord,
462− toolId: input.path.slice(namespace.length + 1),
463− }];
464− }
465−
466− return [{
467− source,
468− sourceRecord,
469− toolId: input.path,
470− }];
379+ const tools = yield* expandRecipeTools({
380+ recipes,
381+ includeSchemas: input.includeSchemas,
471382 });
472−
473− if (candidates.length === 0) {
474− return null;
475− }
476−
477− const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))];
478− const [operations, schemaBundles] = yield* Effect.all([
479− input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
480− input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
481− ]);
482− const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
483− for (const schemaBundle of schemaBundles) {
484− const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
485− existing.push(schemaBundle);
486− schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
487− }
488−
489− for (const candidate of candidates) {
490− const operation = operations.find((entry) =>
491− entry.recipeRevisionId === candidate.sourceRecord.recipeRevisionId
492− && entry.toolId === candidate.toolId
493− );
494− if (!operation) {
495− continue;
496− }
497−
498− const path = recipeToolPath({
499− source: candidate.source,
500− operation,
501− });
502− const searchNamespace = recipeToolSearchNamespace({
503− source: candidate.source,
504− path,
505− operation,
506− });
507− const schemaBundleId = primarySchemaBundleForRevision({
508− source: candidate.source,
509− schemaBundles:
510− schemaBundlesByRevisionId.get(candidate.sourceRecord.recipeRevisionId) ?? [],
511− })?.id ?? null;
512− const metadata = yield* recipeToolMetadata({
513− source: candidate.source,
514− operation,
515− path,
516− });
517−
518− return {
519− path,
520− searchNamespace,
521− searchText: [
522− path,
523− searchNamespace,
524− candidate.source.name,
525− metadata.searchText,
526− ]
527− .filter((part) => part.length > 0)
528− .join(" ")
529− .toLowerCase(),
530− source: candidate.source,
531− sourceRecord: candidate.sourceRecord,
532− operation,
533− metadata,
534− schemaBundleId,
535− descriptor: recipeToolDescriptor({
536− source: candidate.source,
537− operation,
538− path,
539− schemaBundleId,
540− includeSchemas: input.includeSchemas,
541− }),
542− } satisfies LoadedSourceRecipeToolIndexEntry;
543− }
544−
545− return null;
383+ const tool = tools.find((entry) => entry.path === input.path) ?? null;
384+ return tool
385+ ? {
386+ path: tool.path,
387+ searchNamespace: tool.searchNamespace,
388+ searchText: tool.searchText,
389+ source: tool.source,
390+ sourceRecord: tool.sourceRecord,
391+ operation: tool.operation,
392+ metadata: tool.metadata,
393+ schemaBundleId: tool.schemaBundleId,
394+ descriptor: tool.descriptor,
395+ }
396+ : null;
546397 });
packages/control-plane/src/runtime/source-store.test.ts+113−348
@@ -1,10 +1,13 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
15 import { describe, expect, it } from "@effect/vitest";
26
37 import {
48 AccountIdSchema,
59 decodeBuiltInAuthArtifactConfig,
610 McpSourceAuthSessionDataJsonSchema,
7− OrganizationIdSchema,
811 SecretMaterialIdSchema,
912 SourceAuthSessionIdSchema,
1013 SourceIdSchema,
@@ -21,9 +24,16 @@ import { · 10 unmodified lines
2124 type SqlControlPlanePersistence,
2225 } from "../persistence";
2326 import {
24− stableSourceRecipeId,
25− stableSourceRecipeRevisionId,
26−} from "./source-definitions";
27+ loadLocalExecutorConfig,
28+ resolveLocalWorkspaceContext,
29+} from "./local-config";
30+import {
31+ RuntimeLocalWorkspaceService,
32+ type RuntimeLocalWorkspaceState,
33+} from "./local-runtime-context";
34+import {
35+ loadLocalWorkspaceState,
36+} from "./local-workspace-state";
2737 import { persistSource, removeSourceById } from "./source-store";
2838
2939 const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
@@ -40,6 +50,33 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 10 unmodified lines
4050
4151 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
4252
53+const makeRuntimeLocalWorkspace = (input: {
54+ workspaceId: Source["workspaceId"];
55+ accountId: ReturnType<typeof AccountIdSchema.make>;
56+}) =>
57+ Effect.gen(function* () {
58+ const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-source-store-"));
59+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
60+ const loadedConfig = yield* loadLocalExecutorConfig(context);
61+
62+ return {
63+ context,
64+ installation: {
65+ workspaceId: input.workspaceId,
66+ accountId: input.accountId,
67+ },
68+ loadedConfig,
69+ } satisfies RuntimeLocalWorkspaceState;
70+ });
71+
72+const withRuntimeLocalWorkspace = <A, E>(
73+ effect: Effect.Effect<A, E, never>,
74+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
75+) =>
76+ effect.pipe(
77+ Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
78+ );
79+
4380 const makeOpenApiSource = (input: {
4481 workspaceId: Source["workspaceId"];
4582 sourceId: Source["id"];
@@ -78,28 +115,13 @@ describe("source-store", () => { · 32 unmodified lines
78115 const persistence = yield* makePersistence;
79116 const now = Date.now();
80117 const accountId = AccountIdSchema.make("acc_source_store");
81− const organizationId = OrganizationIdSchema.make("org_source_store");
82118 const workspaceId = WorkspaceIdSchema.make("ws_source_store");
83− const sourceId = SourceIdSchema.make("src_source_store");
119+ const sourceId = SourceIdSchema.make("github");
84120 const firstTokenId = SecretMaterialIdSchema.make("sec_source_store_first");
85121 const secondTokenId = SecretMaterialIdSchema.make("sec_source_store_second");
86−
87− yield* persistence.rows.organizations.insert({
88− id: organizationId,
89− slug: "source-store",
90− name: "Source Store",
91− status: "active",
92− createdByAccountId: accountId,
93− createdAt: now,
94− updatedAt: now,
95− });
96− yield* persistence.rows.workspaces.insert({
97− id: workspaceId,
98− organizationId,
99− name: "Primary",
100− createdByAccountId: accountId,
101− createdAt: now,
102− updatedAt: now,
122+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({
123+ workspaceId,
124+ accountId,
103125 });
104126
105127 yield* persistence.rows.secretMaterials.upsert({
@@ -119,22 +141,25 @@ describe("source-store", () => { · 13 unmodified lines
119141 updatedAt: now,
120142 });
121143
122− yield* persistSource(
123− persistence.rows,
124− makeOpenApiSource({
125− workspaceId,
126− sourceId,
127− now,
128− auth: {
129− kind: "bearer",
130− headerName: "Authorization",
131− prefix: "Bearer ",
132− token: {
133− providerId: "postgres",
134− handle: firstTokenId,
144+ yield* withRuntimeLocalWorkspace(
145+ persistSource(
146+ persistence.rows,
147+ makeOpenApiSource({
148+ workspaceId,
149+ sourceId,
150+ now,
151+ auth: {
152+ kind: "bearer",
153+ headerName: "Authorization",
154+ prefix: "Bearer ",
155+ token: {
156+ providerId: "postgres",
157+ handle: firstTokenId,
158+ },
135159 },
136− },
137− }),
160+ }),
161+ ),
162+ runtimeLocalWorkspace,
138163 );
139164
140165 yield* persistence.rows.sourceAuthSessions.upsert({
@@ -167,23 +192,26 @@ describe("source-store", () => { · 26 unmodified lines
167192 updatedAt: now,
168193 });
169194
170− yield* persistSource(
171− persistence.rows,
172− makeOpenApiSource({
173− workspaceId,
174− sourceId,
175− now,
176− updatedAt: now + 1,
177− auth: {
178− kind: "bearer",
179− headerName: "Authorization",
180− prefix: "Bearer ",
181− token: {
182− providerId: "postgres",
183− handle: secondTokenId,
195+ yield* withRuntimeLocalWorkspace(
196+ persistSource(
197+ persistence.rows,
198+ makeOpenApiSource({
199+ workspaceId,
200+ sourceId,
201+ now,
202+ updatedAt: now + 1,
203+ auth: {
204+ kind: "bearer",
205+ headerName: "Authorization",
206+ prefix: "Bearer ",
207+ token: {
208+ providerId: "postgres",
209+ handle: secondTokenId,
210+ },
184211 },
185− },
186− }),
212+ }),
213+ ),
214+ runtimeLocalWorkspace,
187215 );
188216
189217 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true);
@@ -196,16 +224,22 @@ describe("source-store", () => { · 6 unmodified lines
196224 : null,
197225 ).toBe(secondTokenId);
198226
199− const removed = yield* removeSourceById(persistence.rows, {
200− workspaceId,
201− sourceId,
202− });
227+ const removed = yield* withRuntimeLocalWorkspace(
228+ removeSourceById(persistence.rows, {
229+ workspaceId,
230+ sourceId,
231+ }),
232+ runtimeLocalWorkspace,
233+ );
203234 expect(removed).toBe(true);
204235
205236 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);
206− expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0);
207237 expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
208238 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
239+ const config = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context);
240+ expect(config.config?.sources?.[sourceId]).toBeUndefined();
241+ const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
242+ expect(workspaceState.sources[sourceId]).toBeUndefined();
209243 }),
210244 );
211245
@@ -214,27 +248,12 @@ describe("source-store", () => { · 2 unmodified lines
214248 const persistence = yield* makePersistence;
215249 const now = Date.now();
216250 const accountId = AccountIdSchema.make("acc_actor_scoped");
217− const organizationId = OrganizationIdSchema.make("org_actor_scoped");
218251 const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped");
219− const sourceId = SourceIdSchema.make("src_actor_scoped");
252+ const sourceId = SourceIdSchema.make("github");
220253 const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared");
221−
222− yield* persistence.rows.organizations.insert({
223− id: organizationId,
224− slug: "actor-scoped",
225− name: "Actor Scoped",
226− status: "active",
227− createdByAccountId: accountId,
228− createdAt: now,
229− updatedAt: now,
230− });
231− yield* persistence.rows.workspaces.insert({
232− id: workspaceId,
233− organizationId,
234− name: "Primary",
235− createdByAccountId: accountId,
236− createdAt: now,
237− updatedAt: now,
254+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({
255+ workspaceId,
256+ accountId,
238257 });
239258 yield* persistence.rows.secretMaterials.upsert({
240259 id: sharedTokenId,
@@ -260,16 +279,22 @@ describe("source-store", () => { · 19 unmodified lines
260279 },
261280 });
262281
263− yield* persistSource(persistence.rows, source);
264− yield* persistSource(
265− persistence.rows,
266− {
267− ...source,
268− updatedAt: now + 1,
269− },
270− {
271− actorAccountId: accountId,
272− },
282+ yield* withRuntimeLocalWorkspace(
283+ persistSource(persistence.rows, source),
284+ runtimeLocalWorkspace,
285+ );
286+ yield* withRuntimeLocalWorkspace(
287+ persistSource(
288+ persistence.rows,
289+ {
290+ ...source,
291+ updatedAt: now + 1,
292+ },
293+ {
294+ actorAccountId: accountId,
295+ },
296+ ),
297+ runtimeLocalWorkspace,
273298 );
274299
275300 const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
@@ -282,264 +307,4 @@ describe("source-store", () => { · 6 unmodified lines
282307 expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2);
283308 }),
284309 );
285−
286− it.scoped("cleans up orphaned recipe data when the source config changes", () =>
287− Effect.gen(function* () {
288− const persistence = yield* makePersistence;
289− const now = Date.now();
290− const accountId = AccountIdSchema.make("acc_recipe_rewrite");
291− const organizationId = OrganizationIdSchema.make("org_recipe_rewrite");
292− const workspaceId = WorkspaceIdSchema.make("ws_recipe_rewrite");
293− const sourceId = SourceIdSchema.make("src_recipe_rewrite");
294−
295− yield* persistence.rows.organizations.insert({
296− id: organizationId,
297− slug: "recipe-rewrite",
298− name: "Recipe Rewrite",
299− status: "active",
300− createdByAccountId: accountId,
301− createdAt: now,
302− updatedAt: now,
303− });
304− yield* persistence.rows.workspaces.insert({
305− id: workspaceId,
306− organizationId,
307− name: "Primary",
308− createdByAccountId: accountId,
309− createdAt: now,
310− updatedAt: now,
311− });
312−
313− const initialSource = makeOpenApiSource({
314− workspaceId,
315− sourceId,
316− now,
317− auth: { kind: "none" },
318− });
319− const initialRecipeId = stableSourceRecipeId(initialSource);
320− const initialRecipeRevisionId = stableSourceRecipeRevisionId(initialSource);
321−
322− yield* persistSource(persistence.rows, initialSource);
323− yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
324− recipeRevisionId: initialRecipeRevisionId,
325− documents: [{
326− id: "src_recipe_doc_recipe_rewrite",
327− recipeRevisionId: initialRecipeRevisionId,
328− documentKind: "openapi",
329− documentKey:
330− typeof initialSource.binding.specUrl === "string"
331− ? initialSource.binding.specUrl
332− : initialSource.endpoint,
333− contentText: "{}",
334− contentHash: "hash_recipe_rewrite",
335− fetchedAt: now,
336− createdAt: now,
337− updatedAt: now,
338− }],
339− });
340− yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
341− recipeRevisionId: initialRecipeRevisionId,
342− operations: [{
343− id: "src_recipe_op_recipe_rewrite",
344− recipeRevisionId: initialRecipeRevisionId,
345− operationKey: "getRepo",
346− transportKind: "http",
347− toolId: "getRepo",
348− title: "Get Repo",
349− description: "Read a repository",
350− operationKind: "read",
351− searchText: "github get repo",
352− inputSchemaJson: null,
353− outputSchemaJson: null,
354− providerKind: "openapi",
355− providerDataJson: JSON.stringify({
356− kind: "openapi",
357− toolId: "getRepo",
358− rawToolId: "repos_getRepo",
359− operationId: "repos.getRepo",
360− group: "repos",
361− leaf: "getRepo",
362− tags: ["repos"],
363− method: "get",
364− path: "/repos/{owner}/{repo}",
365− operationHash: "hash_recipe_rewrite",
366− invocation: {
367− method: "get",
368− pathTemplate: "/repos/{owner}/{repo}",
369− parameters: [],
370− requestBody: null,
371− },
372− }),
373− createdAt: now,
374− updatedAt: now,
375− }],
376− });
377−
378− const updatedSource = makeOpenApiSource({
379− workspaceId,
380− sourceId,
381− now,
382− updatedAt: now + 1,
383− endpoint: "https://api.example.com",
384− specUrl: "https://api.example.com/openapi.json",
385− auth: { kind: "none" },
386− });
387− const nextRecipeId = stableSourceRecipeId(updatedSource);
388− const nextRecipeRevisionId = stableSourceRecipeRevisionId(updatedSource);
389−
390− yield* persistSource(persistence.rows, updatedSource);
391−
392− expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(initialRecipeId))).toBe(true);
393− expect(
394− Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(initialRecipeRevisionId)),
395− ).toBe(true);
396− expect(
397− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(initialRecipeRevisionId),
398− ).toEqual([]);
399− expect(
400− yield* persistence.rows.sourceRecipeOperations.listByRevisionId(initialRecipeRevisionId),
401− ).toEqual([]);
402− expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(nextRecipeId))).toBe(true);
403− expect(
404− Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(nextRecipeRevisionId)),
405− ).toBe(true);
406− }),
407− );
408−
409− it.scoped("retains shared recipe data until the last source reference is removed", () =>
410− Effect.gen(function* () {
411− const persistence = yield* makePersistence;
412− const now = Date.now();
413− const accountId = AccountIdSchema.make("acc_shared_recipe");
414− const organizationId = OrganizationIdSchema.make("org_shared_recipe");
415− const workspaceId = WorkspaceIdSchema.make("ws_shared_recipe");
416− const firstSourceId = SourceIdSchema.make("src_shared_recipe_one");
417− const secondSourceId = SourceIdSchema.make("src_shared_recipe_two");
418−
419− yield* persistence.rows.organizations.insert({
420− id: organizationId,
421− slug: "shared-recipe",
422− name: "Shared Recipe",
423− status: "active",
424− createdByAccountId: accountId,
425− createdAt: now,
426− updatedAt: now,
427− });
428− yield* persistence.rows.workspaces.insert({
429− id: workspaceId,
430− organizationId,
431− name: "Primary",
432− createdByAccountId: accountId,
433− createdAt: now,
434− updatedAt: now,
435− });
436−
437− const firstSource = makeOpenApiSource({
438− workspaceId,
439− sourceId: firstSourceId,
440− now,
441− name: "GitHub One",
442− auth: { kind: "none" },
443− });
444− const secondSource = makeOpenApiSource({
445− workspaceId,
446− sourceId: secondSourceId,
447− now,
448− updatedAt: now + 1,
449− name: "GitHub Two",
450− auth: { kind: "none" },
451− });
452− const sharedRecipeId = stableSourceRecipeId(firstSource);
453− const sharedRecipeRevisionId = stableSourceRecipeRevisionId(firstSource);
454−
455− yield* persistSource(persistence.rows, firstSource);
456− yield* persistSource(persistence.rows, secondSource);
457− yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
458− recipeRevisionId: sharedRecipeRevisionId,
459− documents: [{
460− id: "src_recipe_doc_shared_recipe",
461− recipeRevisionId: sharedRecipeRevisionId,
462− documentKind: "openapi",
463− documentKey:
464− typeof firstSource.binding.specUrl === "string"
465− ? firstSource.binding.specUrl
466− : firstSource.endpoint,
467− contentText: "{}",
468− contentHash: "hash_shared_recipe",
469− fetchedAt: now,
470− createdAt: now,
471− updatedAt: now,
472− }],
473− });
474− yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
475− recipeRevisionId: sharedRecipeRevisionId,
476− operations: [{
477− id: "src_recipe_op_shared_recipe",
478− recipeRevisionId: sharedRecipeRevisionId,
479− operationKey: "getRepo",
480− transportKind: "http",
481− toolId: "getRepo",
482− title: "Get Repo",
483− description: "Read a repository",
484− operationKind: "read",
485− searchText: "github get repo",
486− inputSchemaJson: null,
487− outputSchemaJson: null,
488− providerKind: "openapi",
489− providerDataJson: JSON.stringify({
490− kind: "openapi",
491− toolId: "getRepo",
492− rawToolId: "repos_getRepo",
493− operationId: "repos.getRepo",
494− group: "repos",
495− leaf: "getRepo",
496− tags: ["repos"],
497− method: "get",
498− path: "/repos/{owner}/{repo}",
499− operationHash: "hash_shared_recipe",
500− invocation: {
501− method: "get",
502− pathTemplate: "/repos/{owner}/{repo}",
503− parameters: [],
504− requestBody: null,
505− },
506− }),
507− createdAt: now,
508− updatedAt: now,
509− }],
510− });
511−
512− yield* removeSourceById(persistence.rows, {
513− workspaceId,
514− sourceId: firstSourceId,
515− });
516−
517− expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true);
518− expect(
519− Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)),
520− ).toBe(true);
521− expect(
522− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId),
523− ).toHaveLength(1);
524− expect(
525− yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId),
526− ).toHaveLength(1);
527−
528− yield* removeSourceById(persistence.rows, {
529− workspaceId,
530− sourceId: secondSourceId,
531− });
532−
533− expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true);
534− expect(
535− Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)),
536− ).toBe(true);
537− expect(
538− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId),
539− ).toEqual([]);
540− expect(
541− yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId),
542− ).toEqual([]);
543− }),
544− );
545310 });
packages/control-plane/src/runtime/source-store.ts+508−164
@@ -2,27 +2,60 @@ import type { · 1 unmodified line
22 AccountId,
33 AuthArtifact,
44 CredentialSlot,
5+ LocalConfigSecretInput,
6+ LocalConfigSource,
57 Source,
6− SourceRecipeId,
7− SourceRecipeRevisionId,
8+ SourceId,
89 WorkspaceId,
910 } from "#schema";
11+import { SourceIdSchema } from "#schema";
1012 import { type SqlControlPlaneRows } from "#persistence";
1113 import * as Effect from "effect/Effect";
12−import * as Option from "effect/Option";
1314
1415 import {
15− createSourceRecipeRecord,
16− createSourceRecipeRevisionRecord,
17− projectSourceFromStorage,
18− projectSourcesFromStorage,
1916 stableSourceRecipeId,
2017 stableSourceRecipeRevisionId,
2118 splitSourceForStorage,
2219 } from "./source-definitions";
20+import {
21+ sourceAuthFromAuthArtifact,
22+} from "./auth-artifacts";
23+import {
24+ loadLocalExecutorConfig,
25+ type LoadedLocalExecutorConfig,
26+ type ResolvedLocalWorkspaceContext,
27+ writeProjectLocalExecutorConfig,
28+} from "./local-config";
29+import {
30+ readLocalSourceArtifact,
31+ removeLocalSourceArtifact,
32+} from "./local-source-artifacts";
33+import {
34+ LocalConfiguredSourceNotFoundError,
35+ LocalExecutorConfigDecodeError,
36+ LocalFileSystemError,
37+ LocalUnsupportedSourceKindError,
38+ LocalWorkspaceStateDecodeError,
39+ RuntimeLocalWorkspaceMismatchError,
40+ RuntimeLocalWorkspaceUnavailableError,
41+} from "./local-errors";
42+import {
43+ requireRuntimeLocalWorkspace,
44+} from "./local-runtime-context";
45+import {
46+ loadLocalWorkspaceState,
47+ writeLocalWorkspaceState,
48+ type LocalWorkspaceState,
49+} from "./local-workspace-state";
50+import {
51+ fromConfigSecretProviderId,
52+ toConfigSecretProviderId,
53+} from "./local-config-secrets";
2354 import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
2455 import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
2556 import { removeAuthLeaseAndSecrets } from "./auth-leases";
57+import { getSourceAdapter } from "./source-adapters";
58+import { slugify } from "./slug";
2659
2760 const secretRefKey = (ref: { providerId: string; handle: string }): string =>
2861 `${ref.providerId}:${ref.handle}`;
@@ -79,78 +112,408 @@ const selectExactAuthArtifact = (input: { · 50 unmodified lines
79112 && artifact.actorAccountId === (input.actorAccountId ?? null),
80113 ) ?? null;
81114
115+const trimOrNull = (value: string | null | undefined): string | null => {
116+ if (value == null) {
117+ return null;
118+ }
119+
120+ const trimmed = value.trim();
121+ return trimmed.length > 0 ? trimmed : null;
122+};
123+
124+const cloneJson = <T>(value: T): T =>
125+ JSON.parse(JSON.stringify(value)) as T;
126+
127+const deriveLocalSourceId = (
128+ source: Pick<Source, "namespace" | "name">,
129+ used: ReadonlySet<string>,
130+): SourceId => {
131+ const base =
132+ trimOrNull(source.namespace)
133+ ?? trimOrNull(source.name)
134+ ?? "source";
135+ const slugBase = slugify(base) || "source";
136+ let candidate = slugBase;
137+ let counter = 2;
138+ while (used.has(candidate)) {
139+ candidate = `${slugBase}-${counter}`;
140+ counter += 1;
141+ }
142+ return SourceIdSchema.make(candidate);
143+};
144+
145+const resolveLocalConfigSecretProviderAlias = (config: LoadedLocalExecutorConfig["config"]): string | null => {
146+ const defaultAlias = trimOrNull(config?.secrets?.defaults?.env);
147+ if (defaultAlias !== null && config?.secrets?.providers?.[defaultAlias]) {
148+ return defaultAlias;
149+ }
150+
151+ return config?.secrets?.providers?.default ? "default" : null;
152+};
153+
154+const sourceAuthFromConfigInput = (input: {
155+ auth: unknown;
156+ config: LoadedLocalExecutorConfig["config"];
157+ existing: Source["auth"] | null;
158+}): Source["auth"] => {
159+ if (input.auth === undefined) {
160+ return input.existing ?? { kind: "none" };
161+ }
162+
163+ if (typeof input.auth === "string") {
164+ const providerAlias = resolveLocalConfigSecretProviderAlias(input.config);
165+ return {
166+ kind: "bearer",
167+ headerName: "Authorization",
168+ prefix: "Bearer ",
169+ token: {
170+ providerId: providerAlias ? toConfigSecretProviderId(providerAlias) : "env",
171+ handle: input.auth,
172+ },
173+ };
174+ }
175+
176+ if (typeof input.auth === "object" && input.auth !== null) {
177+ const explicit = input.auth as {
178+ source?: string;
179+ provider?: string;
180+ id?: string;
181+ };
182+ const providerAlias = trimOrNull(explicit.provider);
183+ const providerId = providerAlias
184+ ? (providerAlias === "params" ? "params" : toConfigSecretProviderId(providerAlias))
185+ : explicit.source === "env"
186+ ? "env"
187+ : explicit.source === "params"
188+ ? "params"
189+ : null;
190+ const handle = trimOrNull(explicit.id);
191+ if (providerId && handle) {
192+ return {
193+ kind: "bearer",
194+ headerName: "Authorization",
195+ prefix: "Bearer ",
196+ token: {
197+ providerId,
198+ handle,
199+ },
200+ };
201+ }
202+ }
203+
204+ return input.existing ?? { kind: "none" };
205+};
206+
207+const configAuthFromSource = (input: {
208+ source: Source;
209+ existingConfigAuth: LocalConfigSecretInput | undefined;
210+ config: LoadedLocalExecutorConfig["config"];
211+}): LocalConfigSecretInput | undefined => {
212+ if (input.source.auth.kind !== "bearer") {
213+ return input.existingConfigAuth;
214+ }
215+
216+ if (input.source.auth.token.providerId === "env") {
217+ return input.source.auth.token.handle;
218+ }
219+
220+ if (input.source.auth.token.providerId === "params") {
221+ return {
222+ source: "params",
223+ provider: "params",
224+ id: input.source.auth.token.handle,
225+ };
226+ }
227+
228+ const provider = fromConfigSecretProviderId(input.source.auth.token.providerId);
229+ if (provider !== null) {
230+ const configuredProvider = input.config?.secrets?.providers?.[provider];
231+ if (configuredProvider) {
232+ return {
233+ source: configuredProvider.source,
234+ provider,
235+ id: input.source.auth.token.handle,
236+ };
237+ }
238+ }
239+
240+ return input.existingConfigAuth;
241+};
242+
243+const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId): Effect.Effect<{
244+ context: ResolvedLocalWorkspaceContext;
245+ installation: {
246+ workspaceId: WorkspaceId;
247+ accountId: AccountId;
248+ };
249+ loadedConfig: LoadedLocalExecutorConfig;
250+ workspaceState: LocalWorkspaceState;
251+},
252+ | RuntimeLocalWorkspaceUnavailableError
253+ | RuntimeLocalWorkspaceMismatchError
254+ | LocalFileSystemError
255+ | LocalExecutorConfigDecodeError
256+ | LocalWorkspaceStateDecodeError,
257+never> =>
258+ Effect.gen(function* () {
259+ const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId);
260+ const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context);
261+ const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
262+
263+ return {
264+ context: runtimeLocalWorkspace.context,
265+ installation: runtimeLocalWorkspace.installation,
266+ loadedConfig,
267+ workspaceState,
268+ };
269+ });
270+
271+const buildLocalSourceRecord = (input: {
272+ context: ResolvedLocalWorkspaceContext;
273+ workspaceId: WorkspaceId;
274+ loadedConfig: LoadedLocalExecutorConfig;
275+ workspaceState: LocalWorkspaceState;
276+ sourceId: SourceId;
277+ actorAccountId?: AccountId | null;
278+ authArtifacts: ReadonlyArray<AuthArtifact>;
279+}): Effect.Effect<{
280+ source: Source;
281+ sourceId: SourceId;
282+}, LocalConfiguredSourceNotFoundError | Error, never> =>
283+ Effect.gen(function* () {
284+ const sourceConfig = input.loadedConfig.config?.sources?.[input.sourceId];
285+ if (!sourceConfig) {
286+ return yield* Effect.fail(
287+ new LocalConfiguredSourceNotFoundError({
288+ message: `Configured source not found for id ${input.sourceId}`,
289+ sourceId: input.sourceId,
290+ }),
291+ );
292+ }
293+
294+ const existingState = input.workspaceState.sources[input.sourceId];
295+ const adapter = getSourceAdapter(sourceConfig.kind);
296+ const baseSource = yield* adapter.validateSource({
297+ id: SourceIdSchema.make(input.sourceId),
298+ workspaceId: input.workspaceId,
299+ name: trimOrNull(sourceConfig.name) ?? input.sourceId,
300+ kind: sourceConfig.kind,
301+ endpoint: sourceConfig.connection.endpoint.trim(),
302+ status: existingState?.status ?? ((sourceConfig.enabled ?? true) ? "connected" : "draft"),
303+ enabled: sourceConfig.enabled ?? true,
304+ namespace: trimOrNull(sourceConfig.namespace) ?? input.sourceId,
305+ bindingVersion: adapter.bindingConfigVersion,
306+ binding: sourceConfig.binding,
307+ importAuthPolicy: adapter.defaultImportAuthPolicy,
308+ importAuth: { kind: "none" },
309+ auth: sourceAuthFromConfigInput({
310+ auth: sourceConfig.connection.auth,
311+ config: input.loadedConfig.config,
312+ existing: null,
313+ }),
314+ sourceHash: existingState?.sourceHash ?? null,
315+ lastError: existingState?.lastError ?? null,
316+ createdAt: existingState?.createdAt ?? Date.now(),
317+ updatedAt: existingState?.updatedAt ?? Date.now(),
318+ });
319+
320+ const artifact = yield* readLocalSourceArtifact({
321+ context: input.context,
322+ sourceId: input.sourceId,
323+ }).pipe(
324+ Effect.catchAll(() => Effect.succeed(null)),
325+ );
326+
327+ const runtimeAuthArtifact = selectPreferredAuthArtifact({
328+ authArtifacts: input.authArtifacts.filter((artifactItem) => artifactItem.sourceId === baseSource.id),
329+ actorAccountId: input.actorAccountId,
330+ slot: "runtime",
331+ });
332+ const importAuthArtifact = selectPreferredAuthArtifact({
333+ authArtifacts: input.authArtifacts.filter((artifactItem) => artifactItem.sourceId === baseSource.id),
334+ actorAccountId: input.actorAccountId,
335+ slot: "import",
336+ });
337+
338+ const sourceRecord = {
339+ id: baseSource.id,
340+ workspaceId: baseSource.workspaceId,
341+ recipeId: artifact?.recipeId ?? stableSourceRecipeId(baseSource),
342+ recipeRevisionId: artifact?.revision.id ?? stableSourceRecipeRevisionId(baseSource),
343+ name: baseSource.name,
344+ kind: baseSource.kind,
345+ endpoint: baseSource.endpoint,
346+ status: baseSource.status,
347+ enabled: baseSource.enabled,
348+ namespace: baseSource.namespace,
349+ importAuthPolicy: baseSource.importAuthPolicy,
350+ bindingConfigJson: adapter.serializeBindingConfig(baseSource),
351+ sourceHash: baseSource.sourceHash,
352+ lastError: baseSource.lastError,
353+ createdAt: baseSource.createdAt,
354+ updatedAt: baseSource.updatedAt,
355+ };
356+
357+ const source: Source = {
358+ ...baseSource,
359+ auth:
360+ runtimeAuthArtifact === null
361+ ? baseSource.auth
362+ : sourceAuthFromAuthArtifact(runtimeAuthArtifact),
363+ importAuth:
364+ baseSource.importAuthPolicy === "separate"
365+ ? importAuthArtifact === null
366+ ? baseSource.importAuth
367+ : sourceAuthFromAuthArtifact(importAuthArtifact)
368+ : { kind: "none" },
369+ sourceHash: sourceRecord.sourceHash,
370+ lastError: sourceRecord.lastError,
371+ createdAt: sourceRecord.createdAt,
372+ updatedAt: sourceRecord.updatedAt,
373+ };
374+
375+ return {
376+ source,
377+ sourceId: input.sourceId,
378+ };
379+ });
380+
82381 export const loadSourcesInWorkspace = (
83382 rows: SqlControlPlaneRows,
84383 workspaceId: WorkspaceId,
85384 options: {
86385 actorAccountId?: AccountId | null;
87386 } = {},
88−) =>
387+): Effect.Effect<
388+ readonly Source[],
389+ | RuntimeLocalWorkspaceUnavailableError
390+ | RuntimeLocalWorkspaceMismatchError
391+ | LocalFileSystemError
392+ | LocalExecutorConfigDecodeError
393+ | LocalWorkspaceStateDecodeError
394+ | LocalConfiguredSourceNotFoundError
395+ | Error,
396+ never
397+> =>
89398 Effect.gen(function* () {
90− const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
399+ const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId);
91400 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
92− const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => {
93− const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id);
94− const preferred = selectPreferredAuthArtifact({
95− authArtifacts: matches,
96− actorAccountId: options.actorAccountId,
97− slot: "runtime",
98− });
99− const preferredImport = selectPreferredAuthArtifact({
100− authArtifacts: matches,
101− actorAccountId: options.actorAccountId,
102− slot: "import",
103− });
104− return [preferred, preferredImport].filter(
105− (artifact): artifact is AuthArtifact => artifact !== null,
106− );
107− });
108−
109− return yield* projectSourcesFromStorage({
110− sourceRecords,
111− authArtifacts: filteredAuthArtifacts,
112− });
401+ return yield* Effect.forEach(
402+ Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}),
403+ (sourceId) =>
404+ Effect.map(
405+ buildLocalSourceRecord({
406+ context: localWorkspace.context,
407+ workspaceId,
408+ loadedConfig: localWorkspace.loadedConfig,
409+ workspaceState: localWorkspace.workspaceState,
410+ sourceId: SourceIdSchema.make(sourceId),
411+ actorAccountId: options.actorAccountId,
412+ authArtifacts,
413+ }),
414+ ({ source }) => source,
415+ ),
416+ );
113417 });
114418
115419 export const loadSourceById = (rows: SqlControlPlaneRows, input: {
116420 workspaceId: WorkspaceId;
117421 sourceId: Source["id"];
118422 actorAccountId?: AccountId | null;
119−}) =>
423+}): Effect.Effect<
424+ Source,
425+ | RuntimeLocalWorkspaceUnavailableError
426+ | RuntimeLocalWorkspaceMismatchError
427+ | LocalFileSystemError
428+ | LocalExecutorConfigDecodeError
429+ | LocalWorkspaceStateDecodeError
430+ | LocalConfiguredSourceNotFoundError
431+ | Error,
432+ never
433+> =>
120434 Effect.gen(function* () {
121− const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
122− input.workspaceId,
123− input.sourceId,
124− );
125−
126− if (Option.isNone(sourceRecord)) {
435+ const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);
436+ const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId);
437+ if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) {
127438 return yield* Effect.fail(
128− new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
439+ new LocalConfiguredSourceNotFoundError({
440+ message: `Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
441+ sourceId: input.sourceId,
442+ }),
129443 );
130444 }
131445
132− const authArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
446+ const localSource = yield* buildLocalSourceRecord({
447+ context: localWorkspace.context,
133448 workspaceId: input.workspaceId,
449+ loadedConfig: localWorkspace.loadedConfig,
450+ workspaceState: localWorkspace.workspaceState,
134451 sourceId: input.sourceId,
135− });
136− const authArtifact = selectPreferredAuthArtifact({
137− authArtifacts,
138452 actorAccountId: input.actorAccountId,
139− slot: "runtime",
140− });
141− const importAuthArtifact = selectPreferredAuthArtifact({
142453 authArtifacts,
143− actorAccountId: input.actorAccountId,
144− slot: "import",
145454 });
146455
147− return yield* projectSourceFromStorage({
148− sourceRecord: sourceRecord.value,
149− runtimeAuthArtifact: authArtifact,
150− importAuthArtifact,
151− });
456+ return localSource.source;
457+ });
458+
459+const configSourceFromLocalSource = (input: {
460+ source: Source;
461+ existingConfigAuth: LocalConfigSecretInput | undefined;
462+ config: LoadedLocalExecutorConfig["config"];
463+}): LocalConfigSource => {
464+ const auth = configAuthFromSource({
465+ source: input.source,
466+ existingConfigAuth: input.existingConfigAuth,
467+ config: input.config,
152468 });
153469
470+ const common = {
471+ ...(trimOrNull(input.source.name) !== trimOrNull(input.source.id)
472+ ? { name: input.source.name }
473+ : {}),
474+ ...(trimOrNull(input.source.namespace) !== trimOrNull(input.source.id)
475+ ? { namespace: input.source.namespace ?? undefined }
476+ : {}),
477+ ...(input.source.enabled === false ? { enabled: false } : {}),
478+ connection: {
479+ endpoint: input.source.endpoint,
480+ ...(auth !== undefined ? { auth } : {}),
481+ },
482+ };
483+
484+ switch (input.source.kind) {
485+ case "openapi":
486+ return {
487+ kind: "openapi",
488+ ...common,
489+ binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "openapi" }>["binding"],
490+ };
491+ case "graphql":
492+ return {
493+ kind: "graphql",
494+ ...common,
495+ binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "graphql" }>["binding"],
496+ };
497+ case "google_discovery":
498+ return {
499+ kind: "google_discovery",
500+ ...common,
501+ binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "google_discovery" }>["binding"],
502+ };
503+ case "mcp":
504+ return {
505+ kind: "mcp",
506+ ...common,
507+ binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "mcp" }>["binding"],
508+ };
509+ default:
510+ throw new LocalUnsupportedSourceKindError({
511+ message: `Unsupported source kind for local config: ${input.source.kind}`,
512+ kind: input.source.kind,
513+ });
514+ }
515+};
516+
154517 const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {
155518 workspaceId: WorkspaceId;
156519 sourceId: Source["id"];
@@ -188,50 +551,46 @@ const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { · 31 unmodified lines
188551 return existingAuthArtifacts.length;
189552 });
190553
191−const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: {
192− recipeId: SourceRecipeId;
193− recipeRevisionId: SourceRecipeRevisionId;
194−}) =>
195− Effect.gen(function* () {
196− const revisionReferenceCount = yield* rows.sources.countByRecipeRevisionId(
197− input.recipeRevisionId,
198− );
199− if (revisionReferenceCount === 0) {
200− yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);
201− yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId);
202− yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);
203− }
204−
205− const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId);
206− if (recipeReferenceCount > 0) {
207− return;
208− }
209−
210− const recipeRevisions = yield* rows.sourceRecipeRevisions.listByRecipeId(input.recipeId);
211− yield* Effect.forEach(
212− recipeRevisions,
213− (recipeRevision) =>
214− Effect.all([
215− rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id),
216− rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id),
217− rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id),
218− ]),
219− { discard: true },
220− );
221− yield* rows.sourceRecipeRevisions.removeByRecipeId(input.recipeId);
222− yield* rows.sourceRecipes.removeById(input.recipeId);
223− });
224−
225554 export const removeSourceById = (rows: SqlControlPlaneRows, input: {
226555 workspaceId: WorkspaceId;
227556 sourceId: Source["id"];
228557 }) =>
229558 Effect.gen(function* () {
230− const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);
231− if (Option.isNone(sourceRecord)) {
559+ const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);
560+ if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) {
232561 return false;
233562 }
234563
564+ const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
565+ const sources = {
566+ ...(projectConfig.sources ?? {}),
567+ };
568+ delete sources[input.sourceId];
569+ yield* writeProjectLocalExecutorConfig({
570+ context: localWorkspace.context,
571+ config: {
572+ ...projectConfig,
573+ sources,
574+ },
575+ });
576+
577+ const {
578+ [input.sourceId]: _removedSource,
579+ ...remainingSources
580+ } = localWorkspace.workspaceState.sources;
581+ const workspaceState: LocalWorkspaceState = {
582+ ...localWorkspace.workspaceState,
583+ sources: remainingSources,
584+ };
585+ yield* writeLocalWorkspaceState({
586+ context: localWorkspace.context,
587+ state: workspaceState,
588+ });
589+ yield* removeLocalSourceArtifact({
590+ context: localWorkspace.context,
591+ sourceId: input.sourceId,
592+ });
593+
235594 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
236595 input.workspaceId,
237596 input.sourceId,
@@ -241,15 +600,6 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 3 unmodified lines
241600 sourceId: input.sourceId,
242601 });
243602 yield* removeAuthArtifactsForSource(rows, input);
244− const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);
245− if (!removed) {
246− return false;
247− }
248−
249− yield* cleanupOrphanedRecipeData(rows, {
250− recipeId: sourceRecord.value.recipeId,
251− recipeRevisionId: sourceRecord.value.recipeRevisionId,
252− });
253603
254604 return true;
255605 });
@@ -262,10 +612,21 @@ export const persistSource = ( · 6 unmodified lines
262612 } = {},
263613 ) =>
264614 Effect.gen(function* () {
265− const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
615+ const localWorkspace = yield* resolveRuntimeLocalWorkspace(source.workspaceId);
616+ const nextSource = {
617+ ...source,
618+ id:
619+ localWorkspace.loadedConfig.config?.sources?.[source.id]
620+ || localWorkspace.workspaceState.sources[source.id]
621+ ? source.id
622+ : deriveLocalSourceId(
623+ source,
624+ new Set(Object.keys(localWorkspace.loadedConfig.config?.sources ?? {})),
625+ ),
626+ } satisfies Source;
266627 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
267− workspaceId: source.workspaceId,
268− sourceId: source.id,
628+ workspaceId: nextSource.workspaceId,
629+ sourceId: nextSource.id,
269630 });
270631 const existingRuntimeAuthArtifact = selectExactAuthArtifact({
271632 authArtifacts: existingAuthArtifacts,
@@ -277,73 +638,33 @@ export const persistSource = ( · 5 unmodified lines
277638 actorAccountId: options.actorAccountId,
278639 slot: "import",
279640 });
280−
281− const nextRecipeId = stableSourceRecipeId(source);
282− const nextRecipeRevisionId = Option.isSome(existing) && existing.value.recipeId === nextRecipeId
283− ? existing.value.recipeRevisionId
284− : stableSourceRecipeRevisionId(source);
285− const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);
286− const nextRevision = createSourceRecipeRevisionRecord({
287− source,
288− recipeId: nextRecipeId,
289− recipeRevisionId: nextRecipeRevisionId,
290− revisionNumber: Option.isSome(existingTargetRevision)
291− ? existingTargetRevision.value.revisionNumber
292− : 1,
293− manifestJson: Option.isSome(existingTargetRevision)
294− ? existingTargetRevision.value.manifestJson
295− : null,
296− manifestHash: Option.isSome(existingTargetRevision)
297− ? existingTargetRevision.value.manifestHash
298− : null,
299− materializationHash: Option.isSome(existingTargetRevision)
300− ? existingTargetRevision.value.materializationHash
301− : null,
641+ const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
642+ const sources = {
643+ ...(projectConfig.sources ?? {}),
644+ };
645+ const existingConfigSource = sources[nextSource.id];
646+ sources[nextSource.id] = configSourceFromLocalSource({
647+ source: nextSource,
648+ existingConfigAuth: existingConfigSource?.connection.auth,
649+ config: localWorkspace.loadedConfig.config,
302650 });
303−
304− const nextRecipe = createSourceRecipeRecord({
305− source,
306− recipeId: nextRecipeId,
307− latestRevisionId: nextRevision.id,
651+ yield* writeProjectLocalExecutorConfig({
652+ context: localWorkspace.context,
653+ config: {
654+ ...projectConfig,
655+ sources,
656+ },
308657 });
309658
310− const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
311− source,
312− recipeId: nextRecipe.id,
313− recipeRevisionId: nextRevision.id,
659+ const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
660+ source: nextSource,
661+ recipeId: stableSourceRecipeId(nextSource),
662+ recipeRevisionId: stableSourceRecipeRevisionId(nextSource),
314663 actorAccountId: options.actorAccountId,
315664 existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,
316665 existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,
317666 });
318667
319− if (Option.isNone(existing)) {
320− yield* rows.sources.insert(sourceRecord);
321− } else {
322− const {
323− id: _id,
324− workspaceId: _workspaceId,
325− createdAt: _createdAt,
326− ...patch
327− } = sourceRecord;
328− yield* rows.sources.update(source.workspaceId, source.id, patch);
329− }
330−
331− yield* rows.sourceRecipes.upsert(nextRecipe);
332− yield* rows.sourceRecipeRevisions.upsert(nextRevision);
333−
334− if (
335− Option.isSome(existing)
336− && (
337− existing.value.recipeId !== nextRecipeId
338− || existing.value.recipeRevisionId !== nextRecipeRevisionId
339− )
340− ) {
341− yield* cleanupOrphanedRecipeData(rows, {
342− recipeId: existing.value.recipeId,
343− recipeRevisionId: existing.value.recipeRevisionId,
344− });
345− }
346−
347668 if (runtimeAuthArtifact === null) {
348669 if (existingRuntimeAuthArtifact !== null) {
349670 yield* removeAuthLeaseAndSecrets(rows, {
@@ -351,8 +672,8 @@ export const persistSource = ( · 1 unmodified line
351672 });
352673 }
353674 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
354− workspaceId: source.workspaceId,
355− sourceId: source.id,
675+ workspaceId: nextSource.workspaceId,
676+ sourceId: nextSource.id,
356677 actorAccountId: options.actorAccountId ?? null,
357678 slot: "runtime",
358679 });
@@ -380,8 +701,8 @@ export const persistSource = ( · 21 unmodified lines
380701 });
381702 }
382703 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
383− workspaceId: source.workspaceId,
384− sourceId: source.id,
704+ workspaceId: nextSource.workspaceId,
705+ sourceId: nextSource.id,
385706 actorAccountId: options.actorAccountId ?? null,
386707 slot: "import",
387708 });
@@ -402,5 +723,28 @@ export const persistSource = ( · 14 unmodified lines
402723 next: importAuthArtifact,
403724 });
404725
405− return source;
726+ const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id];
727+ const workspaceState: LocalWorkspaceState = {
728+ ...localWorkspace.workspaceState,
729+ sources: {
730+ ...localWorkspace.workspaceState.sources,
731+ [nextSource.id]: {
732+ status: nextSource.status,
733+ lastError: nextSource.lastError,
734+ sourceHash: nextSource.sourceHash,
735+ createdAt: existingSourceState?.createdAt ?? nextSource.createdAt,
736+ updatedAt: nextSource.updatedAt,
737+ },
738+ },
739+ };
740+ yield* writeLocalWorkspaceState({
741+ context: localWorkspace.context,
742+ state: workspaceState,
743+ });
744+
745+ return yield* loadSourceById(rows, {
746+ workspaceId: nextSource.workspaceId,
747+ sourceId: nextSource.id,
748+ actorAccountId: options.actorAccountId,
749+ });
406750 });
packages/control-plane/src/runtime/sources-operations.ts+90−85
@@ -2,7 +2,6 @@ import type { · 1 unmodified line
22 CreateSourcePayload,
33 UpdateSourcePayload,
44 } from "../api/sources/api";
5−import { Actor } from "#domain";
65 import {
76 type AccountId,
87 SourceIdSchema,
@@ -137,11 +136,14 @@ const syncArtifactsForSource = (input: { · 128 unmodified lines
137136 });
138137 });
139138
140−export const listSources = (workspaceId: WorkspaceId) =>
139+export const listSources = (input: {
140+ workspaceId: WorkspaceId;
141+ accountId: AccountId;
142+}) =>
141143 Effect.flatMap(ControlPlaneStore, (store) =>
142− Effect.flatMap(Actor, (actor) =>
143− loadSourcesInWorkspace(store, workspaceId, {
144− actorAccountId: actor.principal.accountId,
144+ Effect.gen(function* () {
145+ return yield* loadSourcesInWorkspace(store, input.workspaceId, {
146+ actorAccountId: input.accountId,
145147 }).pipe(
146148 Effect.mapError((error) =>
147149 sourceOps.list.unknownStorage(
@@ -149,58 +151,60 @@ export const listSources = (workspaceId: WorkspaceId) => · 1 unmodified line
149151 "Failed projecting stored sources",
150152 ),
151153 ),
152− ),
153− ));
154+ );
155+ }));
154156
155157 export const createSource = (input: {
156158 workspaceId: WorkspaceId;
159+ accountId: AccountId;
157160 payload: CreateSourcePayload;
158161 }) =>
159162 Effect.flatMap(ControlPlaneStore, (store) =>
160− Effect.flatMap(Actor, (actor) =>
161− Effect.gen(function* () {
162− const now = Date.now();
163+ Effect.gen(function* () {
164+ const now = Date.now();
163165
164− const source = yield* createSourceFromPayload({
165− workspaceId: input.workspaceId,
166− sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
167− payload: input.payload,
168− now,
169− }).pipe(
170− Effect.mapError((cause) =>
171− sourceOps.create.badRequest(
172− "Invalid source definition",
173− cause instanceof Error ? cause.message : String(cause),
174− ),
166+ const source = yield* createSourceFromPayload({
167+ workspaceId: input.workspaceId,
168+ sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
169+ payload: input.payload,
170+ now,
171+ }).pipe(
172+ Effect.mapError((cause) =>
173+ sourceOps.create.badRequest(
174+ "Invalid source definition",
175+ cause instanceof Error ? cause.message : String(cause),
175176 ),
176− );
177+ ),
178+ );
177179
178− yield* mapPersistenceError(
179− sourceOps.create.child("persist"),
180− persistSource(store, source, {
181− actorAccountId: actor.principal.accountId,
182− }),
183− );
180+ const persistedSource = yield* mapPersistenceError(
181+ sourceOps.create.child("persist"),
182+ persistSource(store, source, {
183+ actorAccountId: input.accountId,
184+ }),
185+ );
184186
185− return yield* syncArtifactsForSource({
186− store,
187− source,
188− actorAccountId: actor.principal.accountId,
189− operation: sourceOps.create,
190− });
191− }),
192− ));
187+ const synchronizedSource = yield* syncArtifactsForSource({
188+ store,
189+ source: persistedSource,
190+ actorAccountId: input.accountId,
191+ operation: sourceOps.create,
192+ });
193+
194+ return synchronizedSource;
195+ }));
193196
194197 export const getSource = (input: {
195198 workspaceId: WorkspaceId;
196199 sourceId: SourceId;
200+ accountId: AccountId;
197201 }) =>
198202 Effect.flatMap(ControlPlaneStore, (store) =>
199− Effect.flatMap(Actor, (actor) =>
200− loadSourceById(store, {
203+ Effect.gen(function* () {
204+ return yield* loadSourceById(store, {
201205 workspaceId: input.workspaceId,
202206 sourceId: input.sourceId,
203− actorAccountId: actor.principal.accountId,
207+ actorAccountId: input.accountId,
204208 }).pipe(
205209 Effect.mapError((cause) =>
206210 cause instanceof Error && cause.message.startsWith("Source not found:")
@@ -213,63 +217,64 @@ export const getSource = (input: { · 6 unmodified lines
213217 "Failed projecting stored source",
214218 ),
215219 ),
216− ),
217− ));
220+ );
221+ }));
218222
219223 export const updateSource = (input: {
220224 workspaceId: WorkspaceId;
221225 sourceId: SourceId;
226+ accountId: AccountId;
222227 payload: UpdateSourcePayload;
223228 }) =>
224229 Effect.flatMap(ControlPlaneStore, (store) =>
225− Effect.flatMap(Actor, (actor) =>
226− Effect.gen(function* () {
227− const existingSource = yield* loadSourceById(store, {
228− workspaceId: input.workspaceId,
229− sourceId: input.sourceId,
230− actorAccountId: actor.principal.accountId,
231− }).pipe(
232− Effect.mapError((cause) =>
233− cause instanceof Error && cause.message.startsWith("Source not found:")
234− ? sourceOps.update.notFound(
235− "Source not found",
236− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
237− )
238− : sourceOps.update.unknownStorage(
239− cause,
240− "Failed projecting stored source",
241− ),
242− ),
243− );
230+ Effect.gen(function* () {
231+ const existingSource = yield* loadSourceById(store, {
232+ workspaceId: input.workspaceId,
233+ sourceId: input.sourceId,
234+ actorAccountId: input.accountId,
235+ }).pipe(
236+ Effect.mapError((cause) =>
237+ cause instanceof Error && cause.message.startsWith("Source not found:")
238+ ? sourceOps.update.notFound(
239+ "Source not found",
240+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
241+ )
242+ : sourceOps.update.unknownStorage(
243+ cause,
244+ "Failed projecting stored source",
245+ ),
246+ ),
247+ );
244248
245− const updatedSource = yield* updateSourceFromPayload({
246− source: existingSource,
247− payload: input.payload,
248− now: Date.now(),
249− }).pipe(
250− Effect.mapError((cause) =>
251− sourceOps.update.badRequest(
252− "Invalid source definition",
253− cause instanceof Error ? cause.message : String(cause),
254− ),
249+ const updatedSource = yield* updateSourceFromPayload({
250+ source: existingSource,
251+ payload: input.payload,
252+ now: Date.now(),
253+ }).pipe(
254+ Effect.mapError((cause) =>
255+ sourceOps.update.badRequest(
256+ "Invalid source definition",
257+ cause instanceof Error ? cause.message : String(cause),
255258 ),
256− );
259+ ),
260+ );
257261
258− yield* mapPersistenceError(
259− sourceOps.update.child("persist"),
260− persistSource(store, updatedSource, {
261− actorAccountId: actor.principal.accountId,
262− }),
263− );
262+ const persistedSource = yield* mapPersistenceError(
263+ sourceOps.update.child("persist"),
264+ persistSource(store, updatedSource, {
265+ actorAccountId: input.accountId,
266+ }),
267+ );
264268
265− return yield* syncArtifactsForSource({
266− store,
267− source: updatedSource,
268− actorAccountId: actor.principal.accountId,
269− operation: sourceOps.update,
270− });
271− }),
272− ));
269+ const synchronizedSource = yield* syncArtifactsForSource({
270+ store,
271+ source: persistedSource,
272+ actorAccountId: input.accountId,
273+ operation: sourceOps.update,
274+ });
275+
276+ return synchronizedSource;
277+ }));
273278
274279 export const removeSource = (input: {
275280 workspaceId: WorkspaceId;
packages/control-plane/src/runtime/test-http-client.ts+5−18
@@ -1,8 +1,6 @@
11 import {
22 HttpApiBuilder,
33 HttpApiClient,
4− HttpClient,
5− HttpClientRequest,
64 } from "@effect/platform";
75 import { NodeHttpServer } from "@effect/platform-node";
86 import * as Effect from "effect/Effect";
@@ -14,11 +12,10 @@ import { · 5 unmodified lines
1412 } from "#api";
1513
1614 import {
17− ControlPlaneAuthHeaders,
18− type SqlControlPlaneRuntime,
15+ type ControlPlaneRuntime,
1916 } from "./index";
2017
21−const createClientLayer = (runtime: SqlControlPlaneRuntime) => {
18+const createClientLayer = (runtime: ControlPlaneRuntime) => {
2219 const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer);
2320
2421 return HttpApiBuilder.serve().pipe(
@@ -28,19 +25,9 @@ const createClientLayer = (runtime: SqlControlPlaneRuntime) => { · 3 unmodified lines
2825 };
2926
3027 const createControlPlaneClient = (accountId?: string) =>
28+ (void accountId,
3129 HttpApiClient.make(ControlPlaneApi, {
32− transformClient: accountId
33− ? (client) =>
34− client.pipe(
35− HttpClient.mapRequest(
36− HttpClientRequest.setHeader(
37− ControlPlaneAuthHeaders.accountId,
38− accountId,
39− ),
40− ),
41− )
42− : undefined,
43− });
30+ }));
4431
4532 type ControlPlaneClient = Effect.Effect.Success<
4633 ReturnType<typeof createControlPlaneClient>
@@ -48,7 +35,7 @@ type ControlPlaneClient = Effect.Effect.Success< · 1 unmodified line
4835
4936 export const withControlPlaneClient = <A, E>(
5037 input: {
51− runtime: SqlControlPlaneRuntime;
38+ runtime: ControlPlaneRuntime;
5239 accountId?: string;
5340 },
5441 f: (client: ControlPlaneClient) => Effect.Effect<A, E, never>,
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+316−197
@@ -1,5 +1,8 @@
11 import { randomUUID } from "node:crypto";
2+import { mkdtempSync } from "node:fs";
23 import { createServer } from "node:http";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
36
47 import {
58 HttpApi,
@@ -45,7 +48,6 @@ import * as Effect from "effect/Effect"; · 39 unmodified lines
4548 import * as Exit from "effect/Exit";
4649 import * as Layer from "effect/Layer";
4750 import * as Scope from "effect/Scope";
48−import * as Option from "effect/Option";
4951 import { Schema } from "effect";
5052 import * as z from "zod/v4";
5153
@@ -59,9 +61,28 @@ import { · 7 unmodified lines
5961 createRuntimeSourceAuthService,
6062 type RuntimeSourceAuthService,
6163 } from "./source-auth-service";
64+import {
65+ loadLocalExecutorConfig,
66+ resolveLocalWorkspaceContext,
67+ writeProjectLocalExecutorConfig,
68+} from "./local-config";
6269 import { createLiveExecutionManager } from "./live-execution";
63−import { persistSource } from "./source-store";
64−import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization";
70+import {
71+ RuntimeLocalWorkspaceService,
72+ type RuntimeLocalWorkspaceState,
73+} from "./local-runtime-context";
74+import {
75+ buildLocalSourceArtifact,
76+ readLocalSourceArtifact,
77+ writeLocalSourceArtifact,
78+} from "./local-source-artifacts";
79+import {
80+ loadLocalWorkspaceState,
81+ writeLocalWorkspaceState,
82+} from "./local-workspace-state";
83+import {
84+ materializationFromMcpManifestEntries,
85+} from "./source-adapters/mcp";
6586 import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
6687
6788 type CountedMcpServer = {
@@ -579,7 +600,7 @@ const makeGraphqlServer = Effect.acquireRelease( · 511 unmodified lines
579600 );
580601
581602 const persistConnectedEchoTool = (input: {
582− persistence: SqlControlPlanePersistence;
603+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState;
583604 endpoint: string;
584605 workspaceId: WorkspaceId;
585606 sourceId: SourceId;
@@ -610,32 +631,70 @@ const persistConnectedEchoTool = (input: { · 24 unmodified lines
610631 updatedAt: now,
611632 };
612633
613− yield* persistSource(input.persistence.rows, source);
614− yield* persistMcpRecipeMaterializationFromManifest({
615− rows: input.persistence.rows,
616− source,
617− manifestEntries: [
618− {
619− toolId: "echo",
620− toolName: "echo",
621− description: "Echo the provided string",
622− inputSchemaJson: JSON.stringify({
623− type: "object",
624− properties: {
625− value: {
626− type: "string",
627− },
634+ yield* writeProjectLocalExecutorConfig({
635+ context: input.runtimeLocalWorkspace.context,
636+ config: {
637+ sources: {
638+ [input.sourceId]: {
639+ kind: "mcp",
640+ name: source.name,
641+ ...(source.namespace ? { namespace: source.namespace } : {}),
642+ connection: {
643+ endpoint: source.endpoint,
628644 },
629− required: ["value"],
630− additionalProperties: false,
631− }),
645+ binding: {
646+ transport: "streamable-http",
647+ },
648+ },
632649 },
633− ],
650+ },
634651 });
635− });
652+ yield* writeLocalSourceArtifact({
653+ context: input.runtimeLocalWorkspace.context,
654+ sourceId: source.id,
655+ artifact: buildLocalSourceArtifact({
656+ source,
657+ materialization: materializationFromMcpManifestEntries({
658+ recipeRevisionId: "src_recipe_rev_materialization" as never,
659+ endpoint: source.endpoint,
660+ manifestEntries: [
661+ {
662+ toolId: "echo",
663+ toolName: "echo",
664+ description: "Echo the provided string",
665+ inputSchemaJson: JSON.stringify({
666+ type: "object",
667+ properties: {
668+ value: { type: "string" },
669+ },
670+ required: ["value"],
671+ additionalProperties: false,
672+ }),
673+ },
674+ ],
675+ }),
676+ }),
677+ });
678+ yield* writeLocalWorkspaceState({
679+ context: input.runtimeLocalWorkspace.context,
680+ state: {
681+ version: 1,
682+ sources: {
683+ [input.sourceId]: {
684+ status: "connected",
685+ lastError: null,
686+ sourceHash: null,
687+ createdAt: now,
688+ updatedAt: now,
689+ },
690+ },
691+ policies: {},
692+ },
693+ });
694+ }) as Effect.Effect<void, Error, never>;
636695
637696 const persistConnectedGithubOpenApiSource = (input: {
638− persistence: SqlControlPlanePersistence;
697+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState;
639698 workspaceId: WorkspaceId;
640699 sourceId: SourceId;
641700 endpoint?: string;
@@ -831,7 +890,22 @@ const persistConnectedGithubOpenApiSource = (input: { · 189 unmodified lines
831890 updatedAt: now,
832891 };
833892
834− yield* persistSource(input.persistence.rows, source);
893+ const configAuth =
894+ source.auth.kind === "bearer"
895+ ? source.auth.token.providerId === "params"
896+ ? {
897+ source: "params" as const,
898+ provider: "params",
899+ id: source.auth.token.handle,
900+ }
901+ : source.auth.token.providerId === "env"
902+ ? source.auth.token.handle
903+ : {
904+ source: "env" as const,
905+ provider: "env",
906+ id: source.auth.token.handle,
907+ }
908+ : undefined;
835909
836910 const manifest = yield* extractOpenApiManifest(
837911 source.name,
@@ -842,131 +916,165 @@ const persistConnectedGithubOpenApiSource = (input: { · 4 unmodified lines
842916 ),
843917 );
844918 const definitions = compileOpenApiToolDefinitions(manifest);
845− const sourceRecord =
846− yield* input.persistence.rows.sources.getByWorkspaceAndId(
847− input.workspaceId,
848− input.sourceId,
849− );
850− if (Option.isNone(sourceRecord)) {
851− return yield* Effect.fail(
852− new Error(`Missing stored source ${input.sourceId}`),
853− );
854− }
855−
856− yield* input.persistence.rows.sourceRecipeRevisions.update(
857− sourceRecord.value.recipeRevisionId,
858− {
859− manifestJson: JSON.stringify(manifest),
860− manifestHash: manifest.sourceHash,
861− updatedAt: now,
862− },
863− );
864− yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({
865− recipeRevisionId: sourceRecord.value.recipeRevisionId,
866− documents: [
867− {
868− id: `src_recipe_doc_${randomUUID()}`,
869− recipeRevisionId: sourceRecord.value.recipeRevisionId,
870− documentKind: "openapi",
871− documentKey:
872− typeof source.binding.specUrl === "string"
873− ? source.binding.specUrl
874− : source.endpoint,
875− contentText: openApiDocumentText,
876− contentHash: manifest.sourceHash,
877− fetchedAt: now,
878− createdAt: now,
879− updatedAt: now,
919+ yield* writeProjectLocalExecutorConfig({
920+ context: input.runtimeLocalWorkspace.context,
921+ config: {
922+ sources: {
923+ [input.sourceId]: {
924+ kind: "openapi",
925+ name: source.name,
926+ ...(source.namespace ? { namespace: source.namespace } : {}),
927+ connection: {
928+ endpoint: source.endpoint,
929+ ...(configAuth !== undefined ? { auth: configAuth } : {}),
930+ },
931+ binding: {
932+ specUrl: String(source.binding.specUrl),
933+ defaultHeaders: null,
934+ },
935+ },
880936 },
881− ],
937+ },
882938 });
883939 const refEntries = Object.entries(manifest.refHintTable ?? {});
884− yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
885− recipeRevisionId: sourceRecord.value.recipeRevisionId,
886− bundles:
887− refEntries.length > 0
888− ? [
889− {
890− id: SourceRecipeSchemaBundleIdSchema.make(
891− `src_recipe_bundle_${randomUUID()}`,
892− ),
893− recipeRevisionId: sourceRecord.value.recipeRevisionId,
894− bundleKind: "json_schema_ref_map",
895− refsJson: JSON.stringify(
896− Object.fromEntries(
897− refEntries.map(([ref, rawValue]) => {
898− try {
899− return [ref, JSON.parse(rawValue) as unknown];
900− } catch {
901− return [ref, rawValue];
902− }
903− }),
940+ yield* writeLocalSourceArtifact({
941+ context: input.runtimeLocalWorkspace.context,
942+ sourceId: source.id,
943+ artifact: buildLocalSourceArtifact({
944+ source: {
945+ ...source,
946+ sourceHash: manifest.sourceHash,
947+ },
948+ materialization: {
949+ manifestJson: JSON.stringify(manifest),
950+ manifestHash: manifest.sourceHash,
951+ sourceHash: manifest.sourceHash,
952+ documents: [
953+ {
954+ id: `src_recipe_doc_${randomUUID()}`,
955+ recipeRevisionId: "src_recipe_rev_materialization" as never,
956+ documentKind: "openapi",
957+ documentKey:
958+ typeof source.binding.specUrl === "string"
959+ ? source.binding.specUrl
960+ : source.endpoint,
961+ contentText: openApiDocumentText,
962+ contentHash: manifest.sourceHash,
963+ fetchedAt: now,
964+ createdAt: now,
965+ updatedAt: now,
966+ },
967+ ],
968+ schemaBundles:
969+ refEntries.length > 0
970+ ? [
971+ {
972+ id: SourceRecipeSchemaBundleIdSchema.make(
973+ `src_recipe_bundle_${randomUUID()}`,
974+ ),
975+ recipeRevisionId: "src_recipe_rev_materialization" as never,
976+ bundleKind: "json_schema_ref_map",
977+ refsJson: JSON.stringify(
978+ Object.fromEntries(
979+ refEntries.map(([ref, rawValue]) => {
980+ try {
981+ return [ref, JSON.parse(rawValue) as unknown];
982+ } catch {
983+ return [ref, rawValue];
984+ }
985+ }),
986+ ),
987+ ),
988+ contentHash: manifest.sourceHash,
989+ createdAt: now,
990+ updatedAt: now,
991+ },
992+ ]
993+ : [],
994+ operations: definitions.map((definition) => {
995+ const presentation = buildOpenApiToolPresentation({
996+ definition,
997+ });
998+ const method = definition.method.toUpperCase();
999+
1000+ return {
1001+ id: `src_recipe_op_${randomUUID()}`,
1002+ recipeRevisionId: "src_recipe_rev_materialization" as never,
1003+ operationKey: definition.toolId,
1004+ transportKind: "http",
1005+ toolId: definition.toolId,
1006+ title: definition.name,
1007+ description: definition.description,
1008+ operationKind:
1009+ method === "GET" || method === "HEAD"
1010+ ? "read"
1011+ : method === "DELETE"
1012+ ? "delete"
1013+ : "write",
1014+ searchText: normalizeSearchText(
1015+ definition.toolId,
1016+ definition.name,
1017+ definition.description,
1018+ definition.rawToolId,
1019+ definition.operationId ?? undefined,
1020+ definition.method,
1021+ definition.path,
1022+ definition.group,
1023+ definition.leaf,
1024+ definition.tags.join(" "),
9041025 ),
905− ),
906− contentHash: manifest.sourceHash,
907− createdAt: now,
908− updatedAt: now,
909− },
910− ]
911− : [],
912− });
913− yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({
914− recipeRevisionId: sourceRecord.value.recipeRevisionId,
915− operations: definitions.map((definition) => {
916− const presentation = buildOpenApiToolPresentation({
917− definition,
918− });
919− const method = definition.method.toUpperCase();
920−
921− return {
922− id: `src_recipe_op_${randomUUID()}`,
923− recipeRevisionId: sourceRecord.value.recipeRevisionId,
924− operationKey: definition.toolId,
925− transportKind: "http",
926− toolId: definition.toolId,
927− title: definition.name,
928− description: definition.description,
929− operationKind:
930− method === "GET" || method === "HEAD"
931− ? "read"
932− : method === "DELETE"
933− ? "delete"
934− : "write",
935− searchText: normalizeSearchText(
936− definition.toolId,
937− definition.name,
938− definition.description,
939− definition.rawToolId,
940− definition.operationId ?? undefined,
941− definition.method,
942− definition.path,
943− definition.group,
944− definition.leaf,
945− definition.tags.join(" "),
946− ),
947− inputSchemaJson: presentation.inputSchemaJson ?? null,
948− outputSchemaJson: presentation.outputSchemaJson ?? null,
949− providerKind: "openapi",
950− providerDataJson: presentation.providerDataJson,
951− createdAt: now,
952− updatedAt: now,
953− };
954− }),
1026+ inputSchemaJson: presentation.inputSchemaJson ?? null,
1027+ outputSchemaJson: presentation.outputSchemaJson ?? null,
1028+ providerKind: "openapi",
1029+ providerDataJson: presentation.providerDataJson,
1030+ createdAt: now,
1031+ updatedAt: now,
1032+ };
1033+ }),
1034+ },
1035+ }),
9551036 });
956− yield* input.persistence.rows.sources.update(
957− input.workspaceId,
958− input.sourceId,
959− {
960− sourceHash: manifest.sourceHash,
961− updatedAt: now,
1037+ yield* writeLocalWorkspaceState({
1038+ context: input.runtimeLocalWorkspace.context,
1039+ state: {
1040+ version: 1,
1041+ sources: {
1042+ [input.sourceId]: {
1043+ status: "connected",
1044+ lastError: null,
1045+ sourceHash: manifest.sourceHash,
1046+ createdAt: now,
1047+ updatedAt: now,
1048+ },
1049+ },
1050+ policies: {},
9621051 },
963− );
1052+ });
9641053
9651054 return;
1055+ }) as Effect.Effect<void, Error, never>;
1056+
1057+const makeRuntimeLocalWorkspaceState = (workspaceId: WorkspaceId): Effect.Effect<RuntimeLocalWorkspaceState, Error, never> =>
1058+ Effect.gen(function* () {
1059+ const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-workspace-env-"));
1060+ const context = yield* resolveLocalWorkspaceContext({ workspaceRoot });
1061+ const loadedConfig = yield* loadLocalExecutorConfig(context);
1062+ return {
1063+ context,
1064+ installation: {
1065+ workspaceId,
1066+ accountId: AccountIdSchema.make("acc_local_test"),
1067+ },
1068+ loadedConfig,
1069+ } satisfies RuntimeLocalWorkspaceState;
9661070 });
9671071
968−const makeResolver = (persistence: SqlControlPlanePersistence) =>
969− createWorkspaceExecutionEnvironmentResolver({
1072+const makeResolver = (
1073+ persistence: SqlControlPlanePersistence,
1074+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1075+) =>
1076+ ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>
1077+ createWorkspaceExecutionEnvironmentResolver({
9701078 rows: persistence.rows,
9711079 sourceAuthService: {
9721080 getLocalServerBaseUrl: () => null,
@@ -983,19 +1091,25 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 10 unmodified lines
9831091 completeSourceCredentialSetup: () =>
9841092 Effect.fail(new Error("not implemented in test")),
9851093 } as RuntimeSourceAuthService,
986− });
1094+ })(input).pipe(
1095+ Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
1096+ ));
9871097
9881098 const makeResolverWithLiveSourceAuth = (
9891099 persistence: SqlControlPlanePersistence,
1100+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
9901101 ) =>
991− createWorkspaceExecutionEnvironmentResolver({
1102+ ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>
1103+ createWorkspaceExecutionEnvironmentResolver({
9921104 rows: persistence.rows,
9931105 sourceAuthService: createRuntimeSourceAuthService({
9941106 rows: persistence.rows,
9951107 liveExecutionManager: createLiveExecutionManager(),
9961108 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
9971109 }),
998− });
1110+ })(input).pipe(
1111+ Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
1112+ ));
9991113
10001114 describe("workspace-execution-environment", () => {
10011115 it.scoped("discovers persisted tools without live MCP listing", () =>
@@ -1004,8 +1118,9 @@ describe("workspace-execution-environment", () => { · 2 unmodified lines
10041118 const persistence = yield* makePersistence;
10051119 const workspaceId = WorkspaceIdSchema.make("ws_catalog");
10061120 const sourceId = SourceIdSchema.make("src_catalog");
1121+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
10071122 yield* persistConnectedEchoTool({
1008− persistence,
1123+ runtimeLocalWorkspace,
10091124 endpoint: server.endpoint,
10101125 workspaceId,
10111126 sourceId,
@@ -1017,7 +1132,7 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines
10171132 cause instanceof Error ? cause : new Error(String(cause)),
10181133 }).pipe(Effect.orDie);
10191134
1020− const resolveEnvironment = makeResolver(persistence);
1135+ const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
10211136 const environment = yield* resolveEnvironment({
10221137 workspaceId,
10231138 accountId: AccountIdSchema.make("acc_catalog"),
@@ -1050,13 +1165,14 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines
10501165 const persistence = yield* makePersistence;
10511166 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
10521167 const sourceId = SourceIdSchema.make("src_github_discovery");
1168+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
10531169 yield* persistConnectedGithubOpenApiSource({
1054− persistence,
1170+ runtimeLocalWorkspace,
10551171 workspaceId,
10561172 sourceId,
10571173 });
10581174
1059− const resolveEnvironment = makeResolver(persistence);
1175+ const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
10601176 const environment = yield* resolveEnvironment({
10611177 workspaceId,
10621178 accountId: AccountIdSchema.make("acc_github_discovery"),
@@ -1103,14 +1219,15 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
11031219 const persistence = yield* makePersistence;
11041220 const workspaceId = WorkspaceIdSchema.make("ws_invoke");
11051221 const sourceId = SourceIdSchema.make("src_invoke");
1222+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
11061223 yield* persistConnectedEchoTool({
1107− persistence,
1224+ runtimeLocalWorkspace,
11081225 endpoint: server.endpoint,
11091226 workspaceId,
11101227 sourceId,
11111228 });
11121229
1113− const resolveEnvironment = makeResolver(persistence);
1230+ const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
11141231 const environment = yield* resolveEnvironment({
11151232 workspaceId,
11161233 accountId: AccountIdSchema.make("acc_invoke"),
@@ -1139,7 +1256,8 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines
11391256 const specServer = yield* makeOpenApiSpecServer;
11401257 const persistence = yield* makePersistence;
11411258 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1142− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1259+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1260+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
11431261 const now = Date.now();
11441262 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
11451263 "sec_test_openapi_bearer",
@@ -1220,21 +1338,22 @@ describe("workspace-execution-environment", () => { · 74 unmodified lines
12201338 encodeURIComponent("exec_add_openapi:executor.sources.add:"),
12211339 );
12221340
1223− const storedSource =
1224− yield* persistence.rows.sources.getByWorkspaceAndId(
1225− workspaceId,
1226− added.id,
1227− );
1228− expect(Option.isSome(storedSource)).toBe(true);
1229− if (Option.isSome(storedSource)) {
1230− const documents =
1231− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(
1232− storedSource.value.recipeRevisionId,
1233− );
1234− expect(documents[0]?.contentText).toContain(
1235− '"operationId":"repos.getRepo"',
1236− );
1237− }
1341+ const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context);
1342+ expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({
1343+ kind: "openapi",
1344+ connection: {
1345+ endpoint: `${specServer.baseUrl}/`,
1346+ },
1347+ });
1348+ const artifact = yield* readLocalSourceArtifact({
1349+ context: runtimeLocalWorkspace.context,
1350+ sourceId: added.id,
1351+ });
1352+ expect(artifact?.documents[0]?.contentText).toContain(
1353+ '"operationId":"repos.getRepo"',
1354+ );
1355+ const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context);
1356+ expect(workspaceState.sources[added.id]?.status).toBe("connected");
12381357
12391358 const freshEnvironment = yield* resolveEnvironment({
12401359 workspaceId,
@@ -1280,7 +1399,8 @@ describe("workspace-execution-environment", () => { · 39 unmodified lines
12801399 const graphqlServer = yield* makeGraphqlServer;
12811400 const persistence = yield* makePersistence;
12821401 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1283− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1402+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1403+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
12841404 const now = Date.now();
12851405 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
12861406 "sec_test_graphql_bearer",
@@ -1339,33 +1459,28 @@ describe("workspace-execution-environment", () => { · 52 unmodified lines
13391459 expect(added.auth.kind).toBe("bearer");
13401460 expect(added.auth.token?.providerId).toBe("postgres");
13411461
1342− const storedSource =
1343− yield* persistence.rows.sources.getByWorkspaceAndId(
1344− workspaceId,
1345− added.id,
1346− );
1347− expect(Option.isSome(storedSource)).toBe(true);
1348− if (Option.isSome(storedSource)) {
1349− const documents =
1350− yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(
1351− storedSource.value.recipeRevisionId,
1352− );
1353− expect(documents[0]?.contentText).toContain('"__schema"');
1354− expect(
1355− (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(
1356− storedSource.value.recipeRevisionId,
1357− )).length,
1358− ).toBe(1);
1462+ const artifact = yield* readLocalSourceArtifact({
1463+ context: runtimeLocalWorkspace.context,
1464+ sourceId: added.id,
1465+ });
1466+ expect(artifact?.documents[0]?.contentText).toContain('"__schema"');
1467+ expect(artifact?.schemaBundles.length).toBe(1);
1468+ if (artifact === null) {
1469+ throw new Error(`Missing local source artifact for ${added.id}`);
13591470 }
13601471
1361− const connectedSourceRecord = Option.getOrThrow(storedSource);
1362− yield* persistence.rows.sourceRecipeRevisions.update(
1363− connectedSourceRecord.recipeRevisionId,
1364− {
1365− manifestJson: null,
1366− updatedAt: Date.now(),
1472+ yield* writeLocalSourceArtifact({
1473+ context: runtimeLocalWorkspace.context,
1474+ sourceId: added.id,
1475+ artifact: {
1476+ ...artifact,
1477+ revision: {
1478+ ...artifact.revision,
1479+ manifestJson: null,
1480+ updatedAt: Date.now(),
1481+ },
13671482 },
1368− );
1483+ });
13691484
13701485 const freshEnvironment = yield* resolveEnvironment({
13711486 workspaceId,
@@ -1515,9 +1630,10 @@ describe("workspace-execution-environment", () => { · 143 unmodified lines
15151630 const persistence = yield* makePersistence;
15161631 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");
15171632 const sourceId = SourceIdSchema.make("src_params_auth");
1633+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
15181634
15191635 yield* persistConnectedGithubOpenApiSource({
1520− persistence,
1636+ runtimeLocalWorkspace,
15211637 workspaceId,
15221638 sourceId,
15231639 endpoint: specServer.baseUrl,
@@ -1527,7 +1643,7 @@ describe("workspace-execution-environment", () => { · 3 unmodified lines
15271643 },
15281644 });
15291645
1530− const resolveEnvironment = makeResolver(persistence);
1646+ const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
15311647 const environment = yield* resolveEnvironment({
15321648 workspaceId,
15331649 accountId: AccountIdSchema.make("acc_params_auth"),
@@ -1567,14 +1683,15 @@ describe("workspace-execution-environment", () => { · 33 unmodified lines
15671683 "ws_openapi_describe_bundle",
15681684 );
15691685 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");
1686+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
15701687
15711688 yield* persistConnectedGithubOpenApiSource({
1572− persistence,
1689+ runtimeLocalWorkspace,
15731690 workspaceId,
15741691 sourceId,
15751692 });
15761693
1577− const resolveEnvironment = makeResolver(persistence);
1694+ const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
15781695 const environment = yield* resolveEnvironment({
15791696 workspaceId,
15801697 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),
@@ -1621,10 +1738,12 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
16211738 () =>
16221739 Effect.gen(function* () {
16231740 const persistence = yield* makePersistence;
1624− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1741+ const workspaceId = WorkspaceIdSchema.make("ws_describe_add_source");
1742+ const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1743+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
16251744
16261745 const environment = yield* resolveEnvironment({
1627− workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"),
1746+ workspaceId,
16281747 accountId: AccountIdSchema.make("acc_describe_add_source"),
16291748 executionId: ExecutionIdSchema.make("exec_describe_add_source"),
16301749 });
packages/control-plane/src/runtime/workspace-execution-environment.ts+92−81
@@ -37,8 +37,10 @@ import { · 36 unmodified lines
3737 type RuntimeSourceAuthService,
3838 } from "./source-auth-service";
3939 import {
40+ loadSourceWithRecipe,
4041 loadWorkspaceSourceRecipeToolByPath,
4142 loadWorkspaceSourceRecipeToolIndex,
43+ loadWorkspaceSourceRecipes,
4244 type LoadedSourceRecipeToolIndexEntry,
4345 } from "./source-recipes-runtime";
4446 import { resolveSourceAuthMaterial } from "./source-auth-material";
@@ -53,6 +55,14 @@ import { · 8 unmodified lines
5355 evaluateInvocationPolicy,
5456 type InvocationDescriptor,
5557 } from "./invocation-policy-engine";
58+import {
59+ loadRuntimeLocalWorkspacePolicies,
60+} from "./policies-operations";
61+import {
62+ getRuntimeLocalWorkspaceOption,
63+ RuntimeLocalWorkspaceService,
64+ type RuntimeLocalWorkspaceState,
65+} from "./local-runtime-context";
5666
5767 const asToolPath = (value: string): ToolPath => value as ToolPath;
5868
@@ -180,31 +190,38 @@ const loadWorkspaceSchemaBundle = (input: { · 121 unmodified lines
180190 id: SourceRecipeSchemaBundleId;
181191 }): Effect.Effect<ToolSchemaBundle | null, Error, never> =>
182192 Effect.gen(function* () {
183− const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(
184− input.id,
185− );
186− if (Option.isNone(bundle)) {
187− return null;
193+ const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
194+ if (runtimeLocalWorkspace !== null) {
195+ const recipes = yield* loadWorkspaceSourceRecipes({
196+ rows: input.rows,
197+ workspaceId: input.workspaceId,
198+ });
199+ const localBundle = recipes
200+ .flatMap((recipe) => recipe.schemaBundles)
201+ .find((schemaBundle) => schemaBundle.id === input.id);
202+ return localBundle
203+ ? {
204+ id: localBundle.id,
205+ kind: localBundle.bundleKind,
206+ hash: localBundle.contentHash,
207+ refsJson: localBundle.refsJson,
208+ }
209+ : null;
188210 }
189211
190− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
191− input.workspaceId,
192− );
193− if (
194− !sourceRecords.some(
195− (sourceRecord) =>
196− sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
197− )
198− ) {
199− return null;
212+ const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(input.id);
213+ if (Option.isSome(bundle)) {
214+ const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
215+ if (sourceRecords.some((sourceRecord) => sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId)) {
216+ return {
217+ id: bundle.value.id,
218+ kind: bundle.value.bundleKind,
219+ hash: bundle.value.contentHash,
220+ refsJson: bundle.value.refsJson,
221+ };
222+ }
200223 }
201−
202− return {
203− id: bundle.value.id,
204− kind: bundle.value.bundleKind,
205− hash: bundle.value.contentHash,
206− refsJson: bundle.value.refsJson,
207− };
224+ return null;
208225 });
209226
210227 const scoreRecipeTool = (
@@ -373,41 +390,18 @@ const authorizePersistedToolInvocation = (input: { · 162 unmodified lines
373390 >[0]["onElicitation"];
374391 }): Effect.Effect<void, Error, never> =>
375392 Effect.gen(function* () {
376− const workspace = yield* input.rows.workspaces
377− .getById(input.workspaceId)
378− .pipe(
379− Effect.mapError((cause) =>
380− cause instanceof Error ? cause : new Error(String(cause)),
381− ),
382− );
383− const policies = Option.isSome(workspace)
384− ? yield* input.rows.policies
385− .listForWorkspaceContext({
386− organizationId: workspace.value.organizationId,
387− workspaceId: input.workspaceId,
388− })
389− .pipe(
390− Effect.mapError((cause) =>
391− cause instanceof Error ? cause : new Error(String(cause)),
392− ),
393− )
394− : [];
393+ const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
394+ Effect.mapError((cause) =>
395+ cause instanceof Error ? cause : new Error(String(cause)),
396+ ),
397+ );
395398
396399 const decision = evaluateInvocationPolicy({
397400 descriptor: input.descriptor,
398401 args: input.args,
399− policies,
402+ policies: localWorkspacePolicies.policies,
400403 context: {
401404 workspaceId: input.workspaceId,
402− organizationId: Option.isSome(workspace)
403− ? workspace.value.organizationId
404− : ("org_unknown" as never),
405− accountId: input.accountId,
406− clientId:
407− typeof input.context?.clientId === "string" &&
408− input.context.clientId.length > 0
409− ? input.context.clientId
410− : null,
411405 },
412406 });
413407
@@ -471,14 +465,28 @@ const authorizePersistedToolInvocation = (input: { · 57 unmodified lines
471465 }
472466 });
473467
468+const provideRuntimeLocalWorkspace = <A, E, R>(
469+ effect: Effect.Effect<A, E, R>,
470+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null,
471+): Effect.Effect<A, E, R> =>
472+ runtimeLocalWorkspace === null
473+ ? effect
474+ : effect.pipe(
475+ Effect.provideService(
476+ RuntimeLocalWorkspaceService,
477+ runtimeLocalWorkspace,
478+ ),
479+ );
480+
474481 const createWorkspaceToolCatalog = (input: {
475482 workspaceId: Source["workspaceId"];
476483 accountId: AccountId;
477484 rows: SqlControlPlaneRows;
478485 executorCatalog: ToolCatalog;
486+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
479487 }): ToolCatalog => ({
480488 listNamespaces: ({ limit }) =>
481− Effect.gen(function* () {
489+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
482490 const [recipeTools, executor] = yield* Effect.all([
483491 loadWorkspaceRecipeTools({
484492 rows: input.rows,
@@ -513,10 +521,10 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines
513521 return [...merged.values()]
514522 .sort((left, right) => left.namespace.localeCompare(right.namespace))
515523 .slice(0, limit);
516− }),
524+ }), input.runtimeLocalWorkspace),
517525
518526 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
519− Effect.gen(function* () {
527+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
520528 const [recipeTools, executor] = yield* Effect.all([
521529 loadWorkspaceRecipeTools({
522530 rows: input.rows,
@@ -549,10 +557,10 @@ const createWorkspaceToolCatalog = (input: { · 26 unmodified lines
549557 return [...recipeTools.map((tool) => tool.descriptor), ...executor]
550558 .sort((left, right) => left.path.localeCompare(right.path))
551559 .slice(0, limit);
552− }),
560+ }), input.runtimeLocalWorkspace),
553561
554562 getToolByPath: ({ path, includeSchemas }) =>
555− Effect.gen(function* () {
563+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
556564 const executor = yield* input.executorCatalog.getToolByPath({
557565 path,
558566 includeSchemas,
@@ -569,17 +577,17 @@ const createWorkspaceToolCatalog = (input: { · 10 unmodified lines
569577 includeSchemas,
570578 });
571579 return recipeTool?.descriptor ?? null;
572− }),
580+ }), input.runtimeLocalWorkspace),
573581
574582 getSchemaBundle: ({ id }) =>
575− loadWorkspaceSchemaBundle({
583+ provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({
576584 rows: input.rows,
577585 workspaceId: input.workspaceId,
578586 id: SourceRecipeSchemaBundleIdSchema.make(id),
579− }),
587+ }), input.runtimeLocalWorkspace),
580588
581589 searchTools: ({ query, namespace, limit }) =>
582− Effect.gen(function* () {
590+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
583591 const queryTokens = tokenize(query);
584592 const [recipeTools, executor] = yield* Effect.all([
585593 loadWorkspaceRecipeTools({
@@ -614,7 +622,7 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines
614622 right.score - left.score || left.path.localeCompare(right.path),
615623 )
616624 .slice(0, limit);
617− }),
625+ }), input.runtimeLocalWorkspace),
618626 });
619627
620628 const createWorkspaceToolInvoker = (input: {
@@ -623,6 +631,7 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
623631 rows: SqlControlPlaneRows;
624632 resolveSecretMaterial: ResolveSecretMaterial;
625633 sourceAuthService: RuntimeSourceAuthService;
634+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
626635 onElicitation?: Parameters<
627636 typeof makeToolInvokerFromTools
628637 >[0]["onElicitation"];
@@ -634,6 +643,7 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines
634643 workspaceId: input.workspaceId,
635644 accountId: input.accountId,
636645 sourceAuthService: input.sourceAuthService,
646+ runtimeLocalWorkspace: input.runtimeLocalWorkspace,
637647 });
638648 const executorCatalog = createToolCatalogFromTools({
639649 tools: executorTools,
@@ -643,6 +653,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines
643653 accountId: input.accountId,
644654 rows: input.rows,
645655 executorCatalog,
656+ runtimeLocalWorkspace: input.runtimeLocalWorkspace,
646657 });
647658 const systemTools = createSystemToolMap({ catalog });
648659 const systemToolPaths = new Set(Object.keys(systemTools));
@@ -661,7 +672,7 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines
661672 args: unknown;
662673 context?: Record<string, unknown>;
663674 }) =>
664− Effect.gen(function* () {
675+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
665676 const recipeTool = yield* loadWorkspaceRecipeToolByPath({
666677 rows: input.rows,
667678 workspaceId: input.workspaceId,
@@ -700,17 +711,12 @@ const createWorkspaceToolInvoker = (input: { · 32 unmodified lines
700711 id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),
701712 })
702713 : null;
703− const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
704− input.workspaceId,
705− recipeTool.source.id,
706− );
707− let manifestJson: string | null = null;
708− if (Option.isSome(sourceRecord)) {
709− const revision = yield* input.rows.sourceRecipeRevisions.getById(
710− sourceRecord.value.recipeRevisionId,
711− );
712− manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null;
713− }
714+ const recipe = yield* loadSourceWithRecipe({
715+ rows: input.rows,
716+ workspaceId: input.workspaceId,
717+ sourceId: recipeTool.source.id,
718+ actorAccountId: input.accountId,
719+ });
714720
715721 return yield* getSourceAdapterForOperation(recipeTool.operation)
716722 .invokePersistedTool({
@@ -721,23 +727,26 @@ const createWorkspaceToolInvoker = (input: { · 4 unmodified lines
721727 path: invocation.path,
722728 operation: recipeTool.operation,
723729 schemaBundle,
724− manifestJson,
730+ manifestJson: recipe.revision.manifestJson,
725731 auth,
726732 args: invocation.args,
727733 context: invocation.context,
728734 onElicitation: input.onElicitation,
729735 });
730− });
736+ }), input.runtimeLocalWorkspace);
731737
732738 return {
733739 catalog,
734740 toolInvoker: {
735741 invoke: ({ path, args, context }) =>
736− systemToolPaths.has(path)
737− ? systemInvoker.invoke({ path, args, context })
738− : executorToolPaths.has(path)
739− ? executorInvoker.invoke({ path, args, context })
740− : invokePersistedTool({ path, args, context }),
742+ provideRuntimeLocalWorkspace(
743+ systemToolPaths.has(path)
744+ ? systemInvoker.invoke({ path, args, context })
745+ : executorToolPaths.has(path)
746+ ? executorInvoker.invoke({ path, args, context })
747+ : invokePersistedTool({ path, args, context }),
748+ input.runtimeLocalWorkspace,
749+ ),
741750 },
742751 };
743752 };
@@ -754,13 +763,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 10 unmodified lines
754763 });
755764
756765 return ({ workspaceId, accountId, onElicitation }) =>
757− Effect.sync(() => {
766+ Effect.gen(function* () {
767+ const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
758768 const { catalog, toolInvoker } = createWorkspaceToolInvoker({
759769 workspaceId,
760770 accountId,
761771 rows: input.rows,
762772 resolveSecretMaterial,
763773 sourceAuthService: input.sourceAuthService,
774+ runtimeLocalWorkspace,
764775 onElicitation,
765776 });
766777
packages/control-plane/src/schema/enums.ts+4−18
@@ -1,13 +1,3 @@
1−export {
2− OrganizationStatusSchema,
3− type OrganizationStatus,
4−} from "./models/auth/organization";
5−export {
6− OrganizationMemberStatusSchema,
7− RoleSchema,
8− type OrganizationMemberStatus,
9− type Role,
10−} from "./models/auth/organization-membership";
111 export {
122 SourceAuthSchema,
133 SourceBindingSchema,
@@ -72,12 +62,8 @@ export { · 58 unmodified lines
7262 type SourceAuthSessionStatus,
7363 } from "./models/source-auth-session";
7464 export {
75− PolicyApprovalModeSchema,
76− PolicyEffectSchema,
77− PolicyMatchTypeSchema,
78− PolicyResourceTypeSchema,
79− type PolicyApprovalMode,
80− type PolicyEffect,
81− type PolicyMatchType,
82− type PolicyResourceType,
65+ LocalWorkspacePolicyApprovalModeSchema,
66+ LocalWorkspacePolicyEffectSchema,
67+ type LocalWorkspacePolicyApprovalMode,
68+ type LocalWorkspacePolicyEffect,
8369 } from "./models/policy";
packages/control-plane/src/schema/ids.ts+0−8
@@ -1,10 +1,6 @@
11 import { Schema } from "effect";
22
33 export const AccountIdSchema = Schema.String.pipe(Schema.brand("AccountId"));
4−export const OrganizationIdSchema = Schema.String.pipe(Schema.brand("OrganizationId"));
5−export const OrganizationMemberIdSchema = Schema.String.pipe(
6− Schema.brand("OrganizationMemberId"),
7−);
84 export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));
95 export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));
106 export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId"));
@@ -31,15 +27,12 @@ export const SecretMaterialIdSchema = Schema.String.pipe( · 20 unmodified lines
3127 Schema.brand("SecretMaterialId"),
3228 );
3329 export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));
34−export const InstallationIdSchema = Schema.String.pipe(Schema.brand("InstallationId"));
3530 export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));
3631 export const ExecutionInteractionIdSchema = Schema.String.pipe(
3732 Schema.brand("ExecutionInteractionId"),
3833 );
3934
4035 export type AccountId = typeof AccountIdSchema.Type;
41−export type OrganizationId = typeof OrganizationIdSchema.Type;
42−export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type;
4336 export type WorkspaceId = typeof WorkspaceIdSchema.Type;
4437 export type SourceId = typeof SourceIdSchema.Type;
4538 export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;
@@ -52,6 +45,5 @@ export type CredentialId = typeof CredentialIdSchema.Type; · 6 unmodified lines
5245 export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
5346 export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
5447 export type PolicyId = typeof PolicyIdSchema.Type;
55−export type InstallationId = typeof InstallationIdSchema.Type;
5648 export type ExecutionId = typeof ExecutionIdSchema.Type;
5749 export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type;
packages/control-plane/src/schema/index.ts+1−1
@@ -3,7 +3,6 @@ export * from "./enums"; · 2 unmodified lines
33 export * from "./ids";
44
55 export * from "./models/auth";
6−export * from "./models/workspace";
76 export * from "./models/source";
87 export * from "./models/source-recipe";
98 export * from "./models/auth-artifact";
@@ -13,6 +12,7 @@ export * from "./models/secret-material"; · 3 unmodified lines
1312 export * from "./models/source-auth-session";
1413 export * from "./models/policy";
1514 export * from "./models/local-installation";
15+export * from "./models/local-config";
1616 export * from "./models/execution";
1717 export * from "./models/source-inspection";
1818 export * from "./models/source-discovery";
packages/control-plane/src/schema/models/auth/index.ts+0−5
@@ -1,7 +1,2 @@
11 export * from "./account";
2−export * from "./membership";
3−export * from "./organization";
4−export * from "./organization-membership";
5−export * from "./permission";
62 export * from "./principal";
7−export * from "./role";
packages/control-plane/src/schema/models/auth/membership.ts+0−24
@@ -1,24 +0,0 @@
1−import { Schema } from "effect";
2−
3−import { TimestampMsSchema } from "../../common";
4−import { AccountIdSchema, WorkspaceIdSchema } from "../../ids";
5−
6−import { RoleSchema } from "./organization-membership";
7−
8−export const MembershipStatusSchema = Schema.Literal(
9− "active",
10− "suspended",
11− "revoked",
12−);
13−
14−export const WorkspaceMembershipSchema = Schema.Struct({
15− accountId: AccountIdSchema,
16− workspaceId: WorkspaceIdSchema,
17− role: RoleSchema,
18− status: MembershipStatusSchema,
19− grantedAt: TimestampMsSchema,
20− updatedAt: TimestampMsSchema,
21−});
22−
23−export type MembershipStatus = typeof MembershipStatusSchema.Type;
24−export type WorkspaceMembership = typeof WorkspaceMembershipSchema.Type;
packages/control-plane/src/schema/models/auth/organization-membership.ts+0−55
@@ -1,55 +0,0 @@
1−import {
2− createInsertSchema,
3− createSelectSchema,
4− createUpdateSchema,
5−} from "drizzle-orm/effect-schema";
6−import { Schema } from "effect";
7−
8−import { organizationMembershipsTable } from "../../../persistence/schema";
9−import { TimestampMsSchema } from "../../common";
10−import {
11− AccountIdSchema,
12− OrganizationIdSchema,
13− OrganizationMemberIdSchema,
14−} from "../../ids";
15−
16−export const RoleSchema = Schema.Literal("viewer", "editor", "admin", "owner");
17−
18−export const OrganizationMemberStatusSchema = Schema.Literal(
19− "invited",
20− "active",
21− "suspended",
22− "removed",
23−);
24−
25−const organizationMembershipSchemaOverrides = {
26− id: OrganizationMemberIdSchema,
27− organizationId: OrganizationIdSchema,
28− accountId: AccountIdSchema,
29− role: RoleSchema,
30− status: OrganizationMemberStatusSchema,
31− billable: Schema.Boolean,
32− invitedByAccountId: Schema.NullOr(AccountIdSchema),
33− joinedAt: Schema.NullOr(TimestampMsSchema),
34− createdAt: TimestampMsSchema,
35− updatedAt: TimestampMsSchema,
36−} as const;
37−
38−export const OrganizationMembershipSchema = createSelectSchema(
39− organizationMembershipsTable,
40− organizationMembershipSchemaOverrides,
41−);
42−
43−export const OrganizationMembershipInsertSchema = createInsertSchema(
44− organizationMembershipsTable,
45− organizationMembershipSchemaOverrides,
46−);
47−
48−export const OrganizationMembershipUpdateSchema = createUpdateSchema(
49− organizationMembershipsTable,
50− organizationMembershipSchemaOverrides,
51−);
52−
53−export type Role = typeof RoleSchema.Type;
54−export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type;
55−export type OrganizationMembership = typeof OrganizationMembershipSchema.Type;
packages/control-plane/src/schema/models/auth/organization.ts+0−42
@@ -1,42 +0,0 @@
1−import {
2− createInsertSchema,
3− createSelectSchema,
4− createUpdateSchema,
5−} from "drizzle-orm/effect-schema";
6−import { Schema } from "effect";
7−
8−import { organizationsTable } from "../../../persistence/schema";
9−import { TimestampMsSchema } from "../../common";
10−import { AccountIdSchema, OrganizationIdSchema } from "../../ids";
11−
12−export const OrganizationStatusSchema = Schema.Literal(
13− "active",
14− "suspended",
15− "archived",
16−);
17−
18−const organizationSchemaOverrides = {
19− id: OrganizationIdSchema,
20− status: OrganizationStatusSchema,
21− createdByAccountId: Schema.NullOr(AccountIdSchema),
22− createdAt: TimestampMsSchema,
23− updatedAt: TimestampMsSchema,
24−} as const;
25−
26−export const OrganizationSchema = createSelectSchema(
27− organizationsTable,
28− organizationSchemaOverrides,
29−);
30−
31−export const OrganizationInsertSchema = createInsertSchema(
32− organizationsTable,
33− organizationSchemaOverrides,
34−);
35−
36−export const OrganizationUpdateSchema = createUpdateSchema(
37− organizationsTable,
38− organizationSchemaOverrides,
39−);
40−
41−export type OrganizationStatus = typeof OrganizationStatusSchema.Type;
42−export type Organization = typeof OrganizationSchema.Type;
packages/control-plane/src/schema/models/auth/permission.ts+0−36
@@ -1,36 +0,0 @@
1−import { Schema } from "effect";
2−
3−const permissionDomainActions = {
4− organizations: ["read", "manage"],
5− memberships: ["read", "write", "manage"],
6− workspace: ["read", "manage"],
7− sources: ["read", "write", "manage"],
8− policies: ["read", "write", "manage"],
9−} as const;
10−
11−export const PermissionDomainActions = permissionDomainActions;
12−
13−export type PermissionDomain = keyof typeof permissionDomainActions;
14−type PermissionAction<D extends PermissionDomain> =
15− (typeof permissionDomainActions)[D][number];
16−
17−export type Permission = {
18− [D in PermissionDomain]: `${D}:${PermissionAction<D>}`;
19−}[PermissionDomain];
20−
21−export const PermissionValues: ReadonlyArray<Permission> = Object.entries(
22− permissionDomainActions,
23−).flatMap(([domain, actions]) =>
24− actions.map((action) => `${domain}:${action}` as Permission),
25−);
26−
27−const permissionSet = new Set<string>(PermissionValues);
28−
29−const isPermission = (value: string): value is Permission =>
30− permissionSet.has(value);
31−
32−export const PermissionSchema = Schema.String.pipe(
33− Schema.filter(isPermission, {
34− message: () => "Invalid permission",
35− }),
36−);
packages/control-plane/src/schema/models/auth/role.ts+0−32
@@ -1,32 +0,0 @@
1−import { Schema } from "effect";
2−
3−import { RoleSchema } from "./organization-membership";
4−import {
5− PermissionSchema,
6− PermissionValues,
7− type Permission,
8−} from "./permission";
9−
10−type Role = typeof RoleSchema.Type;
11−
12−const readOnlyPermissions: ReadonlyArray<Permission> = PermissionValues.filter(
13− (permission) => permission.endsWith(":read"),
14−);
15−
16−const writePermissions: ReadonlyArray<Permission> = PermissionValues.filter(
17− (permission) => permission.endsWith(":write"),
18−);
19−
20−export const RolePermissions = {
21− viewer: readOnlyPermissions,
22− editor: [...readOnlyPermissions, ...writePermissions],
23− admin: PermissionValues,
24− owner: PermissionValues,
25−} as const satisfies Record<Role, ReadonlyArray<Permission>>;
26−
27−export const RolePermissionsSchema = Schema.Record({
28− key: RoleSchema,
29− value: Schema.Array(PermissionSchema),
30−});
31−
32−export type RolePermissions = typeof RolePermissionsSchema.Type;
packages/control-plane/src/schema/models/local-config.ts+185−0
@@ -0,0 +1,185 @@
1+import { Schema } from "effect";
2+
3+import { SourceTransportSchema, StringMapSchema } from "./source";
4+
5+export const LocalConfigSecretProviderSourceSchema = Schema.Literal(
6+ "env",
7+ "file",
8+ "exec",
9+ "params",
10+);
11+
12+export const LocalConfigEnvSecretProviderSchema = Schema.Struct({
13+ source: Schema.Literal("env"),
14+});
15+
16+export const LocalConfigFileSecretProviderModeSchema = Schema.Literal(
17+ "singleValue",
18+ "json",
19+);
20+
21+export const LocalConfigFileSecretProviderSchema = Schema.Struct({
22+ source: Schema.Literal("file"),
23+ path: Schema.String,
24+ mode: Schema.optional(LocalConfigFileSecretProviderModeSchema),
25+});
26+
27+export const LocalConfigExecSecretProviderSchema = Schema.Struct({
28+ source: Schema.Literal("exec"),
29+ command: Schema.String,
30+ args: Schema.optional(Schema.Array(Schema.String)),
31+ env: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
32+ allowSymlinkCommand: Schema.optional(Schema.Boolean),
33+ trustedDirs: Schema.optional(Schema.Array(Schema.String)),
34+});
35+
36+export const LocalConfigSecretProviderSchema = Schema.Union(
37+ LocalConfigEnvSecretProviderSchema,
38+ LocalConfigFileSecretProviderSchema,
39+ LocalConfigExecSecretProviderSchema,
40+);
41+
42+export const LocalConfigExplicitSecretRefSchema = Schema.Struct({
43+ source: LocalConfigSecretProviderSourceSchema,
44+ provider: Schema.String,
45+ id: Schema.String,
46+});
47+
48+export const LocalConfigSecretInputSchema = Schema.Union(
49+ Schema.String,
50+ LocalConfigExplicitSecretRefSchema,
51+);
52+
53+export const LocalConfigSourceConnectionSchema = Schema.Struct({
54+ endpoint: Schema.String,
55+ auth: Schema.optional(LocalConfigSecretInputSchema),
56+});
57+
58+export const LocalConfigOpenApiBindingSchema = Schema.Struct({
59+ specUrl: Schema.String,
60+ defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
61+});
62+
63+export const LocalConfigGraphqlBindingSchema = Schema.Struct({
64+ defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
65+});
66+
67+export const LocalConfigMcpBindingSchema = Schema.Struct({
68+ transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
69+ queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
70+ headers: Schema.optional(Schema.NullOr(StringMapSchema)),
71+});
72+
73+export const LocalConfigGoogleDiscoveryBindingSchema = Schema.Struct({
74+ service: Schema.String,
75+ version: Schema.String,
76+ discoveryUrl: Schema.optional(Schema.NullOr(Schema.String)),
77+ defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
78+ scopes: Schema.optional(Schema.Array(Schema.String)),
79+});
80+
81+const LocalConfigSourceEntryBaseSchema = Schema.Struct({
82+ name: Schema.optional(Schema.String),
83+ namespace: Schema.optional(Schema.String),
84+ enabled: Schema.optional(Schema.Boolean),
85+ connection: LocalConfigSourceConnectionSchema,
86+});
87+
88+export const LocalConfigOpenApiSourceSchema = Schema.extend(
89+ LocalConfigSourceEntryBaseSchema,
90+ Schema.Struct({
91+ kind: Schema.Literal("openapi"),
92+ binding: LocalConfigOpenApiBindingSchema,
93+ }),
94+);
95+
96+export const LocalConfigGraphqlSourceSchema = Schema.extend(
97+ LocalConfigSourceEntryBaseSchema,
98+ Schema.Struct({
99+ kind: Schema.Literal("graphql"),
100+ binding: LocalConfigGraphqlBindingSchema,
101+ }),
102+);
103+
104+export const LocalConfigMcpSourceSchema = Schema.extend(
105+ LocalConfigSourceEntryBaseSchema,
106+ Schema.Struct({
107+ kind: Schema.Literal("mcp"),
108+ binding: LocalConfigMcpBindingSchema,
109+ }),
110+);
111+
112+export const LocalConfigGoogleDiscoverySourceSchema = Schema.extend(
113+ LocalConfigSourceEntryBaseSchema,
114+ Schema.Struct({
115+ kind: Schema.Literal("google_discovery"),
116+ binding: LocalConfigGoogleDiscoveryBindingSchema,
117+ }),
118+);
119+
120+export const LocalConfigSourceSchema = Schema.Union(
121+ LocalConfigOpenApiSourceSchema,
122+ LocalConfigGraphqlSourceSchema,
123+ LocalConfigMcpSourceSchema,
124+ LocalConfigGoogleDiscoverySourceSchema,
125+);
126+
127+export const LocalConfigPolicyActionSchema = Schema.Literal("allow", "deny");
128+export const LocalConfigPolicyApprovalSchema = Schema.Literal("auto", "manual");
129+
130+export const LocalConfigPolicySchema = Schema.Struct({
131+ match: Schema.String,
132+ action: LocalConfigPolicyActionSchema,
133+ approval: LocalConfigPolicyApprovalSchema,
134+ enabled: Schema.optional(Schema.Boolean),
135+ priority: Schema.optional(Schema.Number),
136+});
137+
138+export const LocalConfigSecretsSchema = Schema.Struct({
139+ providers: Schema.optional(
140+ Schema.Record({
141+ key: Schema.String,
142+ value: LocalConfigSecretProviderSchema,
143+ }),
144+ ),
145+ defaults: Schema.optional(
146+ Schema.Struct({
147+ env: Schema.optional(Schema.String),
148+ file: Schema.optional(Schema.String),
149+ exec: Schema.optional(Schema.String),
150+ }),
151+ ),
152+});
153+
154+export const LocalConfigWorkspaceSchema = Schema.Struct({
155+ name: Schema.optional(Schema.String),
156+});
157+
158+export const LocalExecutorConfigSchema = Schema.Struct({
159+ workspace: Schema.optional(LocalConfigWorkspaceSchema),
160+ sources: Schema.optional(
161+ Schema.Record({
162+ key: Schema.String,
163+ value: LocalConfigSourceSchema,
164+ }),
165+ ),
166+ policies: Schema.optional(
167+ Schema.Record({
168+ key: Schema.String,
169+ value: LocalConfigPolicySchema,
170+ }),
171+ ),
172+ secrets: Schema.optional(LocalConfigSecretsSchema),
173+});
174+
175+export type LocalConfigSecretProviderSource =
176+ typeof LocalConfigSecretProviderSourceSchema.Type;
177+export type LocalConfigSecretProvider =
178+ typeof LocalConfigSecretProviderSchema.Type;
179+export type LocalConfigExplicitSecretRef =
180+ typeof LocalConfigExplicitSecretRefSchema.Type;
181+export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type;
182+export type LocalConfigSource = typeof LocalConfigSourceSchema.Type;
183+export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type;
184+export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type;
185+export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type;
packages/control-plane/src/schema/models/local-installation.ts+5−29
@@ -1,40 +1,16 @@
1−import {
2− createInsertSchema,
3− createSelectSchema,
4− createUpdateSchema,
5−} from "drizzle-orm/effect-schema";
1+import * as Schema from "effect/Schema";
62
7−import { localInstallationsTable } from "../../persistence/schema";
8−import { TimestampMsSchema } from "../common";
93 import {
104 AccountIdSchema,
11− InstallationIdSchema,
12− OrganizationIdSchema,
135 WorkspaceIdSchema,
146 } from "../ids";
157
16−const localInstallationSchemaOverrides = {
17− id: InstallationIdSchema,
8+export const LocalInstallationSchema = Schema.Struct({
189 accountId: AccountIdSchema,
19− organizationId: OrganizationIdSchema,
2010 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−);
11+});
3412
35−export const LocalInstallationUpdateSchema = createUpdateSchema(
36− localInstallationsTable,
37− localInstallationSchemaOverrides,
38−);
13+export const LocalInstallationInsertSchema = LocalInstallationSchema;
14+export const LocalInstallationUpdateSchema = Schema.partial(LocalInstallationSchema);
3915
4016 export type LocalInstallation = typeof LocalInstallationSchema.Type;
packages/control-plane/src/schema/models/policy.ts+18−69
@@ -1,79 +1,28 @@
1−import {
2− createInsertSchema,
3− createSelectSchema,
4− createUpdateSchema,
5−} from "drizzle-orm/effect-schema";
61 import { Schema } from "effect";
72
8−import { policiesTable } from "../../persistence/schema";
93 import { TimestampMsSchema } from "../common";
10−import {
11− AccountIdSchema,
12− OrganizationIdSchema,
13− PolicyIdSchema,
14− WorkspaceIdSchema,
15−} from "../ids";
4+import { PolicyIdSchema, WorkspaceIdSchema } from "../ids";
165
17−export const PolicyScopeTypeSchema = Schema.Literal("organization", "workspace");
6+export const LocalWorkspacePolicyEffectSchema = Schema.Literal("allow", "deny");
7+export const LocalWorkspacePolicyApprovalModeSchema = Schema.Literal("auto", "required");
188
19−export const PolicyResourceTypeSchema = Schema.Literal(
20− "all_tools",
21− "source",
22− "namespace",
23− "tool_path",
24−);
25−
26−export const PolicyMatchTypeSchema = Schema.Literal("glob", "exact");
27−export const PolicyEffectSchema = Schema.Literal("allow", "deny");
28−export const PolicyApprovalModeSchema = Schema.Literal("auto", "required");
29−
30−export const ArgumentConditionOperatorSchema = Schema.Literal(
31− "equals",
32− "contains",
33− "starts_with",
34− "not_equals",
35−);
36−
37−export const PolicyArgumentConditionSchema = Schema.Struct({
38− key: Schema.String,
39− operator: ArgumentConditionOperatorSchema,
40− value: Schema.String,
41−});
42−
43−const policySchemaOverrides = {
9+export const LocalWorkspacePolicySchema = Schema.Struct({
4410 id: PolicyIdSchema,
45− scopeType: PolicyScopeTypeSchema,
46− organizationId: OrganizationIdSchema,
47− workspaceId: Schema.NullOr(WorkspaceIdSchema),
48− targetAccountId: Schema.NullOr(AccountIdSchema),
49− clientId: Schema.NullOr(Schema.String),
50− resourceType: PolicyResourceTypeSchema,
51− matchType: PolicyMatchTypeSchema,
52− effect: PolicyEffectSchema,
53− approvalMode: PolicyApprovalModeSchema,
54− argumentConditionsJson: Schema.NullOr(Schema.String),
11+ key: Schema.String,
12+ workspaceId: WorkspaceIdSchema,
13+ resourcePattern: Schema.String,
14+ effect: LocalWorkspacePolicyEffectSchema,
15+ approvalMode: LocalWorkspacePolicyApprovalModeSchema,
16+ priority: Schema.Number,
17+ enabled: Schema.Boolean,
5518 createdAt: TimestampMsSchema,
5619 updatedAt: TimestampMsSchema,
57−} as const;
58−
59−export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides);
60−
61−export const PolicyInsertSchema = createInsertSchema(
62− policiesTable,
63− policySchemaOverrides,
64−);
20+});
6521
66−export const PolicyUpdateSchema = createUpdateSchema(
67− policiesTable,
68− policySchemaOverrides,
69−);
22+export const LocalWorkspacePolicyInsertSchema = LocalWorkspacePolicySchema;
23+export const LocalWorkspacePolicyUpdateSchema = Schema.partial(LocalWorkspacePolicySchema);
7024
71−export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type;
72−export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type;
73−export type PolicyEffect = typeof PolicyEffectSchema.Type;
74−export type PolicyApprovalMode = typeof PolicyApprovalModeSchema.Type;
75−export type PolicyScopeType = typeof PolicyScopeTypeSchema.Type;
76−export type ArgumentConditionOperator =
77− typeof ArgumentConditionOperatorSchema.Type;
78−export type PolicyArgumentCondition = typeof PolicyArgumentConditionSchema.Type;
79−export type Policy = typeof PolicySchema.Type;
25+export type LocalWorkspacePolicyEffect = typeof LocalWorkspacePolicyEffectSchema.Type;
26+export type LocalWorkspacePolicyApprovalMode =
27+ typeof LocalWorkspacePolicyApprovalModeSchema.Type;
28+export type LocalWorkspacePolicy = typeof LocalWorkspacePolicySchema.Type;
packages/control-plane/src/schema/models/workspace.ts+0−32
@@ -1,32 +0,0 @@
1−import {
2− createInsertSchema,
3− createSelectSchema,
4− createUpdateSchema,
5−} from "drizzle-orm/effect-schema";
6−import { Schema } from "effect";
7−
8−import { workspacesTable } from "../../persistence/schema";
9−import { TimestampMsSchema } from "../common";
10−import { AccountIdSchema, OrganizationIdSchema, WorkspaceIdSchema } from "../ids";
11−
12−const workspaceSchemaOverrides = {
13− id: WorkspaceIdSchema,
14− organizationId: OrganizationIdSchema,
15− createdByAccountId: Schema.NullOr(AccountIdSchema),
16− createdAt: TimestampMsSchema,
17− updatedAt: TimestampMsSchema,
18−} as const;
19−
20−export const WorkspaceSchema = createSelectSchema(workspacesTable, workspaceSchemaOverrides);
21−
22−export const WorkspaceInsertSchema = createInsertSchema(
23− workspacesTable,
24− workspaceSchemaOverrides,
25−);
26−
27−export const WorkspaceUpdateSchema = createUpdateSchema(
28− workspacesTable,
29− workspaceSchemaOverrides,
30−);
31−
32−export type Workspace = typeof WorkspaceSchema.Type;
packages/control-plane/src/schema/schema.test.ts+0−9
@@ -3,19 +3,10 @@ import * as Either from "effect/Either"; · 2 unmodified lines
33 import * as Schema from "effect/Schema";
44
55 import {
6− PermissionValues,
7− RolePermissions,
86 StoredSourceRecipeOperationRecordSchema,
97 } from "./index";
108
119 describe("control-plane-schema", () => {
12− it("exposes stable permission and role mappings", () => {
13− expect(PermissionValues).toContain("organizations:manage");
14− expect(RolePermissions.viewer).toContain("workspace:read");
15− expect(RolePermissions.editor).toContain("sources:write");
16− expect(RolePermissions.owner).toContain("policies:manage");
17− });
18−
1910 it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => {
2011 const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema);
2112
packages/executor-mcp/src/index.ts+14−63
@@ -1,7 +1,6 @@
11 import type {
22 ExecutionEnvelope,
3− ExecutionInteraction,
4− SqlControlPlaneRuntime,
3+ ControlPlaneRuntime,
54 } from "@executor/control-plane";
65 import {
76 EXECUTOR_SOURCES_ADD_HELP_LINES,
@@ -18,6 +17,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 10 unmodified lines
1817 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
1918 import { z } from "zod/v4";
2019
20+import {
21+ buildPausedResultText,
22+ parseInteractionPayload,
23+} from "./paused-result";
24+
2125 const pollingIntervalMs = 200;
2226
2327 const executeInputSchema = {
@@ -34,14 +38,6 @@ const resumeInputSchema = { · 10 unmodified lines
3438 }).optional(),
3539 };
3640
37−type ParsedInteractionPayload = {
38− mode: "form" | "url";
39− message: string;
40− url?: string;
41− requestedSchema?: Record<string, unknown>;
42− elicitationId?: string;
43−};
44−
4541 type ResumePayload = {
4642 executionId: string;
4743 };
@@ -62,9 +58,6 @@ export type ExecutorMcpRequestHandler = { · 14 unmodified lines
6258 close: () => Promise<void>;
6359 };
6460
65−const isRecord = (value: unknown): value is Record<string, unknown> =>
66− typeof value === "object" && value !== null && !Array.isArray(value);
67−
6861 const parseJsonValue = (value: string | null): unknown => {
6962 if (value === null) {
7063 return null;
@@ -93,48 +86,8 @@ const formatResultPreview = (resultJson: string): string => { · 22 unmodified lines
9386 return truncateText(resultJson, maxResultPreviewChars);
9487 }
9588 };
96−
97−
98−const parseInteractionPayload = (
99− interaction: ExecutionInteraction,
100−): ParsedInteractionPayload | null => {
101− try {
102− const parsed = JSON.parse(interaction.payloadJson) as {
103− elicitation?: {
104− mode?: "form" | "url";
105− message?: string;
106− url?: string;
107− requestedSchema?: Record<string, unknown>;
108− elicitationId?: string;
109− id?: string;
110− };
111− };
112−
113− if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") {
114− return null;
115− }
116−
117− return {
118− mode: parsed.elicitation.mode === "url" ? "url" : "form",
119− message: parsed.elicitation.message,
120− url: parsed.elicitation.url,
121− requestedSchema: isRecord(parsed.elicitation.requestedSchema)
122− ? parsed.elicitation.requestedSchema
123− : undefined,
124− elicitationId:
125− typeof parsed.elicitation.elicitationId === "string"
126− ? parsed.elicitation.elicitationId
127− : typeof parsed.elicitation.id === "string"
128− ? parsed.elicitation.id
129− : undefined,
130− };
131− } catch {
132− return null;
133− }
134−};
135−
13689 const runControlPlane = async <A, E, R>(
137− runtime: SqlControlPlaneRuntime,
90+ runtime: ControlPlaneRuntime,
13891 effect: Effect.Effect<A, E, R>,
13992 ): Promise<A> => {
14093 const exit = await Effect.runPromiseExit(
@@ -192,7 +145,7 @@ const buildExecuteWorkflowText = (namespaces: readonly string[] = []): string => · 51 unmodified lines
192145
193146 const defaultExecuteDescription = buildExecuteWorkflowText();
194147
195−const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string> =>
148+const loadExecuteDescription = (runtime: ControlPlaneRuntime): Promise<string> =>
196149 runControlPlane(
197150 runtime,
198151 Effect.gen(function* () {
@@ -265,9 +218,7 @@ const buildPausedResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult = · 66 unmodified lines
265218 return {
266219 content: [{
267220 type: "text",
268− text: interaction
269− ? `Execution ${envelope.execution.id} paused: ${parsed?.message ?? "Interaction required."}`
270− : `Execution ${envelope.execution.id} is waiting for interaction.`,
221+ text: buildPausedResultText(envelope),
271222 }],
272223 structuredContent: {
273224 executionId: envelope.execution.id,
@@ -305,7 +256,7 @@ const buildToolResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult => · 31 unmodified lines
305256 };
306257
307258 const waitForInteractionProgress = async (input: {
308− runtime: SqlControlPlaneRuntime;
259+ runtime: ControlPlaneRuntime;
309260 workspaceId: string;
310261 executionId: string;
311262 pendingInteractionId: string;
@@ -331,7 +282,7 @@ const waitForInteractionProgress = async (input: { · 19 unmodified lines
331282 };
332283
333284 const driveExecutionWithElicitation = async (input: {
334− runtime: SqlControlPlaneRuntime;
285+ runtime: ControlPlaneRuntime;
335286 workspaceId: string;
336287 accountId: string;
337288 server: McpServer;
@@ -408,7 +359,7 @@ const driveExecutionWithElicitation = async (input: { · 70 unmodified lines
408359 };
409360
410361 const driveExecutionWithoutElicitation = async (input: {
411− runtime: SqlControlPlaneRuntime;
362+ runtime: ControlPlaneRuntime;
412363 workspaceId: string;
413364 accountId: string;
414365 executionId: string;
@@ -447,7 +398,7 @@ const driveExecutionWithoutElicitation = async (input: { · 32 unmodified lines
447398 };
448399
449400 const createExecutorMcpServer = async (config: {
450− runtime: SqlControlPlaneRuntime;
401+ runtime: ControlPlaneRuntime;
451402 }): Promise<McpServer> => {
452403 const executeDescription = await loadExecuteDescription(config.runtime);
453404 const server = new McpServer(
@@ -552,7 +503,7 @@ const jsonErrorResponse = (status: number, code: number, message: string) => · 98 unmodified lines
552503 });
553504
554505 export const createExecutorMcpRequestHandler = (
555− runtime: SqlControlPlaneRuntime,
506+ runtime: ControlPlaneRuntime,
556507 ): ExecutorMcpRequestHandler => {
557508 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
558509 const servers = new Map<string, McpServer>();
packages/executor-mcp/src/paused-result.test.ts+97−0
@@ -0,0 +1,97 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import type { ExecutionEnvelope, ExecutionInteraction } from "@executor/control-plane";
3+
4+import {
5+ buildPausedResultText,
6+ parseInteractionPayload,
7+} from "./paused-result";
8+
9+const makeInteraction = (patch: Partial<ExecutionInteraction> = {}): ExecutionInteraction => ({
10+ id: "exec_123:source_connect_oauth2:call_1" as never,
11+ executionId: "exec_123" as never,
12+ status: "pending",
13+ kind: "url",
14+ purpose: "source_connect_oauth2",
15+ payloadJson: JSON.stringify({
16+ elicitation: {
17+ mode: "url",
18+ message: "Finish connecting Axiom",
19+ url: "https://mcp.axiom.co/authorize",
20+ elicitationId: "oauth-session-123",
21+ },
22+ }),
23+ responseJson: null,
24+ createdAt: 1,
25+ updatedAt: 1,
26+ ...patch,
27+});
28+
29+const makeEnvelope = (
30+ patch: Partial<ExecutionEnvelope> = {},
31+): ExecutionEnvelope => ({
32+ execution: {
33+ id: "exec_123" as never,
34+ workspaceId: "ws_123" as never,
35+ createdByAccountId: "acct_123" as never,
36+ status: "waiting_for_interaction",
37+ code: "return await tools.executor.sources.add({ endpoint: 'https://mcp.axiom.co/mcp' });",
38+ resultJson: null,
39+ errorText: null,
40+ logsJson: null,
41+ startedAt: 1,
42+ completedAt: null,
43+ createdAt: 1,
44+ updatedAt: 1,
45+ },
46+ pendingInteraction: makeInteraction(),
47+ ...patch,
48+});
49+
50+describe("paused-result", () => {
51+ it("parses the browser URL from a paused interaction payload", () => {
52+ const parsed = parseInteractionPayload(makeInteraction());
53+
54+ expect(parsed).not.toBeNull();
55+ expect(parsed?.mode).toBe("url");
56+ expect(parsed?.url).toBe("https://mcp.axiom.co/authorize");
57+ expect(parsed?.elicitationId).toBe("oauth-session-123");
58+ });
59+
60+ it("includes URL and resume instructions in the paused text for browser auth", () => {
61+ const text = buildPausedResultText(makeEnvelope());
62+
63+ expect(text).toContain("Finish connecting Axiom");
64+ expect(text).toContain("Open this URL in a browser:");
65+ expect(text).toContain("https://mcp.axiom.co/authorize");
66+ expect(text).toContain("executor.resume");
67+ expect(text).toContain('"executionId": "exec_123"');
68+ });
69+
70+ it("includes resume guidance for form interactions", () => {
71+ const text = buildPausedResultText(
72+ makeEnvelope({
73+ pendingInteraction: makeInteraction({
74+ kind: "form",
75+ purpose: "tool_execution_gate",
76+ payloadJson: JSON.stringify({
77+ elicitation: {
78+ mode: "form",
79+ message: "Approve the tool call",
80+ requestedSchema: {
81+ type: "object",
82+ properties: {
83+ approve: { type: "boolean" },
84+ },
85+ required: ["approve"],
86+ },
87+ },
88+ }),
89+ }),
90+ }),
91+ );
92+
93+ expect(text).toContain("Approve the tool call");
94+ expect(text).toContain("include a response object matching the requested schema");
95+ expect(text).toContain('"executionId": "exec_123"');
96+ });
97+});
packages/executor-mcp/src/paused-result.ts+95−0
@@ -0,0 +1,95 @@
1+import type {
2+ ExecutionEnvelope,
3+ ExecutionInteraction,
4+} from "@executor/control-plane";
5+
6+export type ParsedInteractionPayload = {
7+ mode: "form" | "url";
8+ message: string;
9+ url?: string;
10+ requestedSchema?: Record<string, unknown>;
11+ elicitationId?: string;
12+};
13+
14+const isRecord = (value: unknown): value is Record<string, unknown> =>
15+ typeof value === "object" && value !== null && !Array.isArray(value);
16+
17+export const parseInteractionPayload = (
18+ interaction: ExecutionInteraction,
19+): ParsedInteractionPayload | null => {
20+ try {
21+ const parsed = JSON.parse(interaction.payloadJson) as {
22+ elicitation?: {
23+ mode?: "form" | "url";
24+ message?: string;
25+ url?: string;
26+ requestedSchema?: Record<string, unknown>;
27+ elicitationId?: string;
28+ id?: string;
29+ };
30+ };
31+
32+ if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") {
33+ return null;
34+ }
35+
36+ return {
37+ mode: parsed.elicitation.mode === "url" ? "url" : "form",
38+ message: parsed.elicitation.message,
39+ url: parsed.elicitation.url,
40+ requestedSchema: isRecord(parsed.elicitation.requestedSchema)
41+ ? parsed.elicitation.requestedSchema
42+ : undefined,
43+ elicitationId:
44+ typeof parsed.elicitation.elicitationId === "string"
45+ ? parsed.elicitation.elicitationId
46+ : typeof parsed.elicitation.id === "string"
47+ ? parsed.elicitation.id
48+ : undefined,
49+ };
50+ } catch {
51+ return null;
52+ }
53+};
54+
55+const formatResumePayload = (executionId: string): string =>
56+ JSON.stringify({ executionId }, null, 2);
57+
58+export const buildPausedResultText = (envelope: ExecutionEnvelope): string => {
59+ const interaction = envelope.pendingInteraction;
60+ const parsed = interaction ? parseInteractionPayload(interaction) : null;
61+
62+ if (!interaction) {
63+ return [
64+ `Execution ${envelope.execution.id} is waiting for interaction.`,
65+ "Resume with executor.resume using this resumePayload:",
66+ formatResumePayload(envelope.execution.id),
67+ ].join("\n");
68+ }
69+
70+ const lines = [
71+ `Execution ${envelope.execution.id} paused: ${parsed?.message ?? "Interaction required."}`,
72+ ];
73+
74+ if (parsed?.mode === "url" && typeof parsed.url === "string" && parsed.url.trim().length > 0) {
75+ lines.push("Open this URL in a browser:");
76+ lines.push(parsed.url.trim());
77+ lines.push(
78+ "After finishing the browser flow, call executor.resume with this resumePayload if your client does not refresh automatically:",
79+ );
80+ lines.push(formatResumePayload(envelope.execution.id));
81+ return lines.join("\n");
82+ }
83+
84+ if (parsed?.mode === "form") {
85+ lines.push(
86+ "Resume with executor.resume using this resumePayload and include a response object matching the requested schema:",
87+ );
88+ lines.push(formatResumePayload(envelope.execution.id));
89+ return lines.join("\n");
90+ }
91+
92+ lines.push("Resume with executor.resume using this resumePayload:");
93+ lines.push(formatResumePayload(envelope.execution.id));
94+ return lines.join("\n");
95+};
packages/react/src/index.test.tsx+14−4
@@ -5,10 +5,10 @@ import { NodeHttpServer } from "@effect/platform-node"; · 4 unmodified lines
55 import { describe, expect, it } from "@effect/vitest";
66 import {
77 createControlPlaneApiLayer,
8− createSqlControlPlaneRuntime,
8+ createControlPlaneRuntime,
99 type LocalInstallation,
1010 type Source,
11− type SqlControlPlaneRuntime,
11+ type ControlPlaneRuntime,
1212 } from "@executor/control-plane";
1313 import * as Context from "effect/Context";
1414 import * as Effect from "effect/Effect";
@@ -69,7 +69,7 @@ type RunningServer = { · 54 unmodified lines
6969 };
7070
7171 type ApiServer = RunningServer & {
72− runtime: SqlControlPlaneRuntime;
72+ runtime: ControlPlaneRuntime;
7373 };
7474
7575 type HookHarness<T> = {
@@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 19 unmodified lines
9595
9696 const startControlPlaneServer = async (): Promise<ApiServer> => {
9797 const runtime = await Effect.runPromise(
98− createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),
98+ createControlPlaneRuntime({
99+ localDataDir: ":memory:",
100+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")),
101+ }),
99102 );
100103 const scope = await Effect.runPromise(Scope.make());
101104
@@ -531,6 +534,7 @@ describe("executor-react source hooks", () => { · 429 unmodified lines
531534 await apiServer.close();
532535 }
533536 }),
537+ 60_000,
534538 );
535539
536540 it.effect("applies optimistic source updates and refreshes live inspection data after success", () =>
@@ -624,6 +628,7 @@ describe("executor-react source hooks", () => { · 87 unmodified lines
624628 await apiServer.close();
625629 }
626630 }),
631+ 60_000,
627632 );
628633
629634 it.effect("optimistically removes deleted sources and invalidates mounted source queries", () =>
@@ -706,6 +711,7 @@ describe("executor-react source hooks", () => { · 76 unmodified lines
706711 await apiServer.close();
707712 }
708713 }),
714+ 60_000,
709715 );
710716
711717 it.effect("surfaces missing sources as errors instead of staying loading", () =>
@@ -741,5 +747,9 @@ describe("executor-react source hooks", () => { · 29 unmodified lines
741747 await apiServer.close();
742748 }
743749 }),
750+ 60_000,
744751 );
745752 });
753+import { mkdtempSync } from "node:fs";
754+import { tmpdir } from "node:os";
755+import { join } from "node:path";
packages/react/src/index.ts+37−25
@@ -230,14 +230,16 @@ const logExecutorDevError = (label: string, details: Record<string, unknown>): v · 229 unmodified lines
230230 };
231231
232232 const runControlPlane = async <A>(input: {
233+ baseUrl?: string;
233234 accountId?: string;
234235 execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>;
235236 }): Promise<A> => {
237+ const baseUrl = input.baseUrl ?? apiBaseUrl;
236238 const accountId = input.accountId;
237239
238240 const exit = await Effect.runPromiseExit(
239241 createControlPlaneClient({
240− baseUrl: apiBaseUrl,
242+ baseUrl,
241243 ...(accountId !== undefined ? { accountId } : {}),
242244 }).pipe(Effect.flatMap(input.execute)),
243245 );
@@ -248,7 +250,7 @@ const runControlPlane = async <A>(input: { · 4 unmodified lines
248250
249251 const error = Cause.squash(exit.cause);
250252 logExecutorDevError("control-plane request failed", {
251− baseUrl: apiBaseUrl,
253+ baseUrl,
252254 accountId,
253255 error: describeExecutorDevError(error),
254256 cause: Cause.pretty(exit.cause),
@@ -257,6 +259,7 @@ const runControlPlane = async <A>(input: { · 2 unmodified lines
257259 };
258260
259261 const controlPlaneRequest = <A>(input: {
262+ baseUrl?: string;
260263 accountId?: string;
261264 execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>;
262265 }): Effect.Effect<A, Error> =>
@@ -265,23 +268,32 @@ const controlPlaneRequest = <A>(input: { · 2 unmodified lines
265268 catch: toError,
266269 });
267270
268−const localInstallationAtom = Atom.make(
269− controlPlaneRequest({
270− execute: (client) => client.local.installation({}),
271− }),
272−).pipe(Atom.keepAlive);
273−
274−const instanceConfigAtom = Atom.make(
275− controlPlaneRequest({
276− execute: (client) => client.local.config({}),
277− }),
278−).pipe(Atom.keepAlive);
279−
280−const secretsAtom = Atom.make(
281− controlPlaneRequest({
282− execute: (client) => client.local.listSecrets({}),
283− }),
284−).pipe(Atom.keepAlive);
271+const localInstallationAtom = Atom.family((baseUrl: string) =>
272+ Atom.make(
273+ controlPlaneRequest({
274+ baseUrl,
275+ execute: (client) => client.local.installation({}),
276+ }),
277+ ).pipe(Atom.keepAlive),
278+);
279+
280+const instanceConfigAtom = Atom.family((baseUrl: string) =>
281+ Atom.make(
282+ controlPlaneRequest({
283+ baseUrl,
284+ execute: (client) => client.local.config({}),
285+ }),
286+ ).pipe(Atom.keepAlive),
287+);
288+
289+const secretsAtom = Atom.family((baseUrl: string) =>
290+ Atom.make(
291+ controlPlaneRequest({
292+ baseUrl,
293+ execute: (client) => client.local.listSecrets({}),
294+ }),
295+ ).pipe(Atom.keepAlive),
296+);
285297
286298 const sourcesAtom = Atom.family((key: string) => {
287299 const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key);
@@ -453,7 +465,7 @@ const useLoadableAtom = <T>(atom: Atom.Atom<Result.Result<T, Error>>): Loadable< · 165 unmodified lines
453465 };
454466
455467 const useWorkspaceContext = (): Loadable<WorkspaceContext> => {
456− const installation = useLoadableAtom(localInstallationAtom);
468+ const installation = useLoadableAtom(localInstallationAtom(apiBaseUrl));
457469
458470 return React.useMemo(() => {
459471 if (installation.status !== "ready") {
@@ -810,18 +822,18 @@ export const useInvalidateExecutorQueries = (): (() => void) => { · 350 unmodified lines
810822 };
811823
812824 export const useLocalInstallation = (): Loadable<LocalInstallation> =>
813− useLoadableAtom(localInstallationAtom);
825+ useLoadableAtom(localInstallationAtom(apiBaseUrl));
814826
815827 export const useInstanceConfig = (): Loadable<InstanceConfig> =>
816− useLoadableAtom(instanceConfigAtom);
828+ useLoadableAtom(instanceConfigAtom(apiBaseUrl));
817829
818830 export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
819− useLoadableAtom(secretsAtom);
831+ useLoadableAtom(secretsAtom(apiBaseUrl));
820832
821833 export const useRefreshSecrets = (): (() => void) => {
822834 const registry = React.useContext(RegistryContext);
823835 return React.useCallback(() => {
824− registry.refresh(secretsAtom);
836+ registry.refresh(secretsAtom(apiBaseUrl));
825837 }, [registry]);
826838 };
827839
@@ -850,7 +862,7 @@ const useSecretMutation = <TInput, TOutput>( · 22 unmodified lines
850862
851863 try {
852864 const data = await execute(payload);
853− registry.refresh(secretsAtom);
865+ registry.refresh(secretsAtom(apiBaseUrl));
854866 setState({ status: "success", data, error: null });
855867 return data;
856868 } catch (cause) {
packages/runtime-ses/src/index.ts+0−1
@@ -237,4 +237,3 @@ export const makeSesExecutor = ( · 236 unmodified lines
237237 ): CodeExecutor => ({
238238 execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker),
239239 });
240−
packages/runtime-ses/src/sandbox-worker.mjs+9−1
@@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines
2424
2525 const formatLogLine = (args) => args.map(formatLogArg).join(" ");
2626
27+const toSerializableValue = (value) => {
28+ if (typeof value === "undefined") {
29+ return undefined;
30+ }
31+
32+ return JSON.parse(JSON.stringify(value));
33+};
34+
2735 const buildExecutionSource = (code) => {
2836 const trimmed = code.trim();
2937 const looksLikeArrowFunction =
@@ -157,7 +165,7 @@ process.on("message", async (message) => { · 127 unmodified lines
157165 process.send?.({
158166 type: "result",
159167 id: message.id,
160− value: result,
168+ value: toSerializableValue(result),
161169 logs,
162170 });
163171 } catch (error) {
packages/server/src/index.ts+9−7
@@ -11,10 +11,10 @@ import { Readable } from "node:stream"; · 10 unmodified lines
1111 import { HttpApiBuilder, HttpServer } from "@effect/platform";
1212 import {
1313 createControlPlaneApiLayer,
14− createSqlControlPlaneRuntime,
14+ createControlPlaneRuntime,
1515 type ResolveExecutionEnvironment,
1616 type ResolveSecretMaterial,
17− type SqlControlPlaneRuntime,
17+ type ControlPlaneRuntime,
1818 } from "@executor/control-plane";
1919 import { createExecutorMcpRequestHandler } from "@executor/executor-mcp";
2020 import * as Cause from "effect/Cause";
@@ -58,7 +58,7 @@ type StaticUiOptions = { · 37 unmodified lines
5858 };
5959
6060 export type LocalExecutorServer = {
61− readonly runtime: SqlControlPlaneRuntime;
61+ readonly runtime: ControlPlaneRuntime;
6262 readonly port: number;
6363 readonly host: string;
6464 readonly baseUrl: string;
@@ -68,6 +68,7 @@ export type StartLocalExecutorServerOptions = { · 3 unmodified lines
6868 readonly port?: number;
6969 readonly host?: string;
7070 readonly localDataDir?: string;
71+ readonly workspaceRoot?: string;
7172 readonly migrationsFolder?: string;
7273 readonly pidFile?: string;
7374 readonly executionResolver?: ResolveExecutionEnvironment;
@@ -76,7 +77,7 @@ export type StartLocalExecutorServerOptions = { · 2 unmodified lines
7677 };
7778
7879 export type LocalExecutorRequestHandler = {
79− readonly runtime: SqlControlPlaneRuntime;
80+ readonly runtime: ControlPlaneRuntime;
8081 readonly handleApiRequest: (request: Request) => Promise<Response>;
8182 readonly getBaseUrl: () => string | undefined;
8283 readonly setBaseUrl: (baseUrl: string) => void;
@@ -85,7 +86,7 @@ export type LocalExecutorRequestHandler = { · 2 unmodified lines
8586 type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>;
8687 type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>;
8788
88−const disposeRuntime = (runtime: SqlControlPlaneRuntime) =>
89+const disposeRuntime = (runtime: ControlPlaneRuntime) =>
8990 Effect.tryPromise({
9091 try: () => runtime.close(),
9192 catch: (cause) =>
@@ -97,9 +98,10 @@ const createRuntime = ( · 5 unmodified lines
9798 getLocalServerBaseUrl: () => string | undefined,
9899 options: StartLocalExecutorServerOptions,
99100 ) =>
100− createSqlControlPlaneRuntime({
101+ createControlPlaneRuntime({
101102 localDataDir,
102103 migrationsFolder: options.migrationsFolder,
104+ workspaceRoot: options.workspaceRoot,
103105 executionResolver: options.executionResolver,
104106 resolveSecretMaterial: options.resolveSecretMaterial,
105107 getLocalServerBaseUrl,
@@ -207,7 +209,7 @@ const createRuntimeWithLegacyMigration = ( · 101 unmodified lines
207209 );
208210 });
209211
210−const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) =>
212+const createControlPlaneWebHandler = (runtime: ControlPlaneRuntime) =>
211213 Effect.acquireRelease(
212214 Effect.sync(() =>
213215 HttpApiBuilder.toWebHandler(

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t