History

Make local workspaces file-authoritative

Rhys Sullivan committed 8 months ago

Commit 4216bdc1 parent 904b64e51 files · +3934 −687Browse files

Unified · Split

51 files
  1. apps/executor/src/distribution/portable.test.ts +8 −3
  2. apps/executor/src/server/server.test.ts +192 −169
  3. packages/control-plane/src/persistence/control-plane-rows.test.ts +5 −0
  4. packages/control-plane/src/persistence/post-migrations.test.ts +38 −18
  5. packages/control-plane/src/persistence/repos/policies-repo.ts +4 −2
  6. packages/control-plane/src/runtime/control-plane-runtime.test.ts +117 −47
  7. packages/control-plane/src/runtime/execution-http.test.ts +4 −0
  8. packages/control-plane/src/runtime/execution-mcp-resume.test.ts +4 −0
  9. packages/control-plane/src/runtime/execution-service.ts +1 −1
  10. packages/control-plane/src/runtime/executor-tools.ts +29 −5
  11. packages/control-plane/src/runtime/index.ts +117 −8
  12. packages/control-plane/src/runtime/invocation-policy-engine.test.ts +1 −0
  13. packages/control-plane/src/runtime/local-config-secrets.ts +9 −0
  14. packages/control-plane/src/runtime/local-config.ts +341 −0
  15. packages/control-plane/src/runtime/local-installation.test.ts +17 −2
  16. packages/control-plane/src/runtime/local-installation.ts +192 −28
  17. packages/control-plane/src/runtime/local-operations.ts +6 −1
  18. packages/control-plane/src/runtime/local-runtime-context.ts +29 −0
  19. packages/control-plane/src/runtime/local-source-artifacts.ts +198 −0
  20. packages/control-plane/src/runtime/local-workspace-state.ts +91 −0
  21. packages/control-plane/src/runtime/local-workspace-sync.ts +400 −0
  22. packages/control-plane/src/runtime/operation-errors.ts +1 −1
  23. packages/control-plane/src/runtime/operations-shared.ts +1 −1
  24. packages/control-plane/src/runtime/policies-operations.ts +430 −0
  25. packages/control-plane/src/runtime/secret-material-providers.ts +178 −1
  26. packages/control-plane/src/runtime/source-adapters/mcp.ts +1 −1
  27. packages/control-plane/src/runtime/source-auth-material.test.ts +1 −0
  28. packages/control-plane/src/runtime/source-auth-service.test.ts +1 −0
  29. packages/control-plane/src/runtime/source-auth-service.ts +105 −62
  30. packages/control-plane/src/runtime/source-definitions.test.ts +1 −0
  31. packages/control-plane/src/runtime/source-definitions.ts +3 −0
  32. packages/control-plane/src/runtime/source-inspection.test.ts +2 −0
  33. packages/control-plane/src/runtime/source-inspection.ts +40 −51
  34. packages/control-plane/src/runtime/source-materialization.ts +149 −2
  35. packages/control-plane/src/runtime/source-recipes-runtime.test.ts +5 −0
  36. packages/control-plane/src/runtime/source-recipes-runtime.ts +177 −189
  37. packages/control-plane/src/runtime/source-store.test.ts +1 −0
  38. packages/control-plane/src/runtime/source-store.ts +631 −0
  39. packages/control-plane/src/runtime/sources-operations.ts +39 −31
  40. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +2 −0
  41. packages/control-plane/src/runtime/workspace-execution-environment.ts +104 −51
  42. packages/control-plane/src/schema/index.ts +1 −0
  43. packages/control-plane/src/schema/models/local-config.ts +184 −0
  44. packages/control-plane/src/schema/models/policy.ts +51 −10
  45. packages/control-plane/src/schema/models/source.ts +3 −0
  46. packages/kitchen-sink/src/source-runtime.test.ts +1 −0
  47. packages/react/src/index.test.tsx +7 −1
  48. packages/react/src/index.ts +1 −0
  49. packages/runtime-ses/src/index.ts +0 −1
  50. packages/runtime-ses/src/sandbox-worker.mjs +9 −1
  51. packages/server/src/index.ts +2 −0
apps/executor/src/distribution/portable.test.ts+8−3
@@ -100,7 +100,12 @@ describe("portable distribution flow", () => { · 99 unmodified lines
100 const distDir = join(tempRoot, "dist");100 const distDir = join(tempRoot, "dist");
101 const homeDir = join(tempRoot, "home");101 const homeDir = join(tempRoot, "home");
102 const binDir = join(homeDir, ".local", "bin");102 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");
104 const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor");109 const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor");
105 const baseUrl = `http://127.0.0.1:${await allocatePort()}`;110 const baseUrl = `http://127.0.0.1:${await allocatePort()}`;
106 111
@@ -176,9 +181,9 @@ describe("portable distribution flow", () => { · 69 unmodified lines
176 expect(status.reachable).toBe(true);181 expect(status.reachable).toBe(true);
177 expect(status.pidRunning).toBe(true);182 expect(status.pidRunning).toBe(true);
178 expect(status.installation).not.toBeNull();183 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);
180 expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true);185 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);
182 187
183 const html = await fetch(new URL("/", baseUrl));188 const html = await fetch(new URL("/", baseUrl));
184 expect(html.status).toBe(200);189 expect(html.status).toBe(200);
apps/executor/src/server/server.test.ts+192−169
@@ -1,5 +1,7 @@
1import { createServer, type IncomingMessage, type ServerResponse } from "node:http";1import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2import { randomUUID } from "node:crypto";2import { createHash, randomUUID } from "node:crypto";
3import { mkdir, writeFile } from "node:fs/promises";
4import { join } from "node:path";
3 5
4import {6import {
5 HttpApi,7 HttpApi,
@@ -7,7 +9,9 @@ import { · 1 unmodified line
7 HttpApiGroup,9 HttpApiGroup,
8 HttpApiSchema,10 HttpApiSchema,
9 OpenApi,11 OpenApi,
12 FileSystem,
10} from "@effect/platform";13} from "@effect/platform";
14import { NodeFileSystem } from "@effect/platform-node";
11import { describe, expect, it } from "@effect/vitest";15import { describe, expect, it } from "@effect/vitest";
12import { assertTrue } from "@effect/vitest/utils";16import { assertTrue } from "@effect/vitest/utils";
13import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
@@ -57,7 +61,160 @@ const executionResolver: ResolveExecutionEnvironment = () => · 43 unmodified lines
57 }),61 }),
58 });62 });
59 63
60const makeServer = createLocalExecutorServer({64const makeTempWorkspaceRoot = () =>
65 FileSystem.FileSystem.pipe(
66 Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "executor-server-test-" })),
67 Effect.provide(NodeFileSystem.layer),
68 );
69
70const createIsolatedLocalExecutorServer = (
71 options: Parameters<typeof createLocalExecutorServer>[0] = {},
72) =>
73 Effect.gen(function* () {
74 const workspaceRoot = yield* makeTempWorkspaceRoot();
75 const server = yield* createLocalExecutorServer({
76 ...options,
77 workspaceRoot,
78 });
79 return {
80 ...server,
81 workspaceRoot,
82 };
83 });
84
85const stableLocalSourceId = (input: {
86 workspaceRoot: string;
87 configKey: string;
88}) =>
89 SourceIdSchema.make(
90 `src_local_${createHash("sha256")
91 .update(`${input.workspaceRoot}:${input.configKey}`)
92 .digest("hex")
93 .slice(0, 16)}`,
94 );
95
96const writeConfiguredLocalMcpSource = (input: {
97 workspaceRoot: string;
98 configKey: string;
99 endpoint: string;
100 name?: string;
101 namespace?: string;
102}) =>
103 Effect.promise(async () => {
104 const sourceId = stableLocalSourceId({
105 workspaceRoot: input.workspaceRoot,
106 configKey: input.configKey,
107 });
108 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${sourceId}`);
109 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
110 const now = Date.now();
111 const manifest = {
112 version: 1 as const,
113 tools: [{
114 toolId: "gated_echo",
115 toolName: "gated_echo",
116 description: "Asks for approval before echoing a value",
117 inputSchemaJson: JSON.stringify({
118 type: "object",
119 properties: {
120 value: {
121 type: "string",
122 },
123 },
124 required: ["value"],
125 additionalProperties: false,
126 }),
127 }],
128 };
129 const manifestJson = JSON.stringify(manifest);
130
131 await mkdir(join(input.workspaceRoot, ".executor", "artifacts", "sources"), {
132 recursive: true,
133 });
134 await writeFile(
135 join(input.workspaceRoot, ".executor", "executor.jsonc"),
136 `${JSON.stringify({
137 sources: {
138 [input.configKey]: {
139 kind: "mcp",
140 name: input.name ?? "Demo",
141 namespace: input.namespace ?? input.configKey,
142 connection: {
143 endpoint: input.endpoint,
144 },
145 binding: {
146 transport: "streamable-http",
147 },
148 },
149 },
150 }, null, 2)}\n`,
151 "utf8",
152 );
153 await writeFile(
154 join(input.workspaceRoot, ".executor", "artifacts", "sources", `${input.configKey}.json`),
155 `${JSON.stringify({
156 version: 1,
157 sourceId,
158 configKey: input.configKey,
159 recipeId,
160 generatedAt: now,
161 revision: {
162 id: recipeRevisionId,
163 recipeId,
164 revisionNumber: 1,
165 sourceConfigJson: JSON.stringify({
166 kind: "mcp",
167 endpoint: input.endpoint,
168 transport: "streamable-http",
169 queryParams: null,
170 headers: null,
171 }),
172 manifestJson,
173 manifestHash: null,
174 materializationHash: null,
175 createdAt: now,
176 updatedAt: now,
177 },
178 documents: [{
179 id: `src_recipe_doc_${sourceId}`,
180 recipeRevisionId,
181 documentKind: "mcp_manifest",
182 documentKey: "manifest",
183 contentText: manifestJson,
184 contentHash: `manifest_${sourceId}`,
185 fetchedAt: now,
186 createdAt: now,
187 updatedAt: now,
188 }],
189 schemaBundles: [],
190 operations: [{
191 id: `src_recipe_op_${sourceId}`,
192 recipeRevisionId,
193 operationKey: "gated_echo",
194 transportKind: "mcp",
195 toolId: "gated_echo",
196 title: "gated_echo",
197 description: "Asks for approval before echoing a value",
198 operationKind: "unknown",
199 searchText: "demo.gated_echo gated echo approval",
200 inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null,
201 outputSchemaJson: null,
202 providerKind: "mcp",
203 providerDataJson: JSON.stringify({
204 kind: "mcp",
205 toolId: "gated_echo",
206 toolName: "gated_echo",
207 description: "Asks for approval before echoing a value",
208 }),
209 createdAt: now,
210 updatedAt: now,
211 }],
212 }, null, 2)}\n`,
213 "utf8",
214 );
215 });
216
217const makeServer = createIsolatedLocalExecutorServer({
61 port: 0,218 port: 0,
62 localDataDir: ":memory:",219 localDataDir: ":memory:",
63 executionResolver,220 executionResolver,
@@ -578,28 +735,6 @@ const extractUrlInteractionUrl = (payloadJson: string): string => { · 514 unmodified lines
578 return url;735 return url;
579};736};
580 737
581const waitForExecutionCompletion = (input: {
582 client: ControlPlaneClient;
583 workspaceId: string;
584 executionId: string;
585}) =>
586 Effect.gen(function* () {
587 while (true) {
588 const next = yield* input.client.executions.get({
589 path: {
590 workspaceId: input.workspaceId as never,
591 executionId: input.executionId as never,
592 },
593 });
594
595 if (next.execution.status !== "waiting_for_interaction") {
596 return next;
597 }
598
599 yield* Effect.sleep("100 millis");
600 }
601 });
602
603describe("local-executor-server", () => {738describe("local-executor-server", () => {
604 it.scoped("serves the control-plane API and executes code", () =>739 it.scoped("serves the control-plane API and executes code", () =>
605 Effect.gen(function* () {740 Effect.gen(function* () {
@@ -662,7 +797,7 @@ describe("local-executor-server", () => { · 56 unmodified lines
662 797
663 it.scoped("serves only execute over MCP when elicitation is supported", () =>798 it.scoped("serves only execute over MCP when elicitation is supported", () =>
664 Effect.gen(function* () {799 Effect.gen(function* () {
665 const server = yield* createLocalExecutorServer({800 const server = yield* createIsolatedLocalExecutorServer({
666 port: 0,801 port: 0,
667 localDataDir: ":memory:",802 localDataDir: ":memory:",
668 executionResolver: gatedExecutionResolver,803 executionResolver: gatedExecutionResolver,
@@ -719,7 +854,7 @@ describe("local-executor-server", () => { · 50 unmodified lines
719 854
720 it.scoped("serves execute and resume over MCP when elicitation is unavailable", () =>855 it.scoped("serves execute and resume over MCP when elicitation is unavailable", () =>
721 Effect.gen(function* () {856 Effect.gen(function* () {
722 const server = yield* createLocalExecutorServer({857 const server = yield* createIsolatedLocalExecutorServer({
723 port: 0,858 port: 0,
724 localDataDir: ":memory:",859 localDataDir: ":memory:",
725 executionResolver: gatedExecutionResolver,860 executionResolver: gatedExecutionResolver,
@@ -798,7 +933,7 @@ describe("local-executor-server", () => { · 72 unmodified lines
798 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),933 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
799 );934 );
800 935
801 const server = yield* createLocalExecutorServer({936 const server = yield* createIsolatedLocalExecutorServer({
802 port: 0,937 port: 0,
803 localDataDir: ":memory:",938 localDataDir: ":memory:",
804 });939 });
@@ -879,7 +1014,7 @@ describe("local-executor-server", () => { · 74 unmodified lines
879 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),1014 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
880 );1015 );
881 1016
882 const server = yield* createLocalExecutorServer({1017 const server = yield* createIsolatedLocalExecutorServer({
883 port: 0,1018 port: 0,
884 localDataDir: ":memory:",1019 localDataDir: ":memory:",
885 });1020 });
@@ -957,7 +1092,7 @@ describe("local-executor-server", () => { · 71 unmodified lines
957 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),1092 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
958 );1093 );
959 1094
960 const server = yield* createLocalExecutorServer({1095 const server = yield* createIsolatedLocalExecutorServer({
961 port: 0,1096 port: 0,
962 localDataDir: ":memory:",1097 localDataDir: ":memory:",
963 });1098 });
@@ -1046,7 +1181,7 @@ describe("local-executor-server", () => { · 82 unmodified lines
1046 }),1181 }),
1047 );1182 );
1048 1183
1049 const server = yield* createLocalExecutorServer({1184 const server = yield* createIsolatedLocalExecutorServer({
1050 port: 0,1185 port: 0,
1051 localDataDir: ":memory:",1186 localDataDir: ":memory:",
1052 });1187 });
@@ -1092,7 +1227,7 @@ describe("local-executor-server", () => { · 39 unmodified lines
1092 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),1227 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
1093 );1228 );
1094 1229
1095 const server = yield* createLocalExecutorServer({1230 const server = yield* createIsolatedLocalExecutorServer({
1096 port: 0,1231 port: 0,
1097 localDataDir: ":memory:",1232 localDataDir: ":memory:",
1098 });1233 });
@@ -1129,29 +1264,28 @@ describe("local-executor-server", () => { · 30 unmodified lines
1129 }),1264 }),
1130 );1265 );
1131 assertTrue(callbackResponse.ok);1266 assertTrue(callbackResponse.ok);
1132 expect(yield* Effect.promise(() => callbackResponse.text())).toContain("Source connected:");1267 const callbackText = yield* Effect.promise(() => callbackResponse.text());
1133 1268 expect(callbackText).toContain("Source connected:");
1134 const completed = yield* waitForExecutionCompletion({1269
1135 client,1270 const connectedSource = yield* Effect.gen(function* () {
1136 workspaceId: installation.workspaceId,1271 while (true) {
1137 executionId: added.execution.id,1272 const sources = yield* client.sources.list({
1138 });1273 path: {
1139 1274 workspaceId: installation.workspaceId,
1140 expect(completed.execution.status).toBe("completed");1275 },
1141 expect(completed.pendingInteraction).toBeNull();1276 });
1142 expect(completed.execution.resultJson).toContain('"name":"Axiom"');1277 const source = sources.find((entry) => entry.namespace === "axiom");
1143 expect(completed.execution.resultJson).toContain('"status":"connected"');1278 if (source?.status === "connected" && source.auth.kind === "oauth2") {
1279 return source;
1280 }
1144 1281
1145 const sources = yield* client.sources.list({1282 yield* Effect.sleep("100 millis");
1146 path: {1283 }
1147 workspaceId: installation.workspaceId,
1148 },
1149 });1284 });
1150 1285
1151 expect(sources).toHaveLength(1);1286 expect(connectedSource.name).toBe("Axiom");
1152 expect(sources[0]?.name).toBe("Axiom");1287 expect(connectedSource.status).toBe("connected");
1153 expect(sources[0]?.status).toBe("connected");1288 expect(connectedSource.auth.kind).toBe("oauth2");
1154 expect(sources[0]?.auth.kind).toBe("oauth2");
1155 1289
1156 const toolCall = yield* client.executions.create({1290 const toolCall = yield* client.executions.create({
1157 path: {1291 path: {
@@ -1175,7 +1309,7 @@ describe("local-executor-server", () => { · 17 unmodified lines
1175 Effect.promise(() => startMutatingOpenApiDemoServer()),1309 Effect.promise(() => startMutatingOpenApiDemoServer()),
1176 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),1310 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
1177 );1311 );
1178 const server = yield* createLocalExecutorServer({1312 const server = yield* createIsolatedLocalExecutorServer({
1179 port: 0,1313 port: 0,
1180 localDataDir: ":memory:",1314 localDataDir: ":memory:",
1181 });1315 });
@@ -1277,7 +1411,7 @@ describe("local-executor-server", () => { · 95 unmodified lines
1277 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),1411 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
1278 );1412 );
1279 1413
1280 const server = yield* createLocalExecutorServer({1414 const server = yield* createIsolatedLocalExecutorServer({
1281 port: 0,1415 port: 0,
1282 localDataDir: ":memory:",1416 localDataDir: ":memory:",
1283 });1417 });
@@ -1345,7 +1479,7 @@ describe("local-executor-server", () => { · 61 unmodified lines
1345 1479
1346 it.scoped("marks execution failed when a configured MCP endpoint is invalid", () =>1480 it.scoped("marks execution failed when a configured MCP endpoint is invalid", () =>
1347 Effect.gen(function* () {1481 Effect.gen(function* () {
1348 const server = yield* createLocalExecutorServer({1482 const server = yield* createIsolatedLocalExecutorServer({
1349 port: 0,1483 port: 0,
1350 localDataDir: ":memory:",1484 localDataDir: ":memory:",
1351 });1485 });
@@ -1358,123 +1492,12 @@ describe("local-executor-server", () => { · 6 unmodified lines
1358 baseUrl: server.baseUrl,1492 baseUrl: server.baseUrl,
1359 accountId: installation.accountId,1493 accountId: installation.accountId,
1360 });1494 });
1361 1495 yield* writeConfiguredLocalMcpSource({
1362 const sourceId = SourceIdSchema.make("src_invalid_mcp_endpoint");1496 workspaceRoot: server.workspaceRoot,
1363 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${sourceId}`);1497 configKey: "demo",
1364 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
1365 const now = Date.now();
1366 const manifest = {
1367 version: 1 as const,
1368 tools: [{
1369 toolId: "gated_echo",
1370 toolName: "gated_echo",
1371 description: "Asks for approval before echoing a value",
1372 inputSchemaJson: JSON.stringify({
1373 type: "object",
1374 properties: {
1375 value: {
1376 type: "string",
1377 },
1378 },
1379 required: ["value"],
1380 additionalProperties: false,
1381 }),
1382 }],
1383 };
1384 const manifestJson = JSON.stringify(manifest);
1385 yield* server.runtime.persistence.rows.sourceRecipes.upsert({
1386 id: recipeId,
1387 kind: "mcp",
1388 adapterKey: "mcp",
1389 providerKey: "generic_mcp",
1390 name: "Demo",
1391 summary: null,
1392 visibility: "workspace",
1393 latestRevisionId: recipeRevisionId,
1394 createdAt: now,
1395 updatedAt: now,
1396 });
1397 yield* server.runtime.persistence.rows.sourceRecipeRevisions.upsert({
1398 id: recipeRevisionId,
1399 recipeId,
1400 revisionNumber: 1,
1401 sourceConfigJson: JSON.stringify({
1402 kind: "mcp",
1403 endpoint: "http://127.0.0.1:PORT/mcp",
1404 transport: "streamable-http",
1405 queryParams: null,
1406 headers: null,
1407 }),
1408 manifestJson,
1409 manifestHash: null,
1410 materializationHash: null,
1411 createdAt: now,
1412 updatedAt: now,
1413 });
1414 yield* server.runtime.persistence.rows.sources.insert({
1415 id: sourceId,
1416 workspaceId: installation.workspaceId,
1417 recipeId,
1418 recipeRevisionId,
1419 name: "Demo",
1420 kind: "mcp",
1421 endpoint: "http://127.0.0.1:PORT/mcp",1498 endpoint: "http://127.0.0.1:PORT/mcp",
1422 status: "connected",1499 name: "Demo",
1423 enabled: true,
1424 namespace: "demo",1500 namespace: "demo",
1425 importAuthPolicy: "reuse_runtime",
1426 bindingConfigJson: JSON.stringify({
1427 adapterKey: "mcp",
1428 version: 1,
1429 payload: {
1430 transport: "streamable-http",
1431 queryParams: null,
1432 headers: null,
1433 },
1434 }),
1435 sourceHash: null,
1436 lastError: null,
1437 createdAt: now,
1438 updatedAt: now,
1439 });
1440 yield* server.runtime.persistence.rows.sourceRecipeDocuments.replaceForRevision({
1441 recipeRevisionId,
1442 documents: [{
1443 id: `src_recipe_doc_${sourceId}`,
1444 recipeRevisionId,
1445 documentKind: "mcp_manifest",
1446 documentKey: "manifest",
1447 contentText: manifestJson,
1448 contentHash: `manifest_${sourceId}`,
1449 fetchedAt: now,
1450 createdAt: now,
1451 updatedAt: now,
1452 }],
1453 });
1454 yield* server.runtime.persistence.rows.sourceRecipeOperations.replaceForRevision({
1455 recipeRevisionId,
1456 operations: [{
1457 id: `src_recipe_op_${sourceId}`,
1458 recipeRevisionId,
1459 operationKey: "gated_echo",
1460 transportKind: "mcp",
1461 toolId: "gated_echo",
1462 title: "gated_echo",
1463 description: "Asks for approval before echoing a value",
1464 operationKind: "unknown",
1465 searchText: "demo.gated_echo gated echo approval",
1466 inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null,
1467 outputSchemaJson: null,
1468 providerKind: "mcp",
1469 providerDataJson: JSON.stringify({
1470 kind: "mcp",
1471 toolId: "gated_echo",
1472 toolName: "gated_echo",
1473 description: "Asks for approval before echoing a value",
1474 }),
1475 createdAt: now,
1476 updatedAt: now,
1477 }],
1478 });1501 });
1479 1502
1480 const execution = yield* client.executions.create({1503 const execution = yield* client.executions.create({
packages/control-plane/src/persistence/control-plane-rows.test.ts+5−0
@@ -140,6 +140,7 @@ const seedWorkspaceSourceState = (input: { · 139 unmodified lines
140 yield* input.persistence.rows.sources.insert({140 yield* input.persistence.rows.sources.insert({
141 id: input.sourceId,141 id: input.sourceId,
142 workspaceId: input.workspaceId,142 workspaceId: input.workspaceId,
143 configKey: null,
143 recipeId,144 recipeId,
144 recipeRevisionId,145 recipeRevisionId,
145 name: "Github",146 name: "Github",
@@ -277,6 +278,7 @@ describe("control-plane-persistence-drizzle", () => { · 131 unmodified lines
277 yield* persistence.rows.sources.insert({278 yield* persistence.rows.sources.insert({
278 id: SourceIdSchema.make("src_1"),279 id: SourceIdSchema.make("src_1"),
279 workspaceId,280 workspaceId,
281 configKey: null,
280 recipeId: SourceRecipeIdSchema.make("src_recipe_1"),282 recipeId: SourceRecipeIdSchema.make("src_recipe_1"),
281 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"),283 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"),
282 name: "Github",284 name: "Github",
@@ -295,6 +297,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
295 297
296 yield* persistence.rows.policies.insert({298 yield* persistence.rows.policies.insert({
297 id: PolicyIdSchema.make("pol_1"),299 id: PolicyIdSchema.make("pol_1"),
300 configKey: null,
298 scopeType: "workspace",301 scopeType: "workspace",
299 organizationId,302 organizationId,
300 workspaceId,303 workspaceId,
@@ -860,6 +863,7 @@ describe("control-plane-persistence-drizzle", () => { · 559 unmodified lines
860 yield* persistence.rows.sources.insert({863 yield* persistence.rows.sources.insert({
861 id: SourceIdSchema.make("src_shared_removed"),864 id: SourceIdSchema.make("src_shared_removed"),
862 workspaceId: removedWorkspaceId,865 workspaceId: removedWorkspaceId,
866 configKey: null,
863 recipeId: sharedRecipeId,867 recipeId: sharedRecipeId,
864 recipeRevisionId: sharedRecipeRevisionId,868 recipeRevisionId: sharedRecipeRevisionId,
865 name: "Github Removed",869 name: "Github Removed",
@@ -878,6 +882,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
878 yield* persistence.rows.sources.insert({882 yield* persistence.rows.sources.insert({
879 id: SourceIdSchema.make("src_shared_remaining"),883 id: SourceIdSchema.make("src_shared_remaining"),
880 workspaceId: remainingWorkspaceId,884 workspaceId: remainingWorkspaceId,
885 configKey: null,
881 recipeId: sharedRecipeId,886 recipeId: sharedRecipeId,
882 recipeRevisionId: sharedRecipeRevisionId,887 recipeRevisionId: sharedRecipeRevisionId,
883 name: "Github Remaining",888 name: "Github Remaining",
packages/control-plane/src/persistence/post-migrations.test.ts+38−18
@@ -188,6 +188,7 @@ const seedMigratedSourceRecipe = (input: { · 187 unmodified lines
188 yield* input.persistence.rows.sources.insert({188 yield* input.persistence.rows.sources.insert({
189 id: input.sourceId,189 id: input.sourceId,
190 workspaceId: input.workspaceId,190 workspaceId: input.workspaceId,
191 configKey: null,
191 recipeId,192 recipeId,
192 recipeRevisionId,193 recipeRevisionId,
193 name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",194 name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",
@@ -823,24 +824,43 @@ describe("code-migrations", () => { · 629 unmodified lines
823 createdAt: now,824 createdAt: now,
824 updatedAt: now,825 updatedAt: now,
825 })));826 })));
826 await Effect.runPromise(legacyPersistence.rows.sources.insert({827 await legacyPersistence.db.execute(sql`
827 id: sourceId,828 INSERT INTO "sources" (
828 workspaceId,829 "source_id",
829 recipeId,830 "workspace_id",
830 recipeRevisionId,831 "recipe_id",
831 name: "Legacy OpenAPI",832 "recipe_revision_id",
832 kind: "openapi",833 "name",
833 endpoint: "https://api.example.com",834 "kind",
834 status: "connected",835 "endpoint",
835 enabled: true,836 "status",
836 namespace: "legacy.openapi",837 "enabled",
837 importAuthPolicy: "reuse_runtime",838 "namespace",
838 bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"),839 "import_auth_policy",
839 sourceHash: null,840 "binding_config_json",
840 lastError: null,841 "source_hash",
841 createdAt: now,842 "last_error",
842 updatedAt: now,843 "created_at",
843 }));844 "updated_at"
845 ) VALUES (
846 ${sourceId},
847 ${workspaceId},
848 ${recipeId},
849 ${recipeRevisionId},
850 ${"Legacy OpenAPI"},
851 ${"openapi"},
852 ${"https://api.example.com"},
853 ${"connected"},
854 ${true},
855 ${"legacy.openapi"},
856 ${"reuse_runtime"},
857 ${openApiBindingConfigJson("https://api.example.com/openapi.json")},
858 NULL,
859 NULL,
860 ${now},
861 ${now}
862 )
863 `);
844 await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({864 await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({
845 id: authArtifactId as any,865 id: authArtifactId as any,
846 workspaceId,866 workspaceId,
packages/control-plane/src/persistence/repos/policies-repo.ts+4−2
@@ -86,7 +86,8 @@ export const createPoliciesRepo = ( · 85 unmodified lines
86 86
87 insert: (policy: Policy) =>87 insert: (policy: Policy) =>
88 client.use("rows.policies.insert", async (db) => {88 client.use("rows.policies.insert", async (db) => {
89 await db.insert(tables.policiesTable).values(policy);89 const { configKey: _configKey, ...row } = policy;
90 await db.insert(tables.policiesTable).values(row);
90 }),91 }),
91 92
92 update: (93 update: (
@@ -94,9 +95,10 @@ export const createPoliciesRepo = ( · 1 unmodified line
94 patch: Partial<Omit<Policy, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">>,95 patch: Partial<Omit<Policy, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">>,
95 ) =>96 ) =>
96 client.use("rows.policies.update", async (db) => {97 client.use("rows.policies.update", async (db) => {
98 const { configKey: _configKey, ...rowPatch } = patch;
97 const rows = await db99 const rows = await db
98 .update(tables.policiesTable)100 .update(tables.policiesTable)
99 .set(patch)101 .set(rowPatch)
100 .where(eq(tables.policiesTable.id, policyId))102 .where(eq(tables.policiesTable.id, policyId))
101 .returning();103 .returning();
102 104
packages/control-plane/src/runtime/control-plane-runtime.test.ts+117−47
@@ -1,4 +1,7 @@
1import { createServer } from "node:http";1import { createServer } from "node:http";
2import { mkdtempSync, readFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
2 5
3import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
4import { assertTrue } from "@effect/vitest/utils";7import { assertTrue } from "@effect/vitest/utils";
@@ -12,8 +15,6 @@ import { · 7 unmodified lines
12 ExecutionInteractionIdSchema,15 ExecutionInteractionIdSchema,
13 SecretMaterialIdSchema,16 SecretMaterialIdSchema,
14 SourceIdSchema,17 SourceIdSchema,
15 SourceRecipeIdSchema,
16 SourceRecipeRevisionIdSchema,
17} from "#schema";18} from "#schema";
18import type { ToolPath } from "@executor/codemode-core";19import type { ToolPath } from "@executor/codemode-core";
19 20
@@ -21,24 +22,19 @@ import { · 1 unmodified line
21 createSqlControlPlaneRuntime,22 createSqlControlPlaneRuntime,
22 LiveExecutionManagerService,23 LiveExecutionManagerService,
23} from "./index";24} from "./index";
25import { createSourceFromPayload } from "./source-definitions";
24import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";26import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
27import { persistSource } from "./source-store";
25import { withControlPlaneClient } from "./test-http-client";28import { withControlPlaneClient } from "./test-http-client";
26 29
27const makeRuntime = Effect.acquireRelease(30const makeRuntime = Effect.acquireRelease(
28 createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),31 createSqlControlPlaneRuntime({
32 localDataDir: ":memory:",
33 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")),
34 }),
29 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),35 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
30);36);
31 37
32const openApiBindingConfigJson = (specUrl: string): string =>
33 JSON.stringify({
34 adapterKey: "openapi",
35 version: 1,
36 payload: {
37 specUrl,
38 defaultHeaders: null,
39 },
40 });
41
42type OpenApiSpecServer = {38type OpenApiSpecServer = {
43 baseUrl: string;39 baseUrl: string;
44 specUrl: string;40 specUrl: string;
@@ -123,6 +119,64 @@ const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => · 78 unmodified lines
123 );119 );
124 120
125describe("control-plane-runtime", () => {121describe("control-plane-runtime", () => {
122 it.scoped("writes local source changes through executor.jsonc", () =>
123 Effect.gen(function* () {
124 const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-local-config-runtime-"));
125 const runtime = yield* Effect.acquireRelease(
126 createSqlControlPlaneRuntime({
127 localDataDir: ":memory:",
128 workspaceRoot,
129 }),
130 (createdRuntime) => Effect.promise(() => createdRuntime.close()).pipe(Effect.orDie),
131 );
132 const openApiServer = yield* makeOpenApiSpecServer;
133 const installation = runtime.localInstallation;
134
135 const createdSource = yield* withControlPlaneClient(
136 { runtime, accountId: installation.accountId },
137 (client) =>
138 client.sources.create({
139 path: { workspaceId: installation.workspaceId },
140 payload: {
141 name: "GitHub",
142 kind: "openapi",
143 endpoint: openApiServer.baseUrl,
144 namespace: "github",
145 binding: {
146 specUrl: openApiServer.specUrl,
147 defaultHeaders: null,
148 },
149 auth: { kind: "none" },
150 },
151 }),
152 );
153
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);
160
161 const removed = yield* withControlPlaneClient(
162 { runtime, accountId: installation.accountId },
163 (client) =>
164 client.sources.remove({
165 path: {
166 workspaceId: installation.workspaceId,
167 sourceId: createdSource.id,
168 },
169 }),
170 );
171 expect(removed.removed).toBe(true);
172
173 const removedConfig = JSON.parse(readFileSync(configPath, "utf8")) as {
174 sources?: Record<string, unknown>;
175 };
176 expect(removedConfig.sources?.github).toBeUndefined();
177 }),
178 );
179
126 it.scoped("supports full CRUD flow over HTTP API", () =>180 it.scoped("supports full CRUD flow over HTTP API", () =>
127 Effect.gen(function* () {181 Effect.gen(function* () {
128 const runtime = yield* makeRuntime;182 const runtime = yield* makeRuntime;
@@ -293,24 +347,32 @@ describe("control-plane-runtime", () => { · 164 unmodified lines
293 updatedAt: now,347 updatedAt: now,
294 });348 });
295 349
296 yield* runtime.persistence.rows.sources.insert({350 const localSource = yield* createSourceFromPayload({
297 id: sourceId,
298 workspaceId: installation.workspaceId,351 workspaceId: installation.workspaceId,
299 recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`),352 sourceId,
300 recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`),353 payload: {
301 name: "GitHub",354 name: "GitHub",
302 kind: "openapi",355 kind: "openapi",
303 endpoint: "https://api.github.com",356 endpoint: "https://api.github.com",
304 status: "auth_required",357 status: "auth_required",
305 enabled: true,358 enabled: true,
306 namespace: "github",359 namespace: "github",
307 importAuthPolicy: "reuse_runtime",360 importAuthPolicy: "reuse_runtime",
308 bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),361 binding: {
309 sourceHash: null,362 specUrl: "https://example.com/github-openapi.yaml",
310 lastError: null,363 defaultHeaders: null,
311 createdAt: now,364 },
312 updatedAt: now,365 importAuth: { kind: "none" },
313 });366 auth: { kind: "none" },
367 },
368 now,
369 }).pipe(Effect.orDie);
370 yield* persistSource(runtime.persistence.rows, localSource, {
371 actorAccountId: installation.accountId,
372 }).pipe(
373 Effect.provide(runtime.runtimeLayer),
374 Effect.orDie,
375 );
314 376
315 const interactionFiber = yield* Effect.gen(function* () {377 const interactionFiber = yield* Effect.gen(function* () {
316 const liveExecutionManager = yield* LiveExecutionManagerService;378 const liveExecutionManager = yield* LiveExecutionManagerService;
@@ -448,24 +510,32 @@ describe("control-plane-runtime", () => { · 131 unmodified lines
448 updatedAt: now,510 updatedAt: now,
449 });511 });
450 512
451 yield* runtime.persistence.rows.sources.insert({513 const localSource = yield* createSourceFromPayload({
452 id: sourceId,
453 workspaceId: installation.workspaceId,514 workspaceId: installation.workspaceId,
454 recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`),515 sourceId,
455 recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`),516 payload: {
456 name: "GitHub",517 name: "GitHub",
457 kind: "openapi",518 kind: "openapi",
458 endpoint: "https://api.github.com",519 endpoint: "https://api.github.com",
459 status: "auth_required",520 status: "auth_required",
460 enabled: true,521 enabled: true,
461 namespace: "github",522 namespace: "github",
462 importAuthPolicy: "reuse_runtime",523 importAuthPolicy: "reuse_runtime",
463 bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),524 binding: {
464 sourceHash: null,525 specUrl: "https://example.com/github-openapi.yaml",
465 lastError: null,526 defaultHeaders: null,
466 createdAt: now,527 },
467 updatedAt: now,528 importAuth: { kind: "none" },
468 });529 auth: { kind: "none" },
530 },
531 now,
532 }).pipe(Effect.orDie);
533 yield* persistSource(runtime.persistence.rows, localSource, {
534 actorAccountId: installation.accountId,
535 }).pipe(
536 Effect.provide(runtime.runtimeLayer),
537 Effect.orDie,
538 );
469 539
470 const interactionFiber = yield* Effect.gen(function* () {540 const interactionFiber = yield* Effect.gen(function* () {
471 const liveExecutionManager = yield* LiveExecutionManagerService;541 const liveExecutionManager = yield* LiveExecutionManagerService;
packages/control-plane/src/runtime/execution-http.test.ts+4−0
@@ -1,3 +1,6 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
1import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
@@ -39,6 +42,7 @@ const makeExecutionResolver = () => { · 35 unmodified lines
39const makeRuntime = Effect.acquireRelease(42const makeRuntime = Effect.acquireRelease(
40 createSqlControlPlaneRuntime({43 createSqlControlPlaneRuntime({
41 localDataDir: ":memory:",44 localDataDir: ":memory:",
45 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")),
42 executionResolver: makeExecutionResolver(),46 executionResolver: makeExecutionResolver(),
43 }),47 }),
44 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),48 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+4−0
@@ -5,6 +5,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines
5import { Client } from "@modelcontextprotocol/sdk/client/index.js";5import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8import { mkdtempSync } from "node:fs";
9import { tmpdir } from "node:os";
10import { join } from "node:path";
8import { describe, expect, it } from "@effect/vitest";11import { describe, expect, it } from "@effect/vitest";
9import { assertTrue } from "@effect/vitest/utils";12import { assertTrue } from "@effect/vitest/utils";
10import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
@@ -283,6 +286,7 @@ describe("execution-mcp-resume", () => { · 272 unmodified lines
283 const runtime = yield* Effect.acquireRelease(286 const runtime = yield* Effect.acquireRelease(
284 createSqlControlPlaneRuntime({287 createSqlControlPlaneRuntime({
285 localDataDir: ":memory:",288 localDataDir: ":memory:",
289 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")),
286 executionResolver: makeMcpExecutionResolver(mcpServer.endpoint),290 executionResolver: makeMcpExecutionResolver(mcpServer.endpoint),
287 }),291 }),
288 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),292 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
packages/control-plane/src/runtime/execution-service.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
2 ControlPlaneBadRequestError,2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,4 ControlPlaneStorageError,
5} from "#api";5} from "../api/errors";
6import type {6import type {
7 CreateExecutionPayload,7 CreateExecutionPayload,
8 ResumeExecutionPayload,8 ResumeExecutionPayload,
packages/control-plane/src/runtime/executor-tools.ts+29−5
@@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines
19import * as Cause from "effect/Cause";19import * as Cause from "effect/Cause";
20import * as Exit from "effect/Exit";20import * as Exit from "effect/Exit";
21import * as Schema from "effect/Schema";21import * as Schema from "effect/Schema";
22import type { RuntimeLocalWorkspaceState } from "./local-runtime-context";
23import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
22 24
23/** Run an Effect as a Promise, preserving the original error (not FiberFailure). */25/** Run an Effect as a Promise, preserving the original error (not FiberFailure). */
24const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => {26const runEffect = async <A>(
25 const exit = await Effect.runPromiseExit(effect);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);
26 if (Exit.isSuccess(exit)) return exit.value;40 if (Exit.isSuccess(exit)) return exit.value;
27 throw Cause.squash(exit.cause);41 throw Cause.squash(exit.cause);
28};42};
@@ -106,6 +120,9 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 77 unmodified lines
106 return trimmed.length > 0 ? trimmed : null;120 return trimmed.length > 0 ? trimmed : null;
107};121};
108 122
123const toSerializableValue = <A>(value: A): A =>
124 JSON.parse(JSON.stringify(value)) as A;
125
109const resolveLocalCredentialUrl = (input: {126const resolveLocalCredentialUrl = (input: {
110 baseUrl: string;127 baseUrl: string;
111 workspaceId: WorkspaceId;128 workspaceId: WorkspaceId;
@@ -205,6 +222,7 @@ export const createExecutorToolMap = (input: { · 93 unmodified lines
205 workspaceId: WorkspaceId;222 workspaceId: WorkspaceId;
206 accountId: AccountId;223 accountId: AccountId;
207 sourceAuthService: RuntimeSourceAuthService;224 sourceAuthService: RuntimeSourceAuthService;
225 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
208}): ToolMap => ({226}): ToolMap => ({
209 "executor.sources.add": toTool({227 "executor.sources.add": toTool({
210 tool: {228 tool: {
@@ -281,10 +299,11 @@ export const createExecutorToolMap = (input: { · 70 unmodified lines
281 }299 }
282 : undefined,300 : undefined,
283 ),301 ),
302 input.runtimeLocalWorkspace,
284 );303 );
285 304
286 if (result.kind === "connected") {305 if (result.kind === "connected") {
287 return result.source;306 return toSerializableValue(result.source);
288 }307 }
289 308
290 if (result.kind === "credential_required") {309 if (result.kind === "credential_required") {
@@ -313,6 +332,7 @@ export const createExecutorToolMap = (input: { · 22 unmodified lines
313 invocation: context?.invocation,332 invocation: context?.invocation,
314 onElicitation: context?.onElicitation,333 onElicitation: context?.onElicitation,
315 }),334 }),
335 input.runtimeLocalWorkspace,
316 );336 );
317 337
318 pendingArgs = pendingResult.credentialSlot === "import"338 pendingArgs = pendingResult.credentialSlot === "import"
@@ -342,10 +362,11 @@ export const createExecutorToolMap = (input: { · 23 unmodified lines
342 }362 }
343 : undefined,363 : undefined,
344 ),364 ),
365 input.runtimeLocalWorkspace,
345 );366 );
346 367
347 if (completed.kind === "connected") {368 if (completed.kind === "connected") {
348 return completed.source;369 return toSerializableValue(completed.source);
349 }370 }
350 371
351 if (completed.kind === "credential_required") {372 if (completed.kind === "credential_required") {
@@ -385,19 +406,22 @@ export const createExecutorToolMap = (input: { · 33 unmodified lines
385 elicitationId: result.sessionId,406 elicitationId: result.sessionId,
386 },407 },
387 }),408 }),
409 input.runtimeLocalWorkspace,
388 );410 );
389 411
390 if (response.action !== "accept") {412 if (response.action !== "accept") {
391 throw new Error(`Source add was not completed for ${result.source.id}`);413 throw new Error(`Source add was not completed for ${result.source.id}`);
392 }414 }
393 415
394 return await runEffect(416 const connected = await runEffect(
395 input.sourceAuthService.getSourceById({417 input.sourceAuthService.getSourceById({
396 workspaceId: input.workspaceId,418 workspaceId: input.workspaceId,
397 sourceId: result.source.id,419 sourceId: result.source.id,
398 actorAccountId: input.accountId,420 actorAccountId: input.accountId,
399 }),421 }),
422 input.runtimeLocalWorkspace,
400 );423 );
424 return toSerializableValue(connected);
401 },425 },
402 },426 },
403 metadata: {427 metadata: {
packages/control-plane/src/runtime/index.ts+117−8
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 SqlControlPlanePersistenceLive,13 SqlControlPlanePersistenceLive,
14 SqlControlPlanePersistenceService,14 SqlControlPlanePersistenceService,
15 SqlControlPlaneRowsLive,15 SqlControlPlaneRowsLive,
16 SqlControlPlaneRowsService,
16 SqlPersistenceBootstrapError,17 SqlPersistenceBootstrapError,
17 type CreateSqlRuntimeOptions,18 type CreateSqlRuntimeOptions,
18 type SqlControlPlanePersistence,19 type SqlControlPlanePersistence,
@@ -21,25 +22,35 @@ import type { LocalInstallation } from "#schema"; · 2 unmodified lines
21 22
22import {23import {
23 ControlPlaneAuthHeaders,24 ControlPlaneAuthHeaders,
24 RuntimeActorResolverLive,
25 createHeaderActorResolver,25 createHeaderActorResolver,
26 RuntimeActorResolverLive,
26} from "./actor-resolver";27} from "./actor-resolver";
27import { type ResolveExecutionEnvironment } from "./execution-state";28import { type ResolveExecutionEnvironment } from "./execution-state";
28import {29import {
30 createLiveExecutionManager,
29 LiveExecutionManagerLive,31 LiveExecutionManagerLive,
30 LiveExecutionManagerService,32 LiveExecutionManagerService,
31} from "./live-execution";33} from "./live-execution";
32import { getOrProvisionLocalInstallation } from "./local-installation";34import { getOrProvisionLocalInstallation } from "./local-installation";
35import {
36 loadLocalExecutorConfig,
37 resolveLocalWorkspaceContext,
38} from "./local-config";
39import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
40import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";
33import {41import {
34 ControlPlaneStore,42 ControlPlaneStore,
35 ControlPlaneStoreLive,43 ControlPlaneStoreLive,
36} from "./store";44} from "./store";
37import {45import {
46 createRuntimeSourceAuthService,
38 RuntimeSourceAuthServiceLive,47 RuntimeSourceAuthServiceLive,
39 RuntimeSourceAuthServiceTag,48 RuntimeSourceAuthServiceTag,
40} from "./source-auth-service";49} from "./source-auth-service";
41import type { ResolveSecretMaterial } from "./secret-material-providers";50import type { ResolveSecretMaterial } from "./secret-material-providers";
51import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
42import {52import {
53 createWorkspaceExecutionEnvironmentResolver,
43 RuntimeExecutionResolverLive,54 RuntimeExecutionResolverLive,
44 RuntimeExecutionResolverService,55 RuntimeExecutionResolverService,
45} from "./workspace-execution-environment";56} from "./workspace-execution-environment";
@@ -68,6 +79,7 @@ export type RuntimeControlPlaneOptions = { · 22 unmodified lines
68 executionResolver?: ResolveExecutionEnvironment;79 executionResolver?: ResolveExecutionEnvironment;
69 resolveSecretMaterial?: ResolveSecretMaterial;80 resolveSecretMaterial?: ResolveSecretMaterial;
70 getLocalServerBaseUrl?: () => string | undefined;81 getLocalServerBaseUrl?: () => string | undefined;
82 workspaceRoot?: string;
71};83};
72 84
73const detailsFromCause = (cause: unknown): string =>85const detailsFromCause = (cause: unknown): string =>
@@ -96,6 +108,7 @@ const runtimeContextTags = [ · 22 unmodified lines
96 ControlPlaneActorResolver,108 ControlPlaneActorResolver,
97 ControlPlaneStore,109 ControlPlaneStore,
98 LiveExecutionManagerService,110 LiveExecutionManagerService,
111 RuntimeLocalWorkspaceService,
99 RuntimeSourceAuthServiceTag,112 RuntimeSourceAuthServiceTag,
100 RuntimeExecutionResolverService,113 RuntimeExecutionResolverService,
101] as const;114] as const;
@@ -105,6 +118,7 @@ const createRuntimeLayerFromContext = ( · 3 unmodified lines
105 ControlPlaneActorResolver118 ControlPlaneActorResolver
106 | ControlPlaneStore119 | ControlPlaneStore
107 | LiveExecutionManagerService120 | LiveExecutionManagerService
121 | RuntimeLocalWorkspaceService
108 | RuntimeSourceAuthServiceTag122 | RuntimeSourceAuthServiceTag
109 | RuntimeExecutionResolverService123 | RuntimeExecutionResolverService
110 >,124 >,
@@ -115,6 +129,7 @@ const createRuntimeLayerFromContext = ( · 4 unmodified lines
115 ControlPlaneActorResolver129 ControlPlaneActorResolver
116 | ControlPlaneStore130 | ControlPlaneStore
117 | LiveExecutionManagerService131 | LiveExecutionManagerService
132 | RuntimeLocalWorkspaceService
118 | RuntimeSourceAuthServiceTag133 | RuntimeSourceAuthServiceTag
119 | RuntimeExecutionResolverService134 | RuntimeExecutionResolverService
120 >;135 >;
@@ -165,22 +180,59 @@ export const createSqlControlPlaneRuntime = ( · 44 unmodified lines
165 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(180 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(
166 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),181 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),
167 );182 );
168 const runtimeLayer = createRuntimeControlPlaneLayer(options).pipe(183 const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe(
169 Layer.provideMerge(persistenceAndRowsLayer),184 Effect.catchAll((error) =>
185 closeScope(scope).pipe(
186 Effect.zipRight(Effect.fail(error)),
187 )),
188 );
189
190 const persistence = Context.get(baseContext, SqlControlPlanePersistenceService);
191 const rows = Context.get(baseContext, SqlControlPlaneRowsService);
192
193 const localWorkspaceContext = yield* Effect.tryPromise({
194 try: () =>
195 resolveLocalWorkspaceContext({
196 workspaceRoot: options.workspaceRoot,
197 }),
198 catch: toLocalInstallationBootstrapError,
199 }).pipe(
200 Effect.catchAll((error) =>
201 closeScope(scope).pipe(
202 Effect.zipRight(Effect.fail(error)),
203 )),
170 );204 );
171 205
172 const context = yield* Layer.buildWithScope(runtimeLayer, scope).pipe(206 const localInstallation = yield* getOrProvisionLocalInstallation({
207 rows,
208 context: localWorkspaceContext,
209 }).pipe(
210 Effect.mapError(toLocalInstallationBootstrapError),
173 Effect.catchAll((error) =>211 Effect.catchAll((error) =>
174 closeScope(scope).pipe(212 closeScope(scope).pipe(
175 Effect.zipRight(Effect.fail(error)),213 Effect.zipRight(Effect.fail(error)),
176 )),214 )),
177 );215 );
178 216
179 const persistence = Context.get(context, SqlControlPlanePersistenceService);217 const loadedLocalConfig = yield* Effect.tryPromise({
180 const store = Context.get(context, ControlPlaneStore);218 try: () => loadLocalExecutorConfig(localWorkspaceContext),
181 const concreteRuntimeLayer = createRuntimeLayerFromContext(context);219 catch: toLocalInstallationBootstrapError,
220 }).pipe(
221 Effect.catchAll((error) =>
222 closeScope(scope).pipe(
223 Effect.zipRight(Effect.fail(error)),
224 )),
225 );
182 226
183 const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe(227 const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({
228 rows,
229 context: localWorkspaceContext,
230 loadedConfig: loadedLocalConfig,
231 installation: {
232 workspaceId: localInstallation.workspaceId,
233 accountId: localInstallation.accountId,
234 },
235 }).pipe(
184 Effect.mapError(toLocalInstallationBootstrapError),236 Effect.mapError(toLocalInstallationBootstrapError),
185 Effect.catchAll((error) =>237 Effect.catchAll((error) =>
186 closeScope(scope).pipe(238 closeScope(scope).pipe(
@@ -188,6 +240,63 @@ export const createSqlControlPlaneRuntime = ( · 1 unmodified line
188 )),240 )),
189 );241 );
190 242
243 const resolveSecretMaterial =
244 options.resolveSecretMaterial
245 ?? createDefaultSecretMaterialResolver({
246 rows,
247 localConfig: effectiveLocalConfig,
248 workspaceRoot: localWorkspaceContext.workspaceRoot,
249 });
250
251 const liveExecutionManager = createLiveExecutionManager();
252 const sourceAuthService = createRuntimeSourceAuthService({
253 rows,
254 liveExecutionManager,
255 getLocalServerBaseUrl: options.getLocalServerBaseUrl,
256 localConfig: effectiveLocalConfig,
257 workspaceRoot: localWorkspaceContext.workspaceRoot,
258 localWorkspaceState: {
259 context: localWorkspaceContext,
260 installation: {
261 workspaceId: localInstallation.workspaceId,
262 accountId: localInstallation.accountId,
263 },
264 loadedConfig: {
265 ...loadedLocalConfig,
266 config: effectiveLocalConfig,
267 },
268 },
269 });
270 const executionResolver =
271 options.executionResolver
272 ?? createWorkspaceExecutionEnvironmentResolver({
273 rows,
274 sourceAuthService,
275 resolveSecretMaterial,
276 });
277 const actorResolver =
278 options.actorResolver ?? createHeaderActorResolver(rows);
279
280 const runtimeContext = Context.empty().pipe(
281 Context.add(ControlPlaneActorResolver, actorResolver),
282 Context.add(ControlPlaneStore, rows),
283 Context.add(LiveExecutionManagerService, liveExecutionManager),
284 Context.add(RuntimeLocalWorkspaceService, {
285 context: localWorkspaceContext,
286 installation: {
287 workspaceId: localInstallation.workspaceId,
288 accountId: localInstallation.accountId,
289 },
290 loadedConfig: {
291 ...loadedLocalConfig,
292 config: effectiveLocalConfig,
293 },
294 }),
295 Context.add(RuntimeSourceAuthServiceTag, sourceAuthService),
296 Context.add(RuntimeExecutionResolverService, executionResolver),
297 );
298 const concreteRuntimeLayer = createRuntimeLayerFromContext(runtimeContext);
299
191 return {300 return {
192 persistence,301 persistence,
193 localInstallation,302 localInstallation,
packages/control-plane/src/runtime/invocation-policy-engine.test.ts+1−0
@@ -35,6 +35,7 @@ const basePolicy = ( · 34 unmodified lines
35 patch: Partial<Policy> = {},35 patch: Partial<Policy> = {},
36): Policy => ({36): Policy => ({
37 id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`),37 id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`),
38 configKey: null,
38 scopeType: "workspace",39 scopeType: "workspace",
39 organizationId,40 organizationId,
40 workspaceId,41 workspaceId,
packages/control-plane/src/runtime/local-config-secrets.ts+9−0
@@ -0,0 +1,9 @@
1export const CONFIG_SECRET_PROVIDER_PREFIX = "config:";
2
3export const toConfigSecretProviderId = (providerAlias: string): string =>
4 `${CONFIG_SECRET_PROVIDER_PREFIX}${providerAlias}`;
5
6export 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.ts+341−0
@@ -0,0 +1,341 @@
1import { createHash } from "node:crypto";
2import { homedir } from "node:os";
3import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4import { promises as fs } from "node:fs";
5
6import {
7 LocalExecutorConfigSchema,
8 type LocalExecutorConfig,
9 type LocalConfigPolicy,
10 type LocalConfigSecretProvider,
11 type LocalConfigSource,
12} from "#schema";
13import * as Schema from "effect/Schema";
14
15const decodeLocalExecutorConfig = Schema.decodeUnknownSync(LocalExecutorConfigSchema);
16
17const PROJECT_CONFIG_BASENAME = "executor.jsonc";
18const PROJECT_CONFIG_FALLBACK_BASENAME = "executor.json";
19const PROJECT_CONFIG_DIRECTORY = ".executor";
20const HOME_CONFIG_DIRECTORY = join(homedir(), ".config", "executor");
21const HOME_CONFIG_PATH = join(HOME_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME);
22
23const normalizeSlashPath = (value: string): string =>
24 value.replaceAll("\\", "/");
25
26const stableHash = (value: string): string =>
27 createHash("sha256").update(value).digest("hex").slice(0, 16);
28
29const fileExists = async (path: string): Promise<boolean> => {
30 try {
31 await fs.access(path);
32 return true;
33 } catch {
34 return false;
35 }
36};
37
38const pathExists = async (path: string): Promise<boolean> => {
39 try {
40 await fs.access(path);
41 return true;
42 } catch {
43 return false;
44 }
45};
46
47const trimOrUndefined = (value: string | undefined | null): string | undefined => {
48 const trimmed = value?.trim();
49 return trimmed && trimmed.length > 0 ? trimmed : undefined;
50};
51
52const stripJsonComments = (input: string): string => {
53 let output = "";
54 let inString = false;
55 let stringChar = '"';
56 let escapeNext = false;
57 let inLineComment = false;
58 let inBlockComment = false;
59
60 for (let index = 0; index < input.length; index += 1) {
61 const char = input[index]!;
62 const next = input[index + 1];
63
64 if (inLineComment) {
65 if (char === "\n") {
66 inLineComment = false;
67 output += char;
68 }
69 continue;
70 }
71
72 if (inBlockComment) {
73 if (char === "*" && next === "/") {
74 inBlockComment = false;
75 index += 1;
76 }
77 continue;
78 }
79
80 if (inString) {
81 output += char;
82 if (escapeNext) {
83 escapeNext = false;
84 } else if (char === "\\") {
85 escapeNext = true;
86 } else if (char === stringChar) {
87 inString = false;
88 }
89 continue;
90 }
91
92 if ((char === '"' || char === "'") && !inString) {
93 inString = true;
94 stringChar = char;
95 output += char;
96 continue;
97 }
98
99 if (char === "/" && next === "/") {
100 inLineComment = true;
101 index += 1;
102 continue;
103 }
104
105 if (char === "/" && next === "*") {
106 inBlockComment = true;
107 index += 1;
108 continue;
109 }
110
111 output += char;
112 }
113
114 return output;
115};
116
117const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => {
118 try {
119 const parsed = JSON.parse(stripJsonComments(input.content)) as unknown;
120 return decodeLocalExecutorConfig(parsed);
121 } catch (cause) {
122 const message = cause instanceof Error ? cause.message : String(cause);
123 throw new Error(`Invalid executor config at ${input.path}: ${message}`);
124 }
125};
126
127const mergeSourceMaps = (
128 base: Record<string, LocalConfigSource> | undefined,
129 extra: Record<string, LocalConfigSource> | undefined,
130): Record<string, LocalConfigSource> | undefined => {
131 if (!base && !extra) {
132 return undefined;
133 }
134 return {
135 ...(base ?? {}),
136 ...(extra ?? {}),
137 };
138};
139
140const mergePolicyMaps = (
141 base: Record<string, LocalConfigPolicy> | undefined,
142 extra: Record<string, LocalConfigPolicy> | undefined,
143): Record<string, LocalConfigPolicy> | undefined => {
144 if (!base && !extra) {
145 return undefined;
146 }
147 return {
148 ...(base ?? {}),
149 ...(extra ?? {}),
150 };
151};
152
153const mergeSecretProviderMaps = (
154 base: Record<string, LocalConfigSecretProvider> | undefined,
155 extra: Record<string, LocalConfigSecretProvider> | undefined,
156): Record<string, LocalConfigSecretProvider> | undefined => {
157 if (!base && !extra) {
158 return undefined;
159 }
160 return {
161 ...(base ?? {}),
162 ...(extra ?? {}),
163 };
164};
165
166export const mergeLocalExecutorConfigs = (
167 base: LocalExecutorConfig | null,
168 extra: LocalExecutorConfig | null,
169): LocalExecutorConfig | null => {
170 if (!base && !extra) {
171 return null;
172 }
173
174 return decodeLocalExecutorConfig({
175 workspace: {
176 ...(base?.workspace ?? {}),
177 ...(extra?.workspace ?? {}),
178 },
179 sources: mergeSourceMaps(base?.sources, extra?.sources),
180 policies: mergePolicyMaps(base?.policies, extra?.policies),
181 secrets: {
182 providers: mergeSecretProviderMaps(
183 base?.secrets?.providers,
184 extra?.secrets?.providers,
185 ),
186 defaults: {
187 ...(base?.secrets?.defaults ?? {}),
188 ...(extra?.secrets?.defaults ?? {}),
189 },
190 },
191 });
192};
193
194const resolveProjectConfigPath = async (workspaceRoot: string): Promise<string> => {
195 const jsoncPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME);
196 if (await fileExists(jsoncPath)) {
197 return jsoncPath;
198 }
199
200 const jsonPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_FALLBACK_BASENAME);
201 if (await fileExists(jsonPath)) {
202 return jsonPath;
203 }
204
205 return jsoncPath;
206};
207
208const hasProjectConfig = async (workspaceRoot: string): Promise<boolean> =>
209 fileExists(join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME))
210 || fileExists(join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_FALLBACK_BASENAME));
211
212const resolveWorkspaceRootFromCwd = async (cwd: string): Promise<string> => {
213 let current = resolve(cwd);
214 let nearestProjectConfigRoot: string | null = null;
215 let nearestGitRoot: string | null = null;
216
217 while (true) {
218 if (nearestProjectConfigRoot === null && (await hasProjectConfig(current))) {
219 nearestProjectConfigRoot = current;
220 }
221
222 if (nearestGitRoot === null && (await pathExists(join(current, ".git")))) {
223 nearestGitRoot = current;
224 }
225
226 const parent = dirname(current);
227 if (parent === current) {
228 break;
229 }
230 current = parent;
231 }
232
233 return nearestProjectConfigRoot ?? nearestGitRoot ?? resolve(cwd);
234};
235
236export type ResolvedLocalWorkspaceContext = {
237 cwd: string;
238 workspaceRoot: string;
239 workspaceName: string;
240 configDirectory: string;
241 projectConfigPath: string;
242 homeConfigPath: string;
243 artifactsDirectory: string;
244 stateDirectory: string;
245 installationId: string;
246};
247
248export type LoadedLocalExecutorConfig = {
249 config: LocalExecutorConfig | null;
250 homeConfig: LocalExecutorConfig | null;
251 projectConfig: LocalExecutorConfig | null;
252 homeConfigPath: string;
253 projectConfigPath: string;
254};
255
256export const resolveLocalWorkspaceContext = async (input: {
257 cwd?: string;
258 workspaceRoot?: string;
259} = {}): Promise<ResolvedLocalWorkspaceContext> => {
260 const cwd = resolve(input.cwd ?? process.cwd());
261 const workspaceRoot = resolve(
262 input.workspaceRoot ?? (await resolveWorkspaceRootFromCwd(cwd)),
263 );
264 const workspaceName = basename(workspaceRoot) || "workspace";
265 const projectConfigPath = await resolveProjectConfigPath(workspaceRoot);
266
267 return {
268 cwd,
269 workspaceRoot,
270 workspaceName,
271 configDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY),
272 projectConfigPath,
273 homeConfigPath: HOME_CONFIG_PATH,
274 artifactsDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, "artifacts"),
275 stateDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, "state"),
276 installationId: `local_${stableHash(normalizeSlashPath(workspaceRoot))}`,
277 };
278};
279
280export const readOptionalLocalExecutorConfig = async (
281 path: string,
282): Promise<LocalExecutorConfig | null> => {
283 if (!(await fileExists(path))) {
284 return null;
285 }
286
287 const content = await fs.readFile(path, "utf8");
288 return parseJsonc({ path, content });
289};
290
291export const loadLocalExecutorConfig = async (
292 context: ResolvedLocalWorkspaceContext,
293): Promise<LoadedLocalExecutorConfig> => {
294 const [homeConfig, projectConfig] = await Promise.all([
295 readOptionalLocalExecutorConfig(context.homeConfigPath),
296 readOptionalLocalExecutorConfig(context.projectConfigPath),
297 ]);
298
299 return {
300 config: mergeLocalExecutorConfigs(homeConfig, projectConfig),
301 homeConfig,
302 projectConfig,
303 homeConfigPath: context.homeConfigPath,
304 projectConfigPath: context.projectConfigPath,
305 };
306};
307
308export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>
309 `${JSON.stringify(config, null, 2)}\n`;
310
311export const writeProjectLocalExecutorConfig = async (input: {
312 context: ResolvedLocalWorkspaceContext;
313 config: LocalExecutorConfig;
314}): Promise<void> => {
315 await fs.mkdir(input.context.configDirectory, { recursive: true });
316 await fs.writeFile(
317 input.context.projectConfigPath,
318 encodeLocalExecutorConfig(input.config),
319 "utf8",
320 );
321};
322
323export const resolveConfigRelativePath = (input: {
324 path: string;
325 workspaceRoot: string;
326}): string => {
327 const trimmed = input.path.trim();
328 if (trimmed.startsWith("~/")) {
329 return join(homedir(), trimmed.slice(2));
330 }
331 if (trimmed === "~") {
332 return homedir();
333 }
334 if (isAbsolute(trimmed)) {
335 return trimmed;
336 }
337 return resolve(input.workspaceRoot, trimmed);
338};
339
340export const defaultWorkspaceDisplayName = (context: ResolvedLocalWorkspaceContext): string =>
341 trimOrUndefined(context.workspaceName) ?? "workspace";
packages/control-plane/src/runtime/local-installation.test.ts+17−2
@@ -1,12 +1,21 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
1import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";5import { assertTrue } from "@effect/vitest/utils";
3import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
4 7
5import { createSqlControlPlaneRuntime } from "./index";8import { createSqlControlPlaneRuntime } from "./index";
6import { getOrProvisionLocalInstallation } from "./local-installation";9import { getOrProvisionLocalInstallation } from "./local-installation";
10import { resolveLocalWorkspaceContext } from "./local-config";
11
12const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));
7 13
8const makeRuntime = Effect.acquireRelease(14const makeRuntime = Effect.acquireRelease(
9 createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),15 createSqlControlPlaneRuntime({
16 localDataDir: ":memory:",
17 workspaceRoot: TEST_WORKSPACE_ROOT,
18 }),
10 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),19 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
11);20);
12 21
@@ -33,9 +42,15 @@ describe("local-installation", () => { · 20 unmodified lines
33 it.scoped("is idempotent when loading the default local installation", () =>42 it.scoped("is idempotent when loading the default local installation", () =>
34 Effect.gen(function* () {43 Effect.gen(function* () {
35 const runtime = yield* makeRuntime;44 const runtime = yield* makeRuntime;
45 const context = yield* Effect.promise(() =>
46 resolveLocalWorkspaceContext({ workspaceRoot: TEST_WORKSPACE_ROOT }),
47 );
36 48
37 const first = runtime.localInstallation;49 const first = runtime.localInstallation;
38 const second = yield* getOrProvisionLocalInstallation(runtime.persistence.rows);50 const second = yield* getOrProvisionLocalInstallation({
51 rows: runtime.persistence.rows,
52 context,
53 });
39 54
40 expect(second.id).toBe(first.id);55 expect(second.id).toBe(first.id);
41 expect(second.accountId).toBe(first.accountId);56 expect(second.accountId).toBe(first.accountId);
packages/control-plane/src/runtime/local-installation.ts+192−28
@@ -14,7 +14,22 @@ import { · 13 unmodified lines
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";15import * as Option from "effect/Option";
16 16
17const LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");17import {
18 defaultWorkspaceDisplayName,
19 type ResolvedLocalWorkspaceContext,
20} from "./local-config";
21
22const LEGACY_LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");
23const LOCAL_ACCOUNT_SUBJECT = "local:default";
24const PERSONAL_ORGANIZATION_SLUG = "personal";
25
26const trimOrNull = (value: string | null | undefined): string | null => {
27 if (value == null) {
28 return null;
29 }
30 const trimmed = value.trim();
31 return trimmed.length > 0 ? trimmed : null;
32};
18 33
19const buildAccount = (now: number): Account => {34const buildAccount = (now: number): Account => {
20 const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`);35 const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`);
@@ -22,7 +37,7 @@ const buildAccount = (now: number): Account => { · 1 unmodified line
22 return {37 return {
23 id,38 id,
24 provider: "local",39 provider: "local",
25 subject: `local:${id}`,40 subject: LOCAL_ACCOUNT_SUBJECT,
26 email: null,41 email: null,
27 displayName: "Local User",42 displayName: "Local User",
28 createdAt: now,43 createdAt: now,
@@ -35,7 +50,7 @@ const buildOrganization = ( · 6 unmodified lines
35 now: number,50 now: number,
36): Organization => ({51): Organization => ({
37 id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),52 id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
38 slug: "personal",53 slug: PERSONAL_ORGANIZATION_SLUG,
39 name: "Personal",54 name: "Personal",
40 status: "active",55 status: "active",
41 createdByAccountId: accountId,56 createdByAccountId: accountId,
@@ -63,57 +78,206 @@ const buildOwnerMembership = ( · 21 unmodified lines
63const buildWorkspace = (78const buildWorkspace = (
64 organizationId: Organization["id"],79 organizationId: Organization["id"],
65 accountId: Account["id"],80 accountId: Account["id"],
81 workspaceName: string,
66 now: number,82 now: number,
67): Workspace => ({83): Workspace => ({
68 id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),84 id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
69 organizationId,85 organizationId,
70 name: "Default",86 name: workspaceName,
71 createdByAccountId: accountId,87 createdByAccountId: accountId,
72 createdAt: now,88 createdAt: now,
73 updatedAt: now,89 updatedAt: now,
74});90});
75 91
92const chooseWorkspaceName = (
93 desiredName: string,
94 existing: ReadonlyArray<Workspace>,
95): string => {
96 const trimmed = trimOrNull(desiredName) ?? "Workspace";
97 const matching = existing.find((workspace) => workspace.name === trimmed);
98 if (!matching) {
99 return trimmed;
100 }
101 return `${trimmed}-${matching.id.slice(-6)}`;
102};
103
104const ensureLocalAccount = (
105 rows: SqlControlPlaneRows,
106 now: number,
107): Effect.Effect<Account, unknown> =>
108 Effect.gen(function* () {
109 const existing = yield* rows.accounts.getByProviderAndSubject("local", LOCAL_ACCOUNT_SUBJECT);
110 if (Option.isSome(existing)) {
111 return existing.value;
112 }
113
114 const account = buildAccount(now);
115 yield* rows.accounts.upsert(account);
116 return account;
117 });
118
119const ensurePersonalOrganization = (
120 rows: SqlControlPlaneRows,
121 accountId: Account["id"],
122 now: number,
123): Effect.Effect<Organization, unknown> =>
124 Effect.gen(function* () {
125 const existing = yield* rows.organizations.getBySlug(PERSONAL_ORGANIZATION_SLUG);
126 if (Option.isSome(existing)) {
127 return existing.value;
128 }
129
130 const organization = buildOrganization(accountId, now);
131 const membership = buildOwnerMembership(organization.id, accountId, now);
132 yield* rows.organizations.insertWithOwnerMembership(organization, membership);
133 return organization;
134 });
135
136const ensureOwnerMembership = (
137 rows: SqlControlPlaneRows,
138 organizationId: Organization["id"],
139 accountId: Account["id"],
140 now: number,
141): Effect.Effect<void, unknown> =>
142 Effect.gen(function* () {
143 const existing = yield* rows.organizationMemberships.getByOrganizationAndAccount(
144 organizationId,
145 accountId,
146 );
147 if (Option.isSome(existing)) {
148 return;
149 }
150
151 yield* rows.organizationMemberships.upsert(
152 buildOwnerMembership(organizationId, accountId, now),
153 );
154 });
155
156const provisionWorkspaceForContext = (input: {
157 rows: SqlControlPlaneRows;
158 accountId: Account["id"];
159 organizationId: Organization["id"];
160 context: ResolvedLocalWorkspaceContext;
161 now: number;
162}): Effect.Effect<Workspace, unknown> =>
163 Effect.gen(function* () {
164 const workspaces = yield* input.rows.workspaces.listByOrganizationId(input.organizationId);
165 const workspaceName = chooseWorkspaceName(
166 defaultWorkspaceDisplayName(input.context),
167 workspaces,
168 );
169 const workspace = buildWorkspace(
170 input.organizationId,
171 input.accountId,
172 workspaceName,
173 input.now,
174 );
175 yield* input.rows.workspaces.insert(workspace);
176 return workspace;
177 });
178
179const createInstallationForWorkspace = (input: {
180 installationId: LocalInstallation["id"];
181 accountId: Account["id"];
182 organizationId: Organization["id"];
183 workspaceId: Workspace["id"];
184 now: number;
185}): LocalInstallation => ({
186 id: input.installationId,
187 accountId: input.accountId,
188 organizationId: input.organizationId,
189 workspaceId: input.workspaceId,
190 createdAt: input.now,
191 updatedAt: input.now,
192});
193
76export const loadLocalInstallation = (194export const loadLocalInstallation = (
77 rows: SqlControlPlaneRows,195 rows: SqlControlPlaneRows,
196 context: ResolvedLocalWorkspaceContext,
78): Effect.Effect<LocalInstallation | null, unknown> =>197): Effect.Effect<LocalInstallation | null, unknown> =>
79 rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe(198 rows.localInstallations.getById(InstallationIdSchema.make(context.installationId)).pipe(
80 Effect.map((result) => (Option.isSome(result) ? result.value : null)),199 Effect.map((result) => (Option.isSome(result) ? result.value : null)),
81 );200 );
82 201
83export const provisionLocalInstallation = (202const migrateLegacyLocalInstallation = (input: {
84 rows: SqlControlPlaneRows,203 rows: SqlControlPlaneRows;
85): Effect.Effect<LocalInstallation, unknown> =>204 context: ResolvedLocalWorkspaceContext;
205 now: number;
206}): Effect.Effect<LocalInstallation | null, unknown> =>
86 Effect.gen(function* () {207 Effect.gen(function* () {
87 const existing = yield* rows.localInstallations.getById(LOCAL_INSTALLATION_ID);208 const legacy = yield* input.rows.localInstallations.getById(LEGACY_LOCAL_INSTALLATION_ID);
209 if (Option.isNone(legacy)) {
210 return null;
211 }
212
213 const migrated = createInstallationForWorkspace({
214 installationId: InstallationIdSchema.make(input.context.installationId),
215 accountId: legacy.value.accountId,
216 organizationId: legacy.value.organizationId,
217 workspaceId: legacy.value.workspaceId,
218 now: input.now,
219 });
220 yield* input.rows.localInstallations.upsert(migrated);
221
222 yield* input.rows.workspaces.update(legacy.value.workspaceId, {
223 name: defaultWorkspaceDisplayName(input.context),
224 updatedAt: input.now,
225 });
226
227 return migrated;
228 });
229
230export const provisionLocalInstallation = (input: {
231 rows: SqlControlPlaneRows;
232 context: ResolvedLocalWorkspaceContext;
233}): Effect.Effect<LocalInstallation, unknown> =>
234 Effect.gen(function* () {
235 const now = Date.now();
236 const installationId = InstallationIdSchema.make(input.context.installationId);
237
238 const existing = yield* input.rows.localInstallations.getById(installationId);
88 if (Option.isSome(existing)) {239 if (Option.isSome(existing)) {
89 return existing.value;240 return existing.value;
90 }241 }
91 242
92 const now = Date.now();243 const migrated = yield* migrateLegacyLocalInstallation({
93 const account = buildAccount(now);244 rows: input.rows,
94 const organization = buildOrganization(account.id, now);245 context: input.context,
95 const membership = buildOwnerMembership(organization.id, account.id, now);246 now,
96 const workspace = buildWorkspace(organization.id, account.id, now);247 });
97 const installation: LocalInstallation = {248 if (migrated !== null) {
98 id: LOCAL_INSTALLATION_ID,249 return migrated;
250 }
251
252 const account = yield* ensureLocalAccount(input.rows, now);
253 const organization = yield* ensurePersonalOrganization(input.rows, account.id, now);
254 yield* ensureOwnerMembership(input.rows, organization.id, account.id, now);
255 const workspace = yield* provisionWorkspaceForContext({
256 rows: input.rows,
257 accountId: account.id,
258 organizationId: organization.id,
259 context: input.context,
260 now,
261 });
262 const installation = createInstallationForWorkspace({
263 installationId,
99 accountId: account.id,264 accountId: account.id,
100 organizationId: organization.id,265 organizationId: organization.id,
101 workspaceId: workspace.id,266 workspaceId: workspace.id,
102 createdAt: now,267 now,
103 updatedAt: now,268 });
104 };
105 269
106 yield* rows.accounts.upsert(account);270 yield* input.rows.localInstallations.upsert(installation);
107 yield* rows.organizations.insertWithOwnerMembership(organization, membership);
108 yield* rows.workspaces.insert(workspace);
109 yield* rows.localInstallations.upsert(installation);
110 271
111 return installation;272 return installation;
112 });273 });
113 274
114export const getOrProvisionLocalInstallation = (275export const getOrProvisionLocalInstallation = (input: {
115 rows: SqlControlPlaneRows,276 rows: SqlControlPlaneRows;
116): Effect.Effect<LocalInstallation, unknown> =>277 context: ResolvedLocalWorkspaceContext;
117 Effect.flatMap(loadLocalInstallation(rows), (existing) =>278}): Effect.Effect<LocalInstallation, unknown> =>
118 existing ? Effect.succeed(existing) : provisionLocalInstallation(rows)279 Effect.flatMap(loadLocalInstallation(input.rows, input.context), (existing) =>
280 existing
281 ? Effect.succeed(existing)
282 : provisionLocalInstallation(input),
119 );283 );
packages/control-plane/src/runtime/local-operations.ts+6−1
@@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines
17import {17import {
18 operationErrors,18 operationErrors,
19} from "./operation-errors";19} from "./operation-errors";
20import { resolveLocalWorkspaceContext } from "./local-config";
21import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context";
20import { RuntimeSourceAuthServiceTag } from "./source-auth-service";22import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
21import {23import {
22 createSourceCredentialSelectionBearerContent,24 createSourceCredentialSelectionBearerContent,
@@ -181,8 +183,11 @@ const loadSourceCredentialInteraction = (input: { · 158 unmodified lines
181export const getLocalInstallation = () =>183export const getLocalInstallation = () =>
182 Effect.gen(function* () {184 Effect.gen(function* () {
183 const store = yield* ControlPlaneStore;185 const store = yield* ControlPlaneStore;
186 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
187 const context = runtimeLocalWorkspace?.context
188 ?? (yield* Effect.promise(() => resolveLocalWorkspaceContext()));
184 189
185 const installation = yield* loadLocalInstallation(store).pipe(190 const installation = yield* loadLocalInstallation(store, context).pipe(
186 Effect.mapError((error) =>191 Effect.mapError((error) =>
187 error instanceof ControlPlanePersistenceError192 error instanceof ControlPlanePersistenceError
188 ? localOps.installation.storage(error)193 ? localOps.installation.storage(error)
packages/control-plane/src/runtime/local-runtime-context.ts+29−0
@@ -0,0 +1,29 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type { AccountId, WorkspaceId } from "#schema";
6import type {
7 LoadedLocalExecutorConfig,
8 ResolvedLocalWorkspaceContext,
9} from "./local-config";
10
11export type RuntimeLocalWorkspaceState = {
12 context: ResolvedLocalWorkspaceContext;
13 installation: {
14 workspaceId: WorkspaceId;
15 accountId: AccountId;
16 };
17 loadedConfig: LoadedLocalExecutorConfig;
18};
19
20export class RuntimeLocalWorkspaceService extends Context.Tag(
21 "#runtime/RuntimeLocalWorkspaceService",
22)<RuntimeLocalWorkspaceService, RuntimeLocalWorkspaceState>() {}
23
24export const getRuntimeLocalWorkspaceOption = () =>
25 Effect.contextWith((context) =>
26 Context.getOption(context, RuntimeLocalWorkspaceService),
27 ).pipe(
28 Effect.map((option) => (Option.isSome(option) ? option.value : null)),
29 ) as Effect.Effect<RuntimeLocalWorkspaceState | null, never, never>;
packages/control-plane/src/runtime/local-source-artifacts.ts+198−0
@@ -0,0 +1,198 @@
1import { promises as fs } from "node:fs";
2import { join } from "node:path";
3
4import {
5 SourceIdSchema,
6 SourceRecipeIdSchema,
7 StoredSourceRecipeDocumentRecordSchema,
8 StoredSourceRecipeOperationRecordSchema,
9 StoredSourceRecipeRevisionRecordSchema,
10 StoredSourceRecipeSchemaBundleRecordSchema,
11 TimestampMsSchema,
12 type Source,
13 type SourceRecipeId,
14} from "#schema";
15import * as Schema from "effect/Schema";
16
17import type { ResolvedLocalWorkspaceContext } from "./local-config";
18import {
19 createSourceRecipeRevisionRecord,
20 stableSourceRecipeId,
21} from "./source-definitions";
22import {
23 contentHash,
24 type SourceRecipeMaterialization,
25} from "./source-recipe-support";
26
27const LOCAL_SOURCE_ARTIFACT_VERSION = 1 as const;
28
29export const LocalSourceArtifactSchema = Schema.Struct({
30 version: Schema.Literal(LOCAL_SOURCE_ARTIFACT_VERSION),
31 sourceId: SourceIdSchema,
32 configKey: Schema.String,
33 recipeId: SourceRecipeIdSchema,
34 generatedAt: TimestampMsSchema,
35 revision: StoredSourceRecipeRevisionRecordSchema,
36 documents: Schema.Array(StoredSourceRecipeDocumentRecordSchema),
37 schemaBundles: Schema.Array(StoredSourceRecipeSchemaBundleRecordSchema),
38 operations: Schema.Array(StoredSourceRecipeOperationRecordSchema),
39});
40
41export type LocalSourceArtifact = typeof LocalSourceArtifactSchema.Type;
42
43const decodeLocalSourceArtifact = Schema.decodeUnknownSync(LocalSourceArtifactSchema);
44
45const localSourceArtifactPath = (input: {
46 context: ResolvedLocalWorkspaceContext;
47 configKey: string;
48}): string =>
49 join(
50 input.context.artifactsDirectory,
51 "sources",
52 `${input.configKey}.json`,
53 );
54
55const canonicalMaterializationHash = (input: {
56 materialization: SourceRecipeMaterialization;
57}): string => {
58 const documents = [...input.materialization.documents]
59 .map((document) => ({
60 documentKind: document.documentKind,
61 documentKey: document.documentKey,
62 contentHash: document.contentHash,
63 }))
64 .sort((left, right) =>
65 left.documentKind.localeCompare(right.documentKind)
66 || left.documentKey.localeCompare(right.documentKey)
67 || left.contentHash.localeCompare(right.contentHash)
68 );
69 const schemaBundles = [...input.materialization.schemaBundles]
70 .map((bundle) => ({
71 bundleKind: bundle.bundleKind,
72 contentHash: bundle.contentHash,
73 }))
74 .sort((left, right) =>
75 left.bundleKind.localeCompare(right.bundleKind)
76 || left.contentHash.localeCompare(right.contentHash)
77 );
78 const operations = [...input.materialization.operations]
79 .map((operation) => ({
80 operationKey: operation.operationKey,
81 transportKind: operation.transportKind,
82 toolId: operation.toolId,
83 title: operation.title,
84 description: operation.description,
85 operationKind: operation.operationKind,
86 searchText: operation.searchText,
87 inputSchemaJson: operation.inputSchemaJson,
88 outputSchemaJson: operation.outputSchemaJson,
89 providerKind: operation.providerKind,
90 providerDataJson: operation.providerDataJson,
91 }))
92 .sort((left, right) => left.operationKey.localeCompare(right.operationKey));
93
94 return contentHash(JSON.stringify({
95 schemaVersion: 1,
96 manifestHash: input.materialization.manifestHash,
97 manifestJson: input.materialization.manifestJson,
98 documents,
99 schemaBundles,
100 operations,
101 }));
102};
103
104const bindRevisionId = <T extends { recipeRevisionId: string }>(
105 items: readonly T[],
106 recipeRevisionId: string,
107): T[] =>
108 items.map((item) => ({
109 ...item,
110 recipeRevisionId,
111 }));
112
113export const buildLocalSourceArtifact = (input: {
114 source: Source;
115 configKey: string;
116 materialization: SourceRecipeMaterialization;
117}): LocalSourceArtifact => {
118 const recipeId: SourceRecipeId = stableSourceRecipeId(input.source);
119 const now = Date.now();
120 const revision = createSourceRecipeRevisionRecord({
121 source: input.source,
122 recipeId,
123 revisionNumber: 1,
124 manifestJson: input.materialization.manifestJson,
125 manifestHash: input.materialization.manifestHash,
126 materializationHash: canonicalMaterializationHash({
127 materialization: input.materialization,
128 }),
129 });
130
131 return {
132 version: LOCAL_SOURCE_ARTIFACT_VERSION,
133 sourceId: input.source.id,
134 configKey: input.configKey,
135 recipeId,
136 generatedAt: now,
137 revision,
138 documents: bindRevisionId(input.materialization.documents, revision.id),
139 schemaBundles: bindRevisionId(input.materialization.schemaBundles, revision.id),
140 operations: bindRevisionId(input.materialization.operations, revision.id),
141 };
142};
143
144export const readLocalSourceArtifact = async (input: {
145 context: ResolvedLocalWorkspaceContext;
146 configKey: string;
147}): Promise<LocalSourceArtifact | null> => {
148 const path = localSourceArtifactPath(input);
149
150 try {
151 const content = await fs.readFile(path, "utf8");
152 return decodeLocalSourceArtifact(JSON.parse(content) as unknown);
153 } catch (cause) {
154 if (
155 cause instanceof Error
156 && ("code" in cause)
157 && (cause as NodeJS.ErrnoException).code === "ENOENT"
158 ) {
159 return null;
160 }
161
162 const message = cause instanceof Error ? cause.message : String(cause);
163 throw new Error(`Invalid local source artifact at ${path}: ${message}`);
164 }
165};
166
167export const writeLocalSourceArtifact = async (input: {
168 context: ResolvedLocalWorkspaceContext;
169 configKey: string;
170 artifact: LocalSourceArtifact;
171}): Promise<void> => {
172 const path = localSourceArtifactPath(input);
173 await fs.mkdir(join(input.context.artifactsDirectory, "sources"), {
174 recursive: true,
175 });
176 await fs.writeFile(path, `${JSON.stringify(input.artifact, null, 2)}\n`, "utf8");
177};
178
179export const removeLocalSourceArtifact = async (input: {
180 context: ResolvedLocalWorkspaceContext;
181 configKey: string;
182}): Promise<void> => {
183 const path = localSourceArtifactPath(input);
184
185 try {
186 await fs.unlink(path);
187 } catch (cause) {
188 if (
189 cause instanceof Error
190 && ("code" in cause)
191 && (cause as NodeJS.ErrnoException).code === "ENOENT"
192 ) {
193 return;
194 }
195
196 throw cause;
197 }
198};
packages/control-plane/src/runtime/local-workspace-state.ts+91−0
@@ -0,0 +1,91 @@
1import { promises as fs } from "node:fs";
2import { join } from "node:path";
3
4import {
5 PolicyIdSchema,
6 SourceIdSchema,
7 SourceStatusSchema,
8 TimestampMsSchema,
9} from "#schema";
10import * as Schema from "effect/Schema";
11
12import type { ResolvedLocalWorkspaceContext } from "./local-config";
13
14const WORKSPACE_STATE_BASENAME = "workspace-state.json";
15
16const LocalWorkspaceSourceStateSchema = Schema.Struct({
17 id: SourceIdSchema,
18 status: SourceStatusSchema,
19 lastError: Schema.NullOr(Schema.String),
20 sourceHash: Schema.NullOr(Schema.String),
21 createdAt: TimestampMsSchema,
22 updatedAt: TimestampMsSchema,
23});
24
25const LocalWorkspacePolicyStateSchema = Schema.Struct({
26 id: PolicyIdSchema,
27 createdAt: TimestampMsSchema,
28 updatedAt: TimestampMsSchema,
29});
30
31export const LocalWorkspaceStateSchema = Schema.Struct({
32 version: Schema.Literal(1),
33 sources: Schema.Record({
34 key: Schema.String,
35 value: LocalWorkspaceSourceStateSchema,
36 }),
37 policies: Schema.Record({
38 key: Schema.String,
39 value: LocalWorkspacePolicyStateSchema,
40 }),
41});
42
43export type LocalWorkspaceSourceState = typeof LocalWorkspaceSourceStateSchema.Type;
44export type LocalWorkspacePolicyState = typeof LocalWorkspacePolicyStateSchema.Type;
45export type LocalWorkspaceState = typeof LocalWorkspaceStateSchema.Type;
46
47const decodeLocalWorkspaceState = Schema.decodeUnknownSync(LocalWorkspaceStateSchema);
48
49const defaultLocalWorkspaceState = (): LocalWorkspaceState => ({
50 version: 1,
51 sources: {},
52 policies: {},
53});
54
55export const localWorkspaceStatePath = (
56 context: ResolvedLocalWorkspaceContext,
57): string => join(context.stateDirectory, WORKSPACE_STATE_BASENAME);
58
59export const loadLocalWorkspaceState = async (
60 context: ResolvedLocalWorkspaceContext,
61): Promise<LocalWorkspaceState> => {
62 const path = localWorkspaceStatePath(context);
63
64 try {
65 const content = await fs.readFile(path, "utf8");
66 return decodeLocalWorkspaceState(JSON.parse(content) as unknown);
67 } catch (cause) {
68 if (
69 cause instanceof Error
70 && ("code" in cause)
71 && (cause as NodeJS.ErrnoException).code === "ENOENT"
72 ) {
73 return defaultLocalWorkspaceState();
74 }
75
76 const message = cause instanceof Error ? cause.message : String(cause);
77 throw new Error(`Invalid local workspace state at ${path}: ${message}`);
78 }
79};
80
81export const writeLocalWorkspaceState = async (input: {
82 context: ResolvedLocalWorkspaceContext;
83 state: LocalWorkspaceState;
84}): Promise<void> => {
85 await fs.mkdir(input.context.stateDirectory, { recursive: true });
86 await fs.writeFile(
87 localWorkspaceStatePath(input.context),
88 `${JSON.stringify(input.state, null, 2)}\n`,
89 "utf8",
90 );
91};
packages/control-plane/src/runtime/local-workspace-sync.ts+400−0
@@ -0,0 +1,400 @@
1import type { SqlControlPlaneRows } from "#persistence";
2import {
3 type AccountId,
4 type LocalConfigPolicy,
5 type LocalConfigSource,
6 type LocalExecutorConfig,
7 type Policy,
8 type Source,
9 type Workspace,
10} from "#schema";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13
14import {
15 defaultWorkspaceDisplayName,
16 type LoadedLocalExecutorConfig,
17 mergeLocalExecutorConfigs,
18 type ResolvedLocalWorkspaceContext,
19 writeProjectLocalExecutorConfig,
20} from "./local-config";
21import {
22 buildLocalSourceArtifact,
23 writeLocalSourceArtifact,
24} from "./local-source-artifacts";
25import {
26 loadLocalWorkspaceState,
27 writeLocalWorkspaceState,
28 type LocalWorkspaceState,
29} from "./local-workspace-state";
30import { slugify } from "./slug";
31import { loadSourcesInWorkspace } from "./source-store";
32
33const trimOrNull = (value: string | null | undefined): string | null => {
34 if (value == null) {
35 return null;
36 }
37 const trimmed = value.trim();
38 return trimmed.length > 0 ? trimmed : null;
39};
40
41const toApprovalMode = (approval: LocalConfigPolicy["approval"]): Policy["approvalMode"] =>
42 approval === "manual" ? "required" : "auto";
43
44export const deriveSourceConfigKey = (
45 source: Pick<Source, "configKey" | "namespace" | "name">,
46 used: Set<string>,
47): string => {
48 const base =
49 trimOrNull(source.configKey)
50 ?? trimOrNull(source.namespace)
51 ?? trimOrNull(source.name)
52 ?? "source";
53 const slugBase = slugify(base) || "source";
54 let candidate = slugBase;
55 let counter = 2;
56 while (used.has(candidate)) {
57 candidate = `${slugBase}-${counter}`;
58 counter += 1;
59 }
60 used.add(candidate);
61 return candidate;
62};
63
64export const derivePolicyConfigKey = (
65 policy: Pick<Policy, "configKey" | "resourcePattern" | "effect" | "approvalMode">,
66 used: Set<string>,
67): string => {
68 const base =
69 trimOrNull(policy.configKey)
70 ?? trimOrNull(policy.resourcePattern)
71 ?? `${policy.effect}-${policy.approvalMode}`;
72 const slugBase = slugify(base) || "policy";
73 let candidate = slugBase;
74 let counter = 2;
75 while (used.has(candidate)) {
76 candidate = `${slugBase}-${counter}`;
77 counter += 1;
78 }
79 used.add(candidate);
80 return candidate;
81};
82
83const configAuthFromStoredSource = (input: {
84 source: Source;
85 config: LocalExecutorConfig | null;
86}) => {
87 const auth = input.source.auth;
88 if (auth.kind !== "bearer") {
89 return undefined;
90 }
91
92 if (auth.token.providerId === "env") {
93 return auth.token.handle;
94 }
95
96 const providers = input.config?.secrets?.providers ?? {};
97 const providerEntry = Object.entries(providers).find(
98 ([providerAlias]) =>
99 `config:${providerAlias}` === auth.token.providerId,
100 );
101 if (!providerEntry) {
102 return undefined;
103 }
104
105 const [provider, definition] = providerEntry;
106 return {
107 source: definition.source,
108 provider,
109 id: auth.token.handle,
110 } as const;
111};
112
113export const configSourceFromStoredSource = (input: {
114 source: Source;
115 config: LocalExecutorConfig | null;
116}): LocalConfigSource => {
117 const auth = configAuthFromStoredSource(input);
118 const common = {
119 ...(trimOrNull(input.source.name) !== trimOrNull(input.source.configKey)
120 ? { name: input.source.name }
121 : {}),
122 ...(trimOrNull(input.source.namespace) !== trimOrNull(input.source.configKey)
123 ? { namespace: input.source.namespace ?? undefined }
124 : {}),
125 ...(input.source.enabled === false ? { enabled: false } : {}),
126 connection: {
127 endpoint: input.source.endpoint,
128 ...(auth !== undefined ? { auth } : {}),
129 },
130 };
131
132 switch (input.source.kind) {
133 case "openapi":
134 return {
135 kind: "openapi",
136 ...common,
137 binding: input.source.binding as Extract<LocalConfigSource, { kind: "openapi" }>["binding"],
138 };
139 case "graphql":
140 return {
141 kind: "graphql",
142 ...common,
143 binding: input.source.binding as Extract<LocalConfigSource, { kind: "graphql" }>["binding"],
144 };
145 case "google_discovery":
146 return {
147 kind: "google_discovery",
148 ...common,
149 binding: input.source.binding as Extract<LocalConfigSource, { kind: "google_discovery" }>["binding"],
150 };
151 case "mcp":
152 return {
153 kind: "mcp",
154 ...common,
155 binding: input.source.binding as Extract<LocalConfigSource, { kind: "mcp" }>["binding"],
156 };
157 default:
158 throw new Error(`Unsupported source kind for config export: ${input.source.kind}`);
159 }
160};
161
162export const configPolicyFromStoredPolicy = (policy: Policy): LocalConfigPolicy => ({
163 match: policy.resourcePattern,
164 action: policy.effect,
165 approval: policy.approvalMode === "required" ? "manual" : "auto",
166 ...(policy.enabled === false ? { enabled: false } : {}),
167 ...(policy.priority !== 0 ? { priority: policy.priority } : {}),
168});
169
170const ensureWorkspaceMetadata = (input: {
171 rows: SqlControlPlaneRows;
172 installation: {
173 workspaceId: Workspace["id"];
174 };
175 context: ResolvedLocalWorkspaceContext;
176 config: LocalExecutorConfig | null;
177}): Effect.Effect<void, Error, never> =>
178 Effect.gen(function* () {
179 const workspace = yield* input.rows.workspaces.getById(input.installation.workspaceId);
180 if (Option.isNone(workspace)) {
181 return;
182 }
183
184 const desiredName =
185 trimOrNull(input.config?.workspace?.name)
186 ?? defaultWorkspaceDisplayName(input.context);
187 if (workspace.value.name === desiredName) {
188 return;
189 }
190
191 yield* input.rows.workspaces.update(workspace.value.id, {
192 name: desiredName,
193 updatedAt: Date.now(),
194 });
195 });
196
197const exportWorkspaceConfig = (input: {
198 rows: SqlControlPlaneRows;
199 context: ResolvedLocalWorkspaceContext;
200 loadedConfig: LoadedLocalExecutorConfig;
201 installation: {
202 workspaceId: Workspace["id"];
203 accountId: AccountId;
204 };
205}): Effect.Effect<LocalExecutorConfig | null, Error, never> =>
206 Effect.gen(function* () {
207 if (input.loadedConfig.projectConfig !== null) {
208 return input.loadedConfig.projectConfig;
209 }
210
211 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.installation.workspaceId);
212 const sources = yield* loadSourcesInWorkspace(
213 input.rows,
214 input.installation.workspaceId,
215 { actorAccountId: input.installation.accountId },
216 );
217 const policies = yield* input.rows.policies.listByWorkspaceId(input.installation.workspaceId);
218
219 if (sources.length === 0 && policies.length === 0) {
220 return null;
221 }
222
223 const sourceKeys = new Set<string>();
224 const policyKeys = new Set<string>();
225 const sourceMappings: Array<{ configKey: string; source: Source }> = [];
226 const policyMappings: Array<{ configKey: string; policy: Policy }> = [];
227 const sourcesConfig: Record<string, LocalConfigSource> = {};
228 const policiesConfig: Record<string, LocalConfigPolicy> = {};
229
230 for (const source of sources) {
231 const configKey = deriveSourceConfigKey(source, sourceKeys);
232 sourceMappings.push({ configKey, source });
233 sourcesConfig[configKey] = configSourceFromStoredSource({
234 source: {
235 ...source,
236 configKey,
237 },
238 config: input.loadedConfig.config,
239 });
240 }
241
242 for (const policy of policies) {
243 const configKey = derivePolicyConfigKey(policy, policyKeys);
244 policyMappings.push({ configKey, policy });
245 policiesConfig[configKey] = configPolicyFromStoredPolicy({
246 ...policy,
247 configKey,
248 });
249 }
250
251 const projectConfig = {
252 ...(Object.keys(sourcesConfig).length > 0 ? { sources: sourcesConfig } : {}),
253 ...(Object.keys(policiesConfig).length > 0 ? { policies: policiesConfig } : {}),
254 } satisfies LocalExecutorConfig;
255
256 yield* Effect.tryPromise({
257 try: () =>
258 writeProjectLocalExecutorConfig({
259 context: input.context,
260 config: projectConfig,
261 }),
262 catch: (cause) =>
263 cause instanceof Error ? cause : new Error(String(cause)),
264 });
265
266 const sourceRecordById = new Map(sourceRecords.map((sourceRecord) => [sourceRecord.id, sourceRecord]));
267 const revisionIds = [...new Set(sourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
268 const [revisions, documents, schemaBundles, operations] = yield* Effect.all([
269 input.rows.sourceRecipeRevisions.listByIds(revisionIds),
270 input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds),
271 input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
272 input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
273 ]);
274 const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
275 const documentsByRevisionId = new Map<string, typeof documents>();
276 const schemaBundlesByRevisionId = new Map<string, typeof schemaBundles>();
277 const operationsByRevisionId = new Map<string, typeof operations>();
278
279 for (const document of documents) {
280 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
281 existing.push(document);
282 documentsByRevisionId.set(document.recipeRevisionId, existing);
283 }
284 for (const schemaBundle of schemaBundles) {
285 const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
286 existing.push(schemaBundle);
287 schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
288 }
289 for (const operation of operations) {
290 const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];
291 existing.push(operation);
292 operationsByRevisionId.set(operation.recipeRevisionId, existing);
293 }
294
295 let workspaceState: LocalWorkspaceState = yield* Effect.tryPromise({
296 try: () => loadLocalWorkspaceState(input.context),
297 catch: (cause) =>
298 cause instanceof Error ? cause : new Error(String(cause)),
299 });
300
301 for (const { configKey, source } of sourceMappings) {
302 workspaceState = {
303 ...workspaceState,
304 sources: {
305 ...workspaceState.sources,
306 [configKey]: {
307 id: source.id,
308 status: source.status,
309 lastError: source.lastError,
310 sourceHash: source.sourceHash,
311 createdAt: source.createdAt,
312 updatedAt: source.updatedAt,
313 },
314 },
315 };
316
317 const sourceRecord = sourceRecordById.get(source.id);
318 const revision = sourceRecord ? revisionById.get(sourceRecord.recipeRevisionId) : null;
319 if (!sourceRecord || !revision) {
320 continue;
321 }
322
323 yield* Effect.tryPromise({
324 try: () =>
325 writeLocalSourceArtifact({
326 context: input.context,
327 configKey,
328 artifact: buildLocalSourceArtifact({
329 source: {
330 ...source,
331 sourceHash: revision.manifestHash ?? source.sourceHash,
332 },
333 configKey,
334 materialization: {
335 manifestJson: revision.manifestJson,
336 manifestHash: revision.manifestHash,
337 sourceHash: revision.manifestHash ?? source.sourceHash,
338 documents: documentsByRevisionId.get(revision.id) ?? [],
339 schemaBundles: schemaBundlesByRevisionId.get(revision.id) ?? [],
340 operations: operationsByRevisionId.get(revision.id) ?? [],
341 },
342 }),
343 }),
344 catch: (cause) =>
345 cause instanceof Error ? cause : new Error(String(cause)),
346 });
347 }
348
349 for (const { configKey, policy } of policyMappings) {
350 workspaceState = {
351 ...workspaceState,
352 policies: {
353 ...workspaceState.policies,
354 [configKey]: {
355 id: policy.id,
356 createdAt: policy.createdAt,
357 updatedAt: policy.updatedAt,
358 },
359 },
360 };
361 }
362
363 yield* Effect.tryPromise({
364 try: () =>
365 writeLocalWorkspaceState({
366 context: input.context,
367 state: workspaceState,
368 }),
369 catch: (cause) =>
370 cause instanceof Error ? cause : new Error(String(cause)),
371 });
372
373 return projectConfig;
374 });
375
376export const synchronizeLocalWorkspaceState = (input: {
377 rows: SqlControlPlaneRows;
378 context: ResolvedLocalWorkspaceContext;
379 loadedConfig: LoadedLocalExecutorConfig;
380 installation: {
381 workspaceId: Workspace["id"];
382 accountId: AccountId;
383 };
384}): Effect.Effect<LocalExecutorConfig | null, Error, never> =>
385 Effect.gen(function* () {
386 const exportedProjectConfig = yield* exportWorkspaceConfig(input);
387 const effectiveConfig =
388 exportedProjectConfig === null
389 ? input.loadedConfig.config
390 : mergeLocalExecutorConfigs(input.loadedConfig.homeConfig, exportedProjectConfig);
391
392 yield* ensureWorkspaceMetadata({
393 rows: input.rows,
394 installation: input.installation,
395 context: input.context,
396 config: effectiveConfig,
397 });
398
399 return effectiveConfig ?? null;
400 });
packages/control-plane/src/runtime/operation-errors.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
2 ControlPlaneBadRequestError,2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,4 ControlPlaneStorageError,
5} from "#api";5} from "../api/errors";
6import { ControlPlanePersistenceError } from "#persistence";6import { ControlPlanePersistenceError } from "#persistence";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8 8
packages/control-plane/src/runtime/operations-shared.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
2 ControlPlaneBadRequestError,2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,4 ControlPlaneStorageError,
5} from "#api";5} from "../api/errors";
6import { ControlPlanePersistenceError } from "#persistence";6import { ControlPlanePersistenceError } from "#persistence";
7import type { Organization } from "#schema";7import type { Organization } from "#schema";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
packages/control-plane/src/runtime/policies-operations.ts+430−0
@@ -1,9 +1,12 @@
1import { createHash } from "node:crypto";
2
1import type {3import type {
2 CreatePolicyPayload,4 CreatePolicyPayload,
3 UpdatePolicyPayload,5 UpdatePolicyPayload,
4} from "../api/policies/api";6} from "../api/policies/api";
5import {7import {
6 PolicyIdSchema,8 PolicyIdSchema,
9 type LocalExecutorConfig,
7 type OrganizationId,10 type OrganizationId,
8 type Policy,11 type Policy,
9 type PolicyId,12 type PolicyId,
@@ -12,6 +15,20 @@ import { · 2 unmodified lines
12import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";16import * as Option from "effect/Option";
14 17
18import {
19 loadLocalExecutorConfig,
20 writeProjectLocalExecutorConfig,
21} from "./local-config";
22import {
23 getRuntimeLocalWorkspaceOption,
24} from "./local-runtime-context";
25import {
26 loadLocalWorkspaceState,
27 writeLocalWorkspaceState,
28} from "./local-workspace-state";
29import {
30 derivePolicyConfigKey,
31} from "./local-workspace-sync";
15import {32import {
16 mapPersistenceError,33 mapPersistenceError,
17 parseJsonString,34 parseJsonString,
@@ -37,6 +54,138 @@ type PolicyScopeContext = { · 19 unmodified lines
37 workspaceId: Policy["workspaceId"];54 workspaceId: Policy["workspaceId"];
38};55};
39 56
57const cloneJson = <T>(value: T): T =>
58 JSON.parse(JSON.stringify(value)) as T;
59
60const trimOrNull = (value: string | null | undefined): string | null => {
61 if (value == null) {
62 return null;
63 }
64
65 const trimmed = value.trim();
66 return trimmed.length > 0 ? trimmed : null;
67};
68
69const localPolicyIdForConfigKey = (input: {
70 workspaceRoot: string;
71 configKey: string;
72}): Policy["id"] =>
73 PolicyIdSchema.make(
74 `pol_local_${createHash("sha256").update(`${input.workspaceRoot}:${input.configKey}`).digest("hex").slice(0, 16)}`,
75 );
76
77const ensureLocalWorkspacePolicyCompatible = (
78 operation: OperationErrors,
79 payload: CreatePolicyPayload | UpdatePolicyPayload,
80) =>
81 Effect.gen(function* () {
82 if (payload.targetAccountId !== undefined && payload.targetAccountId !== null) {
83 return yield* Effect.fail(
84 operation.badRequest(
85 "Unsupported local workspace policy field",
86 "targetAccountId is not supported in .executor/executor.jsonc policies",
87 ),
88 );
89 }
90 if (payload.clientId !== undefined && payload.clientId !== null) {
91 return yield* Effect.fail(
92 operation.badRequest(
93 "Unsupported local workspace policy field",
94 "clientId is not supported in .executor/executor.jsonc policies",
95 ),
96 );
97 }
98 if (payload.argumentConditionsJson !== undefined && payload.argumentConditionsJson !== null) {
99 return yield* Effect.fail(
100 operation.badRequest(
101 "Unsupported local workspace policy field",
102 "argumentConditionsJson is not supported in .executor/executor.jsonc policies",
103 ),
104 );
105 }
106 if (payload.resourceType !== undefined && payload.resourceType !== "tool_path") {
107 return yield* Effect.fail(
108 operation.badRequest(
109 "Unsupported local workspace policy field",
110 "Only tool_path workspace policies are supported in local config",
111 ),
112 );
113 }
114 if (payload.matchType !== undefined && payload.matchType !== "glob") {
115 return yield* Effect.fail(
116 operation.badRequest(
117 "Unsupported local workspace policy field",
118 "Only glob workspace policies are supported in local config",
119 ),
120 );
121 }
122 });
123
124export const loadRuntimeLocalWorkspacePolicies = (input: {
125 store: ControlPlaneStoreShape;
126 workspaceId: WorkspaceId;
127}) =>
128 Effect.gen(function* () {
129 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
130 if (
131 runtimeLocalWorkspace === null
132 || runtimeLocalWorkspace.installation.workspaceId !== input.workspaceId
133 ) {
134 return null;
135 }
136
137 const workspace = yield* input.store.workspaces.getById(input.workspaceId);
138 if (Option.isNone(workspace)) {
139 return null;
140 }
141
142 const loadedConfig = yield* Effect.tryPromise({
143 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),
144 catch: (cause) =>
145 cause instanceof Error ? cause : new Error(String(cause)),
146 });
147 const workspaceState = yield* Effect.tryPromise({
148 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
149 catch: (cause) =>
150 cause instanceof Error ? cause : new Error(String(cause)),
151 });
152
153 const configEntries = Object.entries(loadedConfig.config?.policies ?? {});
154 const policies = configEntries.map(([configKey, configPolicy]) => {
155 const state = workspaceState.policies[configKey];
156 return {
157 id: state?.id ?? localPolicyIdForConfigKey({
158 workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
159 configKey,
160 }),
161 configKey,
162 scopeType: "workspace" as const,
163 organizationId: workspace.value.organizationId,
164 workspaceId: input.workspaceId,
165 targetAccountId: null,
166 clientId: null,
167 resourceType: "tool_path" as const,
168 resourcePattern: configPolicy.match.trim(),
169 matchType: "glob" as const,
170 effect: configPolicy.action,
171 approvalMode: configPolicy.approval === "manual" ? "required" as const : "auto" as const,
172 argumentConditionsJson: null,
173 priority: configPolicy.priority ?? 0,
174 enabled: configPolicy.enabled ?? true,
175 createdAt: state?.createdAt ?? Date.now(),
176 updatedAt: state?.updatedAt ?? Date.now(),
177 } satisfies Policy;
178 });
179
180 return {
181 runtimeLocalWorkspace,
182 loadedConfig,
183 workspaceState,
184 workspace: workspace.value,
185 policies,
186 };
187 });
188
40const loadWorkspacePolicyContext = (189const loadWorkspacePolicyContext = (
41 store: ControlPlaneStoreShape,190 store: ControlPlaneStoreShape,
42 operation: OperationErrors,191 operation: OperationErrors,
@@ -92,6 +241,35 @@ const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean · 49 unmodified lines
92 && policy.organizationId === scope.organizationId241 && policy.organizationId === scope.organizationId
93 && policy.workspaceId === scope.workspaceId;242 && policy.workspaceId === scope.workspaceId;
94 243
244const writeLocalPolicyFiles = (input: {
245 operation: OperationErrors;
246 context: Parameters<typeof writeProjectLocalExecutorConfig>[0]["context"];
247 projectConfig: LocalExecutorConfig;
248 workspaceState: Awaited<ReturnType<typeof loadLocalWorkspaceState>>;
249}) =>
250 Effect.tryPromise({
251 try: () =>
252 Promise.all([
253 writeProjectLocalExecutorConfig({
254 context: input.context,
255 config: input.projectConfig,
256 }),
257 writeLocalWorkspaceState({
258 context: input.context,
259 state: input.workspaceState,
260 }),
261 ]).then(() => undefined),
262 catch: (cause) =>
263 cause instanceof Error ? cause : new Error(String(cause)),
264 }).pipe(
265 Effect.mapError((cause) =>
266 input.operation.unknownStorage(
267 cause,
268 "Failed writing local workspace policy files",
269 ),
270 ),
271 );
272
95const createScopedPolicy = (input: {273const createScopedPolicy = (input: {
96 scope: PolicyScopeContext;274 scope: PolicyScopeContext;
97 payload: CreatePolicyPayload;275 payload: CreatePolicyPayload;
@@ -101,8 +279,90 @@ const createScopedPolicy = (input: { · 3 unmodified lines
101 const now = Date.now();279 const now = Date.now();
102 const operation = policyOps.create;280 const operation = policyOps.create;
103 281
282 if (input.scope.workspaceId !== null) {
283 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
284 store,
285 workspaceId: input.scope.workspaceId,
286 }).pipe(
287 Effect.mapError((cause) =>
288 operation.unknownStorage(
289 cause,
290 "Failed loading local workspace policies",
291 ),
292 ),
293 );
294 if (localWorkspace !== null) {
295 yield* ensureLocalWorkspacePolicyCompatible(operation, input.payload);
296
297 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
298 const policies = {
299 ...(projectConfig.policies ?? {}),
300 };
301 const configKey = derivePolicyConfigKey({
302 configKey: null,
303 resourcePattern: input.payload.resourcePattern ?? "*",
304 effect: input.payload.effect ?? "allow",
305 approvalMode: input.payload.approvalMode ?? "auto",
306 }, new Set(Object.keys(policies)));
307 const id = localWorkspace.workspaceState.policies[configKey]?.id
308 ?? localPolicyIdForConfigKey({
309 workspaceRoot: localWorkspace.runtimeLocalWorkspace.context.workspaceRoot,
310 configKey,
311 });
312 policies[configKey] = {
313 match: input.payload.resourcePattern ?? "*",
314 action: input.payload.effect ?? "allow",
315 approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto",
316 ...(input.payload.enabled === false ? { enabled: false } : {}),
317 ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}),
318 };
319 const existingState = localWorkspace.workspaceState.policies[configKey];
320 const workspaceState = {
321 ...localWorkspace.workspaceState,
322 policies: {
323 ...localWorkspace.workspaceState.policies,
324 [configKey]: {
325 id,
326 createdAt: existingState?.createdAt ?? now,
327 updatedAt: now,
328 },
329 },
330 };
331 yield* writeLocalPolicyFiles({
332 operation,
333 context: localWorkspace.runtimeLocalWorkspace.context,
334 projectConfig: {
335 ...projectConfig,
336 policies,
337 },
338 workspaceState,
339 });
340
341 return {
342 id,
343 configKey,
344 scopeType: "workspace",
345 organizationId: input.scope.organizationId,
346 workspaceId: input.scope.workspaceId,
347 targetAccountId: null,
348 clientId: null,
349 resourceType: "tool_path",
350 resourcePattern: policies[configKey]!.match,
351 matchType: "glob",
352 effect: policies[configKey]!.action,
353 approvalMode: policies[configKey]!.approval === "manual" ? "required" : "auto",
354 argumentConditionsJson: null,
355 priority: policies[configKey]!.priority ?? 0,
356 enabled: policies[configKey]!.enabled ?? true,
357 createdAt: workspaceState.policies[configKey]!.createdAt,
358 updatedAt: now,
359 } satisfies Policy;
360 }
361 }
362
104 const policy: Policy = {363 const policy: Policy = {
105 id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),364 id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),
365 configKey: null,
106 scopeType: input.scope.scopeType,366 scopeType: input.scope.scopeType,
107 organizationId: input.scope.organizationId,367 organizationId: input.scope.organizationId,
108 workspaceId: input.scope.workspaceId,368 workspaceId: input.scope.workspaceId,
@@ -133,6 +393,10 @@ const createScopedPolicy = (input: { · 24 unmodified lines
133 store.policies.insert(policy),393 store.policies.insert(policy),
134 );394 );
135 395
396 if (policy.workspaceId === null) {
397 return policy;
398 }
399
136 return policy;400 return policy;
137 }));401 }));
138 402
@@ -142,6 +406,33 @@ const getScopedPolicy = (input: { · 3 unmodified lines
142}) =>406}) =>
143 Effect.flatMap(ControlPlaneStore, (store) =>407 Effect.flatMap(ControlPlaneStore, (store) =>
144 Effect.gen(function* () {408 Effect.gen(function* () {
409 if (input.scope.workspaceId !== null) {
410 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
411 store,
412 workspaceId: input.scope.workspaceId,
413 }).pipe(
414 Effect.mapError((cause) =>
415 policyOps.get.unknownStorage(
416 cause,
417 "Failed loading local workspace policies",
418 ),
419 ),
420 );
421 if (localWorkspace !== null) {
422 const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
423 if (policy === null) {
424 return yield* Effect.fail(
425 policyOps.get.notFound(
426 "Policy not found",
427 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
428 ),
429 );
430 }
431
432 return policy;
433 }
434 }
435
145 const existing = yield* policyOps.get.mapStorage(436 const existing = yield* policyOps.get.mapStorage(
146 store.policies.getById(input.policyId),437 store.policies.getById(input.policyId),
147 );438 );
@@ -165,6 +456,84 @@ const updateScopedPolicy = (input: { · 17 unmodified lines
165}) =>456}) =>
166 Effect.flatMap(ControlPlaneStore, (store) =>457 Effect.flatMap(ControlPlaneStore, (store) =>
167 Effect.gen(function* () {458 Effect.gen(function* () {
459 if (input.scope.workspaceId !== null) {
460 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
461 store,
462 workspaceId: input.scope.workspaceId,
463 }).pipe(
464 Effect.mapError((cause) =>
465 policyOps.update.unknownStorage(
466 cause,
467 "Failed loading local workspace policies",
468 ),
469 ),
470 );
471 if (localWorkspace !== null) {
472 yield* ensureLocalWorkspacePolicyCompatible(policyOps.update, input.payload);
473 const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
474 if (existing === null || existing.configKey === null) {
475 return yield* Effect.fail(
476 policyOps.update.notFound(
477 "Policy not found",
478 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
479 ),
480 );
481 }
482
483 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
484 const policies = {
485 ...(projectConfig.policies ?? {}),
486 };
487 const existingConfig = policies[existing.configKey] ?? {
488 match: existing.resourcePattern,
489 action: existing.effect,
490 approval: existing.approvalMode === "required" ? "manual" : "auto",
491 };
492 policies[existing.configKey] = {
493 ...existingConfig,
494 ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}),
495 ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}),
496 ...(input.payload.approvalMode !== undefined
497 ? { approval: input.payload.approvalMode === "required" ? "manual" : "auto" }
498 : {}),
499 ...(input.payload.enabled !== undefined ? { enabled: input.payload.enabled } : {}),
500 ...(input.payload.priority !== undefined ? { priority: input.payload.priority } : {}),
501 };
502 const existingState = localWorkspace.workspaceState.policies[existing.configKey];
503 const updatedAt = Date.now();
504 const workspaceState = {
505 ...localWorkspace.workspaceState,
506 policies: {
507 ...localWorkspace.workspaceState.policies,
508 [existing.configKey]: {
509 id: existing.id,
510 createdAt: existingState?.createdAt ?? existing.createdAt,
511 updatedAt,
512 },
513 },
514 };
515 yield* writeLocalPolicyFiles({
516 operation: policyOps.update,
517 context: localWorkspace.runtimeLocalWorkspace.context,
518 projectConfig: {
519 ...projectConfig,
520 policies,
521 },
522 workspaceState,
523 });
524
525 return {
526 ...existing,
527 resourcePattern: policies[existing.configKey]!.match,
528 effect: policies[existing.configKey]!.action,
529 approvalMode: policies[existing.configKey]!.approval === "manual" ? "required" : "auto",
530 priority: policies[existing.configKey]!.priority ?? 0,
531 enabled: policies[existing.configKey]!.enabled ?? true,
532 updatedAt,
533 } satisfies Policy;
534 }
535 }
536
168 const existing = yield* policyOps.update.mapStorage(537 const existing = yield* policyOps.update.mapStorage(
169 store.policies.getById(input.policyId),538 store.policies.getById(input.policyId),
170 );539 );
@@ -234,6 +603,10 @@ const updateScopedPolicy = (input: { · 63 unmodified lines
234 );603 );
235 }604 }
236 605
606 if (updated.value.workspaceId === null) {
607 return updated.value;
608 }
609
237 return updated.value;610 return updated.value;
238 }));611 }));
239 612
@@ -243,6 +616,49 @@ const removeScopedPolicy = (input: { · 3 unmodified lines
243}) =>616}) =>
244 Effect.flatMap(ControlPlaneStore, (store) =>617 Effect.flatMap(ControlPlaneStore, (store) =>
245 Effect.gen(function* () {618 Effect.gen(function* () {
619 if (input.scope.workspaceId !== null) {
620 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
621 store,
622 workspaceId: input.scope.workspaceId,
623 }).pipe(
624 Effect.mapError((cause) =>
625 policyOps.remove.unknownStorage(
626 cause,
627 "Failed loading local workspace policies",
628 ),
629 ),
630 );
631 if (localWorkspace !== null) {
632 const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
633 if (existing === null || existing.configKey === null) {
634 return { removed: false };
635 }
636
637 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
638 const policies = {
639 ...(projectConfig.policies ?? {}),
640 };
641 delete policies[existing.configKey];
642 const {
643 [existing.configKey]: _removedPolicy,
644 ...remainingPolicies
645 } = localWorkspace.workspaceState.policies;
646 yield* writeLocalPolicyFiles({
647 operation: policyOps.remove,
648 context: localWorkspace.runtimeLocalWorkspace.context,
649 projectConfig: {
650 ...projectConfig,
651 policies,
652 },
653 workspaceState: {
654 ...localWorkspace.workspaceState,
655 policies: remainingPolicies,
656 },
657 });
658 return { removed: true };
659 }
660 }
661
246 const existing = yield* policyOps.remove.mapStorage(662 const existing = yield* policyOps.remove.mapStorage(
247 store.policies.getById(input.policyId),663 store.policies.getById(input.policyId),
248 );664 );
@@ -270,6 +686,20 @@ export const listPolicies = (workspaceId: WorkspaceId) => · 21 unmodified lines
270 Effect.flatMap(ControlPlaneStore, (store) =>686 Effect.flatMap(ControlPlaneStore, (store) =>
271 Effect.gen(function* () {687 Effect.gen(function* () {
272 yield* loadWorkspacePolicyContext(store, policyOps.list, workspaceId);688 yield* loadWorkspacePolicyContext(store, policyOps.list, workspaceId);
689 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
690 store,
691 workspaceId,
692 }).pipe(
693 Effect.mapError((cause) =>
694 policyOps.list.unknownStorage(
695 cause,
696 "Failed loading local workspace policies",
697 ),
698 ),
699 );
700 if (localWorkspace !== null) {
701 return localWorkspace.policies;
702 }
273 return yield* policyOps.list.mapStorage(703 return yield* policyOps.list.mapStorage(
274 store.policies.listByWorkspaceId(workspaceId),704 store.policies.listByWorkspaceId(workspaceId),
275 );705 );
packages/control-plane/src/runtime/secret-material-providers.ts+178−1
@@ -1,8 +1,13 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { spawn } from "node:child_process";2import { spawn } from "node:child_process";
3import { promises as fs } from "node:fs";
4import { lstatSync, realpathSync } from "node:fs";
5import { isAbsolute } from "node:path";
3 6
4import type { SqlControlPlaneRows } from "#persistence";7import type { SqlControlPlaneRows } from "#persistence";
5import {8import {
9 type LocalConfigSecretProvider,
10 type LocalExecutorConfig,
6 type SecretMaterialPurpose,11 type SecretMaterialPurpose,
7 SecretMaterialIdSchema,12 SecretMaterialIdSchema,
8 type SecretRef,13 type SecretRef,
@@ -10,6 +15,9 @@ import { · 1 unmodified line
10import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";16import * as Option from "effect/Option";
12 17
18import { resolveConfigRelativePath } from "./local-config";
19import { fromConfigSecretProviderId } from "./local-config-secrets";
20
13export const ENV_SECRET_PROVIDER_ID = "env";21export const ENV_SECRET_PROVIDER_ID = "env";
14export const PARAMS_SECRET_PROVIDER_ID = "params";22export const PARAMS_SECRET_PROVIDER_ID = "params";
15export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";23export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
@@ -43,6 +51,8 @@ type SecretMaterialProviderRuntime = { · 27 unmodified lines
43 env: NodeJS.ProcessEnv;51 env: NodeJS.ProcessEnv;
44 dangerouslyAllowEnvSecrets: boolean;52 dangerouslyAllowEnvSecrets: boolean;
45 keychainServiceName: string;53 keychainServiceName: string;
54 localConfig: LocalExecutorConfig | null;
55 workspaceRoot: string | null;
46};56};
47 57
48type SecretMaterialProvider = {58type SecretMaterialProvider = {
@@ -125,6 +135,7 @@ const runCommand = (input: { · 76 unmodified lines
125 command: string;135 command: string;
126 args: ReadonlyArray<string>;136 args: ReadonlyArray<string>;
127 stdin?: string;137 stdin?: string;
138 env?: NodeJS.ProcessEnv;
128 operation: string;139 operation: string;
129}): Effect.Effect<SpawnResult, Error, never> =>140}): Effect.Effect<SpawnResult, Error, never> =>
130 Effect.tryPromise({141 Effect.tryPromise({
@@ -132,6 +143,7 @@ const runCommand = (input: { · 1 unmodified line
132 new Promise<SpawnResult>((resolve, reject) => {143 new Promise<SpawnResult>((resolve, reject) => {
133 const child = spawn(input.command, [...input.args], {144 const child = spawn(input.command, [...input.args], {
134 stdio: "pipe",145 stdio: "pipe",
146 env: input.env,
135 });147 });
136 148
137 let stdout = "";149 let stdout = "";
@@ -474,17 +486,169 @@ const createSecretMaterialProviderRuntime = (input: { · 336 unmodified lines
474 rows: SqlControlPlaneRows;486 rows: SqlControlPlaneRows;
475 dangerouslyAllowEnvSecrets?: boolean;487 dangerouslyAllowEnvSecrets?: boolean;
476 keychainServiceName?: string;488 keychainServiceName?: string;
489 localConfig?: LocalExecutorConfig | null;
490 workspaceRoot?: string | null;
477}): SecretMaterialProviderRuntime => ({491}): SecretMaterialProviderRuntime => ({
478 rows: input.rows,492 rows: input.rows,
479 env: process.env,493 env: process.env,
480 dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets),494 dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets),
481 keychainServiceName: resolveKeychainServiceName(input.keychainServiceName),495 keychainServiceName: resolveKeychainServiceName(input.keychainServiceName),
496 localConfig: input.localConfig ?? null,
497 workspaceRoot: input.workspaceRoot ?? null,
482});498});
483 499
500const 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
512const 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
524const 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
573const 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
484export const createDefaultSecretMaterialResolver = (input: {646export const createDefaultSecretMaterialResolver = (input: {
485 rows: SqlControlPlaneRows;647 rows: SqlControlPlaneRows;
486 dangerouslyAllowEnvSecrets?: boolean;648 dangerouslyAllowEnvSecrets?: boolean;
487 keychainServiceName?: string;649 keychainServiceName?: string;
650 localConfig?: LocalExecutorConfig | null;
651 workspaceRoot?: string | null;
488}): ResolveSecretMaterial => {652}): ResolveSecretMaterial => {
489 const providers = createSecretMaterialProviderRegistry();653 const providers = createSecretMaterialProviderRegistry();
490 const runtime = createSecretMaterialProviderRuntime(input);654 const runtime = createSecretMaterialProviderRuntime(input);
@@ -494,7 +658,20 @@ export const createDefaultSecretMaterialResolver = (input: { · 3 unmodified lines
494 const provider = yield* getSecretMaterialProvider({658 const provider = yield* getSecretMaterialProvider({
495 providers,659 providers,
496 providerId: ref.providerId,660 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 }
498 675
499 return yield* provider.resolve({676 return yield* provider.resolve({
500 ref,677 ref,
packages/control-plane/src/runtime/source-adapters/mcp.ts+1−1
@@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines
187 });187 });
188 });188 });
189 189
190const materializationFromMcpManifestEntries = (input: {190export const materializationFromMcpManifestEntries = (input: {
191 recipeRevisionId: SourceRecipeRevisionId;191 recipeRevisionId: SourceRecipeRevisionId;
192 endpoint: string;192 endpoint: string;
193 manifestEntries: readonly McpToolManifestEntry[];193 manifestEntries: readonly McpToolManifestEntry[];
packages/control-plane/src/runtime/source-auth-material.test.ts+1−0
@@ -12,6 +12,7 @@ import { resolveSourceAuthMaterial } from "./source-auth-material"; · 11 unmodified lines
12const makeSource = (overrides: Partial<Source> = {}): Source => ({12const makeSource = (overrides: Partial<Source> = {}): Source => ({
13 id: SourceIdSchema.make("src_tool_artifacts"),13 id: SourceIdSchema.make("src_tool_artifacts"),
14 workspaceId: WorkspaceIdSchema.make("ws_tool_artifacts"),14 workspaceId: WorkspaceIdSchema.make("ws_tool_artifacts"),
15 configKey: null,
15 name: "GitHub",16 name: "GitHub",
16 kind: "openapi",17 kind: "openapi",
17 endpoint: "https://api.github.com",18 endpoint: "https://api.github.com",
packages/control-plane/src/runtime/source-auth-service.test.ts+1−0
@@ -32,6 +32,7 @@ import { resolveSourceAuthMaterial } from "./source-auth-material"; · 31 unmodified lines
32const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({32const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
33 id: "src_test" as Source["id"],33 id: "src_test" as Source["id"],
34 workspaceId: "ws_test" as Source["workspaceId"],34 workspaceId: "ws_test" as Source["workspaceId"],
35 configKey: null,
35 name: "GitHub",36 name: "GitHub",
36 kind: "openapi",37 kind: "openapi",
37 endpoint: "https://api.github.com",38 endpoint: "https://api.github.com",
packages/control-plane/src/runtime/source-auth-service.ts+105−62
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 AccountId,11 AccountId,
12 type CredentialSlot,12 type CredentialSlot,
13 ExecutionIdSchema,13 ExecutionIdSchema,
14 type LocalExecutorConfig,
14 McpSourceAuthSessionDataJsonSchema,15 McpSourceAuthSessionDataJsonSchema,
15 type McpSourceAuthSessionData,16 type McpSourceAuthSessionData,
16 OAuth2PkceSourceAuthSessionDataJsonSchema,17 OAuth2PkceSourceAuthSessionDataJsonSchema,
@@ -43,6 +44,10 @@ import { · 26 unmodified lines
43 LiveExecutionManagerService,44 LiveExecutionManagerService,
44 type LiveExecutionManager,45 type LiveExecutionManager,
45} from "./live-execution";46} from "./live-execution";
47import {
48 RuntimeLocalWorkspaceService,
49 type RuntimeLocalWorkspaceState,
50} from "./local-runtime-context";
46import {51import {
47 exchangeMcpOAuthAuthorizationCode,52 exchangeMcpOAuthAuthorizationCode,
48 startMcpOAuthAuthorization,53 startMcpOAuthAuthorization,
@@ -1634,13 +1639,35 @@ export const createRuntimeSourceAuthService = (input: { · 1585 unmodified lines
1634 rows: SqlControlPlaneRows;1639 rows: SqlControlPlaneRows;
1635 liveExecutionManager: LiveExecutionManager;1640 liveExecutionManager: LiveExecutionManager;
1636 getLocalServerBaseUrl?: () => string | undefined;1641 getLocalServerBaseUrl?: () => string | undefined;
1642 localConfig?: LocalExecutorConfig | null;
1643 workspaceRoot?: string | null;
1644 localWorkspaceState?: RuntimeLocalWorkspaceState;
1637}) => {1645}) => {
1638 const resolveSecretMaterial = createDefaultSecretMaterialResolver({1646 const resolveSecretMaterial = createDefaultSecretMaterialResolver({
1639 rows: input.rows,1647 rows: input.rows,
1648 localConfig: input.localConfig,
1649 workspaceRoot: input.workspaceRoot,
1640 });1650 });
1641 const storeSecretMaterial = createDefaultSecretMaterialStorer({1651 const storeSecretMaterial = createDefaultSecretMaterialStorer({
1642 rows: input.rows,1652 rows: input.rows,
1643 });1653 });
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;
1644 1671
1645 return {1672 return {
1646 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,1673 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,
@@ -1652,68 +1679,78 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
1652 }),1679 }),
1653 1680
1654 getSourceById: ({ workspaceId, sourceId, actorAccountId }) =>1681 getSourceById: ({ workspaceId, sourceId, actorAccountId }) =>
1655 loadSourceById(input.rows, {1682 provideLocalWorkspace(
1656 workspaceId,1683 loadSourceById(input.rows, {
1657 sourceId,1684 workspaceId,
1658 actorAccountId,1685 sourceId,
1659 }),1686 actorAccountId,
1687 }),
1688 ),
1660 1689
1661 addExecutorSource: (sourceInput, options = undefined) =>1690 addExecutorSource: (sourceInput, options = undefined) =>
1662 sourceInput.kind === "google_discovery"1691 provideLocalWorkspace(
1663 ? addExecutorGoogleDiscoverySource({1692 (sourceInput.kind === "google_discovery"
1664 rows: input.rows,1693 ? addExecutorGoogleDiscoverySource({
1665 sourceInput,1694 rows: input.rows,
1666 storeSecretMaterial,1695 sourceInput,
1667 resolveSecretMaterial,1696 storeSecretMaterial,
1668 getLocalServerBaseUrl: input.getLocalServerBaseUrl,1697 resolveSecretMaterial,
1669 baseUrl: options?.baseUrl,1698 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1670 })1699 baseUrl: options?.baseUrl,
1671 : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")1700 })
1672 ? addExecutorHttpSource({1701 : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
1673 rows: input.rows,1702 ? addExecutorHttpSource({
1674 sourceInput: sourceInput as Extract<1703 rows: input.rows,
1675 ExecutorAddSourceInput,1704 sourceInput: sourceInput as Extract<
1676 { kind: "openapi" | "graphql" }1705 ExecutorAddSourceInput,
1677 >,1706 { kind: "openapi" | "graphql" }
1678 storeSecretMaterial,1707 >,
1679 resolveSecretMaterial,1708 storeSecretMaterial,
1680 getLocalServerBaseUrl: input.getLocalServerBaseUrl,1709 resolveSecretMaterial,
1681 baseUrl: options?.baseUrl,1710 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1682 })1711 baseUrl: options?.baseUrl,
1683 : connectMcpSourceInternal({1712 })
1684 rows: input.rows,1713 : connectMcpSourceInternal({
1685 getLocalServerBaseUrl: input.getLocalServerBaseUrl,1714 rows: input.rows,
1686 workspaceId: sourceInput.workspaceId,1715 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1687 actorAccountId: sourceInput.actorAccountId,1716 workspaceId: sourceInput.workspaceId,
1688 executionId: sourceInput.executionId,1717 actorAccountId: sourceInput.actorAccountId,
1689 interactionId: sourceInput.interactionId,1718 executionId: sourceInput.executionId,
1690 endpoint: sourceInput.endpoint,1719 interactionId: sourceInput.interactionId,
1691 name: sourceInput.name,1720 endpoint: sourceInput.endpoint,
1692 namespace: sourceInput.namespace,1721 name: sourceInput.name,
1693 mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation,1722 namespace: sourceInput.namespace,
1694 baseUrl: options?.baseUrl,1723 mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation,
1695 resolveSecretMaterial,1724 baseUrl: options?.baseUrl,
1696 }),1725 resolveSecretMaterial,
1726 })).pipe(
1727 Effect.flatMap(mirrorLocalSourceResult),
1728 ),
1729 ),
1697 1730
1698 connectMcpSource: (sourceInput) =>1731 connectMcpSource: (sourceInput) =>
1699 connectMcpSourceInternal({1732 provideLocalWorkspace(
1700 rows: input.rows,1733 connectMcpSourceInternal({
1701 getLocalServerBaseUrl: input.getLocalServerBaseUrl,1734 rows: input.rows,
1702 workspaceId: sourceInput.workspaceId,1735 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1703 actorAccountId: sourceInput.actorAccountId,1736 workspaceId: sourceInput.workspaceId,
1704 sourceId: sourceInput.sourceId,1737 actorAccountId: sourceInput.actorAccountId,
1705 executionId: null,1738 sourceId: sourceInput.sourceId,
1706 interactionId: null,1739 executionId: null,
1707 endpoint: sourceInput.endpoint,1740 interactionId: null,
1708 name: sourceInput.name,1741 endpoint: sourceInput.endpoint,
1709 namespace: sourceInput.namespace,1742 name: sourceInput.name,
1710 enabled: sourceInput.enabled,1743 namespace: sourceInput.namespace,
1711 transport: sourceInput.transport,1744 enabled: sourceInput.enabled,
1712 queryParams: sourceInput.queryParams,1745 transport: sourceInput.transport,
1713 headers: sourceInput.headers,1746 queryParams: sourceInput.queryParams,
1714 baseUrl: sourceInput.baseUrl,1747 headers: sourceInput.headers,
1715 resolveSecretMaterial,1748 baseUrl: sourceInput.baseUrl,
1716 }),1749 resolveSecretMaterial,
1750 }).pipe(
1751 Effect.flatMap(mirrorLocalMcpSourceResult),
1752 ),
1753 ),
1717 1754
1718 startSourceOAuthSession: (oauthInput) =>1755 startSourceOAuthSession: (oauthInput) =>
1719 Effect.gen(function* () {1756 Effect.gen(function* () {
@@ -1781,7 +1818,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines
1781 error,1818 error,
1782 errorDescription,1819 errorDescription,
1783 }) =>1820 }) =>
1784 Effect.gen(function* () {1821 provideLocalWorkspace(Effect.gen(function* () {
1785 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);1822 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
1786 if (Option.isNone(sessionOption)) {1823 if (Option.isNone(sessionOption)) {
1787 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));1824 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
1890 sessionId: session.id,1927 sessionId: session.id,
1891 auth,1928 auth,
1892 } satisfies CompleteSourceOAuthSessionResult;1929 } satisfies CompleteSourceOAuthSessionResult;
1893 }),1930 })),
1894 1931
1895 completeSourceCredentialSetup: ({1932 completeSourceCredentialSetup: ({
1896 workspaceId,1933 workspaceId,
@@ -1901,7 +1938,7 @@ export const createRuntimeSourceAuthService = (input: { · 4 unmodified lines
1901 error,1938 error,
1902 errorDescription,1939 errorDescription,
1903 }) =>1940 }) =>
1904 Effect.gen(function* () {1941 provideLocalWorkspace(Effect.gen(function* () {
1905 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);1942 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
1906 if (Option.isNone(sessionOption)) {1943 if (Option.isNone(sessionOption)) {
1907 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));1944 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
2164 });2201 });
2165 2202
2166 return connectedSource;2203 return connectedSource;
2167 }),2204 })),
2168 } satisfies RuntimeSourceAuthServiceShape;2205 } satisfies RuntimeSourceAuthServiceShape;
2169};2206};
2170 2207
@@ -2176,6 +2213,9 @@ export class RuntimeSourceAuthServiceTag extends Context.Tag( · 5 unmodified lines
2176 2213
2177export const RuntimeSourceAuthServiceLive = (input: {2214export const RuntimeSourceAuthServiceLive = (input: {
2178 getLocalServerBaseUrl?: () => string | undefined;2215 getLocalServerBaseUrl?: () => string | undefined;
2216 localConfig?: LocalExecutorConfig | null;
2217 workspaceRoot?: string | null;
2218 localWorkspaceState?: RuntimeLocalWorkspaceState;
2179} = {}) =>2219} = {}) =>
2180 Layer.effect(2220 Layer.effect(
2181 RuntimeSourceAuthServiceTag,2221 RuntimeSourceAuthServiceTag,
@@ -2187,6 +2227,9 @@ export const RuntimeSourceAuthServiceLive = (input: { · 5 unmodified lines
2187 rows,2227 rows,
2188 liveExecutionManager,2228 liveExecutionManager,
2189 getLocalServerBaseUrl: input.getLocalServerBaseUrl,2229 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
2230 localConfig: input.localConfig,
2231 workspaceRoot: input.workspaceRoot,
2232 localWorkspaceState: input.localWorkspaceState,
2190 });2233 });
2191 }),2234 }),
2192 );2235 );
packages/control-plane/src/runtime/source-definitions.test.ts+1−0
@@ -44,6 +44,7 @@ const mcpBinding = (input: { · 43 unmodified lines
44const makeSource = (overrides: Partial<Source> = {}): Source => ({44const makeSource = (overrides: Partial<Source> = {}): Source => ({
45 id: SourceIdSchema.make("src_source_definitions"),45 id: SourceIdSchema.make("src_source_definitions"),
46 workspaceId: WorkspaceIdSchema.make("ws_source_definitions"),46 workspaceId: WorkspaceIdSchema.make("ws_source_definitions"),
47 configKey: null,
47 name: "GitHub",48 name: "GitHub",
48 kind: "openapi",49 kind: "openapi",
49 endpoint: "https://api.github.com",50 endpoint: "https://api.github.com",
packages/control-plane/src/runtime/source-definitions.ts+3−0
@@ -232,6 +232,7 @@ export const createSourceFromPayload = (input: { · 231 unmodified lines
232 return yield* validateSourceByKind({232 return yield* validateSourceByKind({
233 id: input.sourceId,233 id: input.sourceId,
234 workspaceId: input.workspaceId,234 workspaceId: input.workspaceId,
235 configKey: null,
235 name: input.payload.name.trim(),236 name: input.payload.name.trim(),
236 kind: input.payload.kind,237 kind: input.payload.kind,
237 endpoint: input.payload.endpoint.trim(),238 endpoint: input.payload.endpoint.trim(),
@@ -352,6 +353,7 @@ export const splitSourceForStorage = (input: { · 114 unmodified lines
352 const sourceRecord: StoredSourceRecord = {353 const sourceRecord: StoredSourceRecord = {
353 id: input.source.id,354 id: input.source.id,
354 workspaceId: input.source.workspaceId,355 workspaceId: input.source.workspaceId,
356 configKey: input.source.configKey,
355 recipeId: input.recipeId,357 recipeId: input.recipeId,
356 recipeRevisionId: input.recipeRevisionId,358 recipeRevisionId: input.recipeRevisionId,
357 name: input.source.name,359 name: input.source.name,
@@ -406,6 +408,7 @@ export const projectSourceFromStorage = (input: { · 48 unmodified lines
406 return {408 return {
407 id: input.sourceRecord.id,409 id: input.sourceRecord.id,
408 workspaceId: input.sourceRecord.workspaceId,410 workspaceId: input.sourceRecord.workspaceId,
411 configKey: input.sourceRecord.configKey,
409 name: input.sourceRecord.name,412 name: input.sourceRecord.name,
410 kind: input.sourceRecord.kind,413 kind: input.sourceRecord.kind,
411 endpoint: input.sourceRecord.endpoint,414 endpoint: input.sourceRecord.endpoint,
packages/control-plane/src/runtime/source-inspection.test.ts+2−0
@@ -62,6 +62,7 @@ const makeSource = (input: { · 61 unmodified lines
62}): Source => ({62}): Source => ({
63 id: input.sourceId,63 id: input.sourceId,
64 workspaceId: input.workspaceId,64 workspaceId: input.workspaceId,
65 configKey: null,
65 name: "Cloudflare API",66 name: "Cloudflare API",
66 kind: "openapi",67 kind: "openapi",
67 endpoint: "https://api.cloudflare.com/client/v4",68 endpoint: "https://api.cloudflare.com/client/v4",
@@ -237,6 +238,7 @@ describe("source-inspection", () => { · 169 unmodified lines
237 await Effect.runPromise(persistence.rows.sources.insert({238 await Effect.runPromise(persistence.rows.sources.insert({
238 id: sourceId,239 id: sourceId,
239 workspaceId,240 workspaceId,
241 configKey: null,
240 recipeId,242 recipeId,
241 recipeRevisionId,243 recipeRevisionId,
242 name: "Cloudflare API",244 name: "Cloudflare API",
packages/control-plane/src/runtime/source-inspection.ts+40−51
@@ -16,7 +16,7 @@ import { SourceRecipeSchemaBundleIdSchema } from "#schema"; · 15 unmodified lines
16import {16import {
17 ControlPlaneNotFoundError,17 ControlPlaneNotFoundError,
18 ControlPlaneStorageError,18 ControlPlaneStorageError,
19} from "#api";19} from "../api/errors";
20import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";21import * as Option from "effect/Option";
22 22
@@ -26,6 +26,7 @@ import { · 3 unmodified lines
26 getSourceAdapterForOperation,26 getSourceAdapterForOperation,
27} from "./source-adapters";27} from "./source-adapters";
28import {28import {
29 loadSourceWithRecipe,
29 recipeToolMetadata,30 recipeToolMetadata,
30 recipeToolPath,31 recipeToolPath,
31} from "./source-recipes-runtime";32} from "./source-recipes-runtime";
@@ -110,41 +111,29 @@ const loadSourceRecipeRecord = (input: { · 78 unmodified lines
110}) =>111}) =>
111 Effect.flatMap(ControlPlaneStore, (store) =>112 Effect.flatMap(ControlPlaneStore, (store) =>
112 Effect.gen(function* () {113 Effect.gen(function* () {
113 const sourceRecord = yield* sourceInspectOps.bundle.child("record").mapStorage(114 const recipe = yield* loadSourceWithRecipe({
114 store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),115 rows: store,
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, {
126 workspaceId: input.workspaceId,116 workspaceId: input.workspaceId,
127 sourceId: input.sourceId,117 sourceId: input.sourceId,
128 }).pipe(118 }).pipe(
129 Effect.mapError((cause) =>119 Effect.mapError((cause) =>
130 sourceInspectOps.bundle.unknownStorage(120 cause instanceof Error && cause.message.startsWith("Source not found:")
131 cause,121 ? sourceInspectOps.bundle.notFound(
132 "Failed loading source",122 "Source not found",
133 ),123 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
124 )
125 : sourceInspectOps.bundle.unknownStorage(
126 cause,
127 "Failed loading source recipe",
128 ),
134 ),129 ),
135 );130 );
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 );
142 131
143 return {132 return {
144 source,133 source: recipe.source,
145 operations,134 operations: recipe.operations,
146 schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id135 schemaBundleId: recipe.schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
147 ?? schemaBundles[0]?.id136 ?? recipe.schemaBundles[0]?.id
148 ?? null,137 ?? null,
149 };138 };
150 }),139 }),
@@ -403,27 +392,27 @@ export const getSourceInspectionSchemaBundle = (input: { · 252 unmodified lines
403}) =>392}) =>
404 Effect.flatMap(ControlPlaneStore, (store) =>393 Effect.flatMap(ControlPlaneStore, (store) =>
405 Effect.gen(function* () {394 Effect.gen(function* () {
406 const sourceRecord = yield* sourceInspectOps.tool.child("bundle_record").mapStorage(395 const recipe = yield* loadSourceWithRecipe({
407 store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),396 rows: store,
408 );397 workspaceId: input.workspaceId,
409 if (Option.isNone(sourceRecord)) {398 sourceId: input.sourceId,
410 return yield* Effect.fail(399 }).pipe(
411 sourceInspectOps.tool.notFound(400 Effect.mapError((cause) =>
412 "Source not found",401 cause instanceof Error && cause.message.startsWith("Source not found:")
413 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,402 ? sourceInspectOps.tool.notFound(
414 ),403 "Source not found",
415 );404 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
416 }405 )
417 406 : sourceInspectOps.tool.unknownStorage(
418 const schemaBundle = yield* sourceInspectOps.tool.child("schema_bundle").mapStorage(407 cause,
419 store.sourceRecipeSchemaBundles.getById(408 "Failed loading source recipe",
420 SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),409 ),
421 ),410 ),
422 );411 );
423 if (412 const schemaBundle = recipe.schemaBundles.find(
424 Option.isNone(schemaBundle)413 (candidate) => candidate.id === SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),
425 || schemaBundle.value.recipeRevisionId !== sourceRecord.value.recipeRevisionId414 );
426 ) {415 if (!schemaBundle) {
427 return yield* Effect.fail(416 return yield* Effect.fail(
428 sourceInspectOps.tool.notFound(417 sourceInspectOps.tool.notFound(
429 "Schema bundle not found",418 "Schema bundle not found",
@@ -433,10 +422,10 @@ export const getSourceInspectionSchemaBundle = (input: { · 3 unmodified lines
433 }422 }
434 423
435 return {424 return {
436 id: schemaBundle.value.id,425 id: schemaBundle.id,
437 kind: schemaBundle.value.bundleKind,426 kind: schemaBundle.bundleKind,
438 hash: schemaBundle.value.contentHash,427 hash: schemaBundle.contentHash,
439 refsJson: schemaBundle.value.refsJson,428 refsJson: schemaBundle.refsJson,
440 } satisfies SourceInspectionSchemaBundle;429 } satisfies SourceInspectionSchemaBundle;
441 }),430 }),
442 ).pipe(431 ).pipe(
packages/control-plane/src/runtime/source-materialization.ts+149−2
@@ -2,14 +2,30 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line
2import type {2import type {
3 AccountId,3 AccountId,
4 Source,4 Source,
5 SourceStatus,
5} from "#schema";6} from "#schema";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7 8
9import {
10 buildLocalSourceArtifact,
11 writeLocalSourceArtifact,
12} from "./local-source-artifacts";
13import {
14 getRuntimeLocalWorkspaceOption,
15} from "./local-runtime-context";
16import {
17 loadLocalWorkspaceState,
18 writeLocalWorkspaceState,
19 type LocalWorkspaceState,
20} from "./local-workspace-state";
8import { resolveSourceAuthMaterial } from "./source-auth-material";21import { resolveSourceAuthMaterial } from "./source-auth-material";
9import {22import {
10 getSourceAdapterForSource,23 getSourceAdapterForSource,
11} from "./source-adapters";24} from "./source-adapters";
12import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp";25import {
26 materializationFromMcpManifestEntries,
27 persistMcpRecipeRevisionFromManifestEntries,
28} from "./source-adapters/mcp";
13import type {29import type {
14 ResolveSecretMaterial as ResolveSourceSecretMaterial,30 ResolveSecretMaterial as ResolveSourceSecretMaterial,
15} from "./secret-material-providers";31} from "./secret-material-providers";
@@ -27,6 +43,106 @@ export const syncSourceMaterialization = (input: { · 11 unmodified lines
27 resolveSecretMaterial: ResolveSourceSecretMaterial;43 resolveSecretMaterial: ResolveSourceSecretMaterial;
28}): Effect.Effect<void, Error, never> =>44}): Effect.Effect<void, Error, never> =>
29 Effect.gen(function* () {45 Effect.gen(function* () {
46 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
47 if (
48 runtimeLocalWorkspace !== null
49 && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId
50 && input.source.configKey !== null
51 ) {
52 if (!shouldIndexSource(input.source)) {
53 const state = yield* Effect.tryPromise({
54 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
55 catch: (cause) =>
56 cause instanceof Error ? cause : new Error(String(cause)),
57 });
58 const existingSourceState = state.sources[input.source.configKey];
59 const nextState: LocalWorkspaceState = {
60 ...state,
61 sources: {
62 ...state.sources,
63 [input.source.configKey]: {
64 id: input.source.id,
65 status: (input.source.enabled ? input.source.status : "draft") as SourceStatus,
66 lastError: null,
67 sourceHash: input.source.sourceHash,
68 createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
69 updatedAt: Date.now(),
70 },
71 },
72 };
73 yield* Effect.tryPromise({
74 try: () =>
75 writeLocalWorkspaceState({
76 context: runtimeLocalWorkspace.context,
77 state: nextState,
78 }),
79 catch: (cause) =>
80 cause instanceof Error ? cause : new Error(String(cause)),
81 });
82 return;
83 }
84
85 const adapter = getSourceAdapterForSource(input.source);
86 const materialization = yield* adapter.materializeSource({
87 source: input.source,
88 resolveSecretMaterial: input.resolveSecretMaterial,
89 resolveAuthMaterialForSlot: (slot) =>
90 resolveSourceAuthMaterial({
91 rows: input.rows,
92 source: input.source,
93 slot,
94 actorAccountId: input.actorAccountId,
95 resolveSecretMaterial: input.resolveSecretMaterial,
96 }),
97 });
98
99 yield* Effect.tryPromise({
100 try: () =>
101 writeLocalSourceArtifact({
102 context: runtimeLocalWorkspace.context,
103 configKey: input.source.configKey!,
104 artifact: buildLocalSourceArtifact({
105 source: input.source,
106 configKey: input.source.configKey!,
107 materialization,
108 }),
109 }),
110 catch: (cause) =>
111 cause instanceof Error ? cause : new Error(String(cause)),
112 });
113
114 const state = yield* Effect.tryPromise({
115 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
116 catch: (cause) =>
117 cause instanceof Error ? cause : new Error(String(cause)),
118 });
119 const existingSourceState = state.sources[input.source.configKey];
120 const nextState: LocalWorkspaceState = {
121 ...state,
122 sources: {
123 ...state.sources,
124 [input.source.configKey]: {
125 id: input.source.id,
126 status: "connected",
127 lastError: null,
128 sourceHash: materialization.sourceHash,
129 createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
130 updatedAt: Date.now(),
131 },
132 },
133 };
134 yield* Effect.tryPromise({
135 try: () =>
136 writeLocalWorkspaceState({
137 context: runtimeLocalWorkspace.context,
138 state: nextState,
139 }),
140 catch: (cause) =>
141 cause instanceof Error ? cause : new Error(String(cause)),
142 });
143 return;
144 }
145
30 if (!shouldIndexSource(input.source)) {146 if (!shouldIndexSource(input.source)) {
31 return;147 return;
32 }148 }
@@ -58,4 +174,35 @@ export const persistMcpRecipeMaterializationFromManifest = (input: { · 25 unmodified lines
58 typeof persistMcpRecipeRevisionFromManifestEntries174 typeof persistMcpRecipeRevisionFromManifestEntries
59 >[0]["manifestEntries"];175 >[0]["manifestEntries"];
60}): Effect.Effect<void, Error, never> =>176}): Effect.Effect<void, Error, never> =>
61 persistMcpRecipeRevisionFromManifestEntries(input);177 Effect.gen(function* () {
178 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
179 if (
180 runtimeLocalWorkspace !== null
181 && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId
182 && input.source.configKey !== null
183 ) {
184 const materialization = materializationFromMcpManifestEntries({
185 recipeRevisionId: "src_recipe_rev_materialization" as never,
186 endpoint: input.source.endpoint,
187 manifestEntries: input.manifestEntries,
188 });
189
190 yield* Effect.tryPromise({
191 try: () =>
192 writeLocalSourceArtifact({
193 context: runtimeLocalWorkspace.context,
194 configKey: input.source.configKey!,
195 artifact: buildLocalSourceArtifact({
196 source: input.source,
197 configKey: input.source.configKey!,
198 materialization,
199 }),
200 }),
201 catch: (cause) =>
202 cause instanceof Error ? cause : new Error(String(cause)),
203 });
204 return;
205 }
206
207 return yield* persistMcpRecipeRevisionFromManifestEntries(input);
208 });
packages/control-plane/src/runtime/source-recipes-runtime.test.ts+5−0
@@ -87,6 +87,7 @@ const makeSource = (overrides: Partial<Source> = {}): Source => { · 86 unmodified lines
87 return {87 return {
88 id: SourceIdSchema.make("src_runtime_recipe"),88 id: SourceIdSchema.make("src_runtime_recipe"),
89 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),89 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),
90 configKey: null,
90 name: "GitHub",91 name: "GitHub",
91 kind,92 kind,
92 endpoint,93 endpoint,
@@ -372,6 +373,7 @@ describe("source-recipes-runtime", () => { · 279 unmodified lines
372 sourceRecord: {373 sourceRecord: {
373 id: SourceIdSchema.make("src_runtime_recipe"),374 id: SourceIdSchema.make("src_runtime_recipe"),
374 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),375 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),
376 configKey: null,
375 recipeId: SourceRecipeIdSchema.make("src_recipe_runtime"),377 recipeId: SourceRecipeIdSchema.make("src_recipe_runtime"),
376 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),378 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
377 name: "GITHUB API",379 name: "GITHUB API",
@@ -543,6 +545,7 @@ describe("source-recipes-runtime", () => { · 165 unmodified lines
543 await Effect.runPromise(persistence.rows.sources.insert({545 await Effect.runPromise(persistence.rows.sources.insert({
544 id: SourceIdSchema.make(`src_shared_revision_${index}`),546 id: SourceIdSchema.make(`src_shared_revision_${index}`),
545 workspaceId,547 workspaceId,
548 configKey: null,
546 recipeId,549 recipeId,
547 recipeRevisionId,550 recipeRevisionId,
548 name,551 name,
@@ -621,6 +624,7 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines
621 await Effect.runPromise(persistence.rows.sources.insert({624 await Effect.runPromise(persistence.rows.sources.insert({
622 id: sourceId,625 id: sourceId,
623 workspaceId,626 workspaceId,
627 configKey: null,
624 recipeId,628 recipeId,
625 recipeRevisionId,629 recipeRevisionId,
626 name: "GraphQL Demo",630 name: "GraphQL Demo",
@@ -688,6 +692,7 @@ describe("source-recipes-runtime", () => { · 61 unmodified lines
688 await Effect.runPromise(persistence.rows.sources.insert({692 await Effect.runPromise(persistence.rows.sources.insert({
689 id: sourceId,693 id: sourceId,
690 workspaceId,694 workspaceId,
695 configKey: null,
691 recipeId,696 recipeId,
692 recipeRevisionId,697 recipeRevisionId,
693 name: "Broken GitHub",698 name: "Broken GitHub",
packages/control-plane/src/runtime/source-recipes-runtime.ts+177−189
@@ -15,6 +15,12 @@ import type { · 14 unmodified lines
15import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";16import * as Option from "effect/Option";
17 17
18import {
19 readLocalSourceArtifact,
20} from "./local-source-artifacts";
21import {
22 getRuntimeLocalWorkspaceOption,
23} from "./local-runtime-context";
18import { namespaceFromSourceName } from "./source-names";24import { namespaceFromSourceName } from "./source-names";
19import {25import {
20 getSourceAdapterForOperation,26 getSourceAdapterForOperation,
@@ -156,6 +162,73 @@ export const loadWorkspaceSourceRecipes = (input: { · 135 unmodified lines
156 actorAccountId?: AccountId | null;162 actorAccountId?: AccountId | null;
157}): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> =>163}): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> =>
158 Effect.gen(function* () {164 Effect.gen(function* () {
165 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
166 if (
167 runtimeLocalWorkspace !== null
168 && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId
169 ) {
170 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
171 actorAccountId: input.actorAccountId,
172 });
173
174 const localRecipes = yield* Effect.forEach(sources, (source) =>
175 Effect.gen(function* () {
176 if (source.configKey === null) {
177 return null;
178 }
179
180 const artifact = yield* Effect.tryPromise({
181 try: () =>
182 readLocalSourceArtifact({
183 context: runtimeLocalWorkspace.context,
184 configKey: source.configKey!,
185 }),
186 catch: (cause) =>
187 cause instanceof Error ? cause : new Error(String(cause)),
188 });
189 if (artifact === null) {
190 return null;
191 }
192
193 const sourceRecord: StoredSourceRecord = {
194 id: source.id,
195 workspaceId: source.workspaceId,
196 configKey: source.configKey,
197 recipeId: artifact.recipeId,
198 recipeRevisionId: artifact.revision.id,
199 name: source.name,
200 kind: source.kind,
201 endpoint: source.endpoint,
202 status: source.status,
203 enabled: source.enabled,
204 namespace: source.namespace,
205 importAuthPolicy: source.importAuthPolicy,
206 bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
207 sourceHash: source.sourceHash,
208 lastError: source.lastError,
209 createdAt: source.createdAt,
210 updatedAt: source.updatedAt,
211 };
212 const manifest = yield* parseManifestForRecipe({
213 source,
214 revision: artifact.revision,
215 });
216
217 return {
218 source,
219 sourceRecord,
220 revision: artifact.revision,
221 documents: artifact.documents,
222 schemaBundles: artifact.schemaBundles,
223 operations: artifact.operations,
224 manifest,
225 } satisfies LoadedSourceRecipe;
226 }),
227 );
228
229 return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null);
230 }
231
159 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);232 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
160 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {233 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
161 actorAccountId: input.actorAccountId,234 actorAccountId: input.actorAccountId,
@@ -230,6 +303,72 @@ export const loadSourceWithRecipe = (input: { · 68 unmodified lines
230 actorAccountId?: AccountId | null;303 actorAccountId?: AccountId | null;
231}): Effect.Effect<LoadedSourceRecipe, Error, never> =>304}): Effect.Effect<LoadedSourceRecipe, Error, never> =>
232 Effect.gen(function* () {305 Effect.gen(function* () {
306 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
307 if (
308 runtimeLocalWorkspace !== null
309 && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId
310 ) {
311 const source = yield* loadSourceById(input.rows, {
312 workspaceId: input.workspaceId,
313 sourceId: input.sourceId,
314 actorAccountId: input.actorAccountId,
315 });
316 if (source.configKey === null) {
317 return yield* Effect.fail(
318 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
319 );
320 }
321
322 const artifact = yield* Effect.tryPromise({
323 try: () =>
324 readLocalSourceArtifact({
325 context: runtimeLocalWorkspace.context,
326 configKey: source.configKey!,
327 }),
328 catch: (cause) =>
329 cause instanceof Error ? cause : new Error(String(cause)),
330 });
331 if (artifact === null) {
332 return yield* Effect.fail(
333 new Error(`Recipe artifact missing for source ${input.sourceId}`),
334 );
335 }
336
337 const sourceRecord: StoredSourceRecord = {
338 id: source.id,
339 workspaceId: source.workspaceId,
340 configKey: source.configKey,
341 recipeId: artifact.recipeId,
342 recipeRevisionId: artifact.revision.id,
343 name: source.name,
344 kind: source.kind,
345 endpoint: source.endpoint,
346 status: source.status,
347 enabled: source.enabled,
348 namespace: source.namespace,
349 importAuthPolicy: source.importAuthPolicy,
350 bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
351 sourceHash: source.sourceHash,
352 lastError: source.lastError,
353 createdAt: source.createdAt,
354 updatedAt: source.updatedAt,
355 };
356 const manifest = yield* parseManifestForRecipe({
357 source,
358 revision: artifact.revision,
359 });
360
361 return {
362 source,
363 sourceRecord,
364 revision: artifact.revision,
365 documents: artifact.documents,
366 schemaBundles: artifact.schemaBundles,
367 operations: artifact.operations,
368 manifest,
369 } satisfies LoadedSourceRecipe;
370 }
371
233 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(372 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
234 input.workspaceId,373 input.workspaceId,
235 input.sourceId,374 input.sourceId,
@@ -339,94 +478,26 @@ export const loadWorkspaceSourceRecipeToolIndex = (input: { · 103 unmodified lines
339 includeSchemas: boolean;478 includeSchemas: boolean;
340}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>479}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>
341 Effect.gen(function* () {480 Effect.gen(function* () {
342 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);481 const recipes = yield* loadWorkspaceSourceRecipes({
343 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {482 rows: input.rows,
483 workspaceId: input.workspaceId,
344 actorAccountId: input.actorAccountId,484 actorAccountId: input.actorAccountId,
345 });485 });
346 const sourceById = new Map(sources.map((source) => [source.id, source]));486 const tools = yield* expandRecipeTools({
347 const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));487 recipes,
348 const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];488 includeSchemas: input.includeSchemas,
349 const [operations, schemaBundles] = yield* Effect.all([489 });
350 input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),490 return tools.map((tool) => ({
351 input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),491 path: tool.path,
352 ]);492 searchNamespace: tool.searchNamespace,
353 493 searchText: tool.searchText,
354 const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();494 source: tool.source,
355 for (const operation of operations) {495 sourceRecord: tool.sourceRecord,
356 const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];496 operation: tool.operation,
357 existing.push(operation);497 metadata: tool.metadata,
358 operationsByRevisionId.set(operation.recipeRevisionId, existing);498 schemaBundleId: tool.schemaBundleId,
359 }499 descriptor: tool.descriptor,
360 500 }));
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();
430 });501 });
431 502
432export const loadWorkspaceSourceRecipeToolByPath = (input: {503export const loadWorkspaceSourceRecipeToolByPath = (input: {
@@ -437,110 +508,27 @@ export const loadWorkspaceSourceRecipeToolByPath = (input: { · 4 unmodified lines
437 includeSchemas: boolean;508 includeSchemas: boolean;
438}): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> =>509}): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> =>
439 Effect.gen(function* () {510 Effect.gen(function* () {
440 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);511 const recipes = yield* loadWorkspaceSourceRecipes({
441 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {512 rows: input.rows,
513 workspaceId: input.workspaceId,
442 actorAccountId: input.actorAccountId,514 actorAccountId: input.actorAccountId,
443 });515 });
444 const sourceById = new Map(sources.map((source) => [source.id, source]));516 const tools = yield* expandRecipeTools({
445 const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));517 recipes,
446 518 includeSchemas: input.includeSchemas,
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 }];
471 });519 });
472 520 const tool = tools.find((entry) => entry.path === input.path) ?? null;
473 if (candidates.length === 0) {521 return tool
474 return null;522 ? {
475 }523 path: tool.path,
476 524 searchNamespace: tool.searchNamespace,
477 const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))];525 searchText: tool.searchText,
478 const [operations, schemaBundles] = yield* Effect.all([526 source: tool.source,
479 input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),527 sourceRecord: tool.sourceRecord,
480 input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),528 operation: tool.operation,
481 ]);529 metadata: tool.metadata,
482 const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();530 schemaBundleId: tool.schemaBundleId,
483 for (const schemaBundle of schemaBundles) {531 descriptor: tool.descriptor,
484 const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];532 }
485 existing.push(schemaBundle);533 : null;
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;
546 });534 });
packages/control-plane/src/runtime/source-store.test.ts+1−0
@@ -52,6 +52,7 @@ const makeOpenApiSource = (input: { · 51 unmodified lines
52}): Source => ({52}): Source => ({
53 id: input.sourceId,53 id: input.sourceId,
54 workspaceId: input.workspaceId,54 workspaceId: input.workspaceId,
55 configKey: null,
55 name: input.name ?? "GitHub",56 name: input.name ?? "GitHub",
56 kind: "openapi",57 kind: "openapi",
57 endpoint: input.endpoint ?? "https://api.github.com",58 endpoint: input.endpoint ?? "https://api.github.com",
packages/control-plane/src/runtime/source-store.ts+631−0
@@ -1,7 +1,11 @@
1import { createHash } from "node:crypto";
2
1import type {3import type {
2 AccountId,4 AccountId,
3 AuthArtifact,5 AuthArtifact,
4 CredentialSlot,6 CredentialSlot,
7 LocalConfigSecretInput,
8 LocalConfigSource,
5 Source,9 Source,
6 SourceRecipeId,10 SourceRecipeId,
7 SourceRecipeRevisionId,11 SourceRecipeRevisionId,
@@ -20,9 +24,32 @@ import { · 12 unmodified lines
20 stableSourceRecipeRevisionId,24 stableSourceRecipeRevisionId,
21 splitSourceForStorage,25 splitSourceForStorage,
22} from "./source-definitions";26} from "./source-definitions";
27import {
28 loadLocalExecutorConfig,
29 type ResolvedLocalWorkspaceContext,
30 writeProjectLocalExecutorConfig,
31} from "./local-config";
32import {
33 readLocalSourceArtifact,
34 removeLocalSourceArtifact,
35} from "./local-source-artifacts";
36import {
37 getRuntimeLocalWorkspaceOption,
38} from "./local-runtime-context";
39import {
40 loadLocalWorkspaceState,
41 writeLocalWorkspaceState,
42 type LocalWorkspaceState,
43} from "./local-workspace-state";
44import {
45 fromConfigSecretProviderId,
46 toConfigSecretProviderId,
47} from "./local-config-secrets";
23import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";48import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
24import { authArtifactSecretMaterialRefs } from "./auth-artifacts";49import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
25import { removeAuthLeaseAndSecrets } from "./auth-leases";50import { removeAuthLeaseAndSecrets } from "./auth-leases";
51import { getSourceAdapter } from "./source-adapters";
52import { slugify } from "./slug";
26 53
27const secretRefKey = (ref: { providerId: string; handle: string }): string =>54const secretRefKey = (ref: { providerId: string; handle: string }): string =>
28 `${ref.providerId}:${ref.handle}`;55 `${ref.providerId}:${ref.handle}`;
@@ -79,6 +106,283 @@ const selectExactAuthArtifact = (input: { · 50 unmodified lines
79 && artifact.actorAccountId === (input.actorAccountId ?? null),106 && artifact.actorAccountId === (input.actorAccountId ?? null),
80 ) ?? null;107 ) ?? null;
81 108
109const trimOrNull = (value: string | null | undefined): string | null => {
110 if (value == null) {
111 return null;
112 }
113
114 const trimmed = value.trim();
115 return trimmed.length > 0 ? trimmed : null;
116};
117
118const cloneJson = <T>(value: T): T =>
119 JSON.parse(JSON.stringify(value)) as T;
120
121const localSourceIdForConfigKey = (input: {
122 workspaceRoot: string;
123 configKey: string;
124}): Source["id"] => {
125 const hash = createHash("sha256")
126 .update(`${input.workspaceRoot}:${input.configKey}`)
127 .digest("hex")
128 .slice(0, 16);
129 return `src_local_${hash}` as Source["id"];
130};
131
132const deriveLocalSourceConfigKey = (source: Pick<Source, "configKey" | "namespace" | "name">): string => {
133 const base =
134 trimOrNull(source.configKey)
135 ?? trimOrNull(source.namespace)
136 ?? trimOrNull(source.name)
137 ?? "source";
138 return slugify(base) || "source";
139};
140
141const resolveLocalConfigSecretProviderAlias = (config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"]): string | null => {
142 const defaultAlias = trimOrNull(config?.secrets?.defaults?.env);
143 if (defaultAlias !== null && config?.secrets?.providers?.[defaultAlias]) {
144 return defaultAlias;
145 }
146
147 return config?.secrets?.providers?.default ? "default" : null;
148};
149
150const sourceAuthFromConfigInput = (input: {
151 auth: unknown;
152 config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"];
153 existing: Source["auth"] | null;
154}): Source["auth"] => {
155 if (input.auth === undefined) {
156 return input.existing ?? { kind: "none" };
157 }
158
159 if (typeof input.auth === "string") {
160 const providerAlias = resolveLocalConfigSecretProviderAlias(input.config);
161 return {
162 kind: "bearer",
163 headerName: "Authorization",
164 prefix: "Bearer ",
165 token: {
166 providerId: providerAlias ? toConfigSecretProviderId(providerAlias) : "env",
167 handle: input.auth,
168 },
169 };
170 }
171
172 if (typeof input.auth === "object" && input.auth !== null) {
173 const explicit = input.auth as {
174 source?: string;
175 provider?: string;
176 id?: string;
177 };
178 const providerAlias = trimOrNull(explicit.provider);
179 const providerId = providerAlias
180 ? toConfigSecretProviderId(providerAlias)
181 : explicit.source === "env"
182 ? "env"
183 : null;
184 const handle = trimOrNull(explicit.id);
185 if (providerId && handle) {
186 return {
187 kind: "bearer",
188 headerName: "Authorization",
189 prefix: "Bearer ",
190 token: {
191 providerId,
192 handle,
193 },
194 };
195 }
196 }
197
198 return input.existing ?? { kind: "none" };
199};
200
201const configAuthFromSource = (input: {
202 source: Source;
203 existingConfigAuth: LocalConfigSecretInput | undefined;
204 config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"];
205}): LocalConfigSecretInput | undefined => {
206 if (input.source.auth.kind !== "bearer") {
207 return input.existingConfigAuth;
208 }
209
210 if (input.source.auth.token.providerId === "env") {
211 return input.source.auth.token.handle;
212 }
213
214 const provider = fromConfigSecretProviderId(input.source.auth.token.providerId);
215 if (provider !== null) {
216 const configuredProvider = input.config?.secrets?.providers?.[provider];
217 if (configuredProvider) {
218 return {
219 source: configuredProvider.source,
220 provider,
221 id: input.source.auth.token.handle,
222 };
223 }
224 }
225
226 return input.existingConfigAuth;
227};
228
229const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId) =>
230 Effect.gen(function* () {
231 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
232 if (runtimeLocalWorkspace === null || runtimeLocalWorkspace.installation.workspaceId !== workspaceId) {
233 return null;
234 }
235
236 const loadedConfig = yield* Effect.tryPromise({
237 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),
238 catch: (cause) =>
239 cause instanceof Error ? cause : new Error(String(cause)),
240 });
241 const workspaceState = yield* Effect.tryPromise({
242 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
243 catch: (cause) =>
244 cause instanceof Error ? cause : new Error(String(cause)),
245 });
246
247 return {
248 context: runtimeLocalWorkspace.context,
249 installation: runtimeLocalWorkspace.installation,
250 loadedConfig,
251 workspaceState,
252 };
253 });
254
255const buildLocalSourceRecord = (input: {
256 context: ResolvedLocalWorkspaceContext;
257 workspaceId: WorkspaceId;
258 loadedConfig: Awaited<ReturnType<typeof loadLocalExecutorConfig>>;
259 workspaceState: LocalWorkspaceState;
260 configKey: string;
261 actorAccountId?: AccountId | null;
262 authArtifacts: ReadonlyArray<AuthArtifact>;
263}): Effect.Effect<{
264 source: Source;
265 configKey: string;
266}, Error, never> =>
267 Effect.gen(function* () {
268 const sourceConfig = input.loadedConfig.config?.sources?.[input.configKey];
269 if (!sourceConfig) {
270 return yield* Effect.fail(new Error(`Configured source not found for key ${input.configKey}`));
271 }
272
273 const existingState = input.workspaceState.sources[input.configKey];
274 const adapter = getSourceAdapter(sourceConfig.kind);
275 const baseSource = yield* adapter.validateSource({
276 id: existingState?.id ?? localSourceIdForConfigKey({
277 workspaceRoot: input.context.workspaceRoot,
278 configKey: input.configKey,
279 }),
280 workspaceId: input.workspaceId,
281 configKey: input.configKey,
282 name: trimOrNull(sourceConfig.name) ?? input.configKey,
283 kind: sourceConfig.kind,
284 endpoint: sourceConfig.connection.endpoint.trim(),
285 status: existingState?.status ?? ((sourceConfig.enabled ?? true) ? "connected" : "draft"),
286 enabled: sourceConfig.enabled ?? true,
287 namespace: trimOrNull(sourceConfig.namespace) ?? input.configKey,
288 bindingVersion: adapter.bindingConfigVersion,
289 binding: sourceConfig.binding,
290 importAuthPolicy: adapter.defaultImportAuthPolicy,
291 importAuth: { kind: "none" },
292 auth: sourceAuthFromConfigInput({
293 auth: sourceConfig.connection.auth,
294 config: input.loadedConfig.config,
295 existing: null,
296 }),
297 sourceHash: existingState?.sourceHash ?? null,
298 lastError: existingState?.lastError ?? null,
299 createdAt: existingState?.createdAt ?? Date.now(),
300 updatedAt: existingState?.updatedAt ?? Date.now(),
301 });
302
303 const artifact = yield* Effect.tryPromise({
304 try: () => readLocalSourceArtifact({
305 context: input.context,
306 configKey: input.configKey,
307 }),
308 catch: (cause) =>
309 cause instanceof Error ? cause : new Error(String(cause)),
310 }).pipe(
311 Effect.catchAll(() => Effect.succeed(null)),
312 );
313
314 const runtimeAuthArtifact = selectPreferredAuthArtifact({
315 authArtifacts: input.authArtifacts.filter((artifactItem) => artifactItem.sourceId === baseSource.id),
316 actorAccountId: input.actorAccountId,
317 slot: "runtime",
318 });
319 const importAuthArtifact = selectPreferredAuthArtifact({
320 authArtifacts: input.authArtifacts.filter((artifactItem) => artifactItem.sourceId === baseSource.id),
321 actorAccountId: input.actorAccountId,
322 slot: "import",
323 });
324
325 const sourceRecord = {
326 id: baseSource.id,
327 workspaceId: baseSource.workspaceId,
328 configKey: input.configKey,
329 recipeId: artifact?.recipeId ?? stableSourceRecipeId(baseSource),
330 recipeRevisionId: artifact?.revision.id ?? stableSourceRecipeRevisionId(baseSource),
331 name: baseSource.name,
332 kind: baseSource.kind,
333 endpoint: baseSource.endpoint,
334 status: baseSource.status,
335 enabled: baseSource.enabled,
336 namespace: baseSource.namespace,
337 importAuthPolicy: baseSource.importAuthPolicy,
338 bindingConfigJson: adapter.serializeBindingConfig(baseSource),
339 sourceHash: baseSource.sourceHash,
340 lastError: baseSource.lastError,
341 createdAt: baseSource.createdAt,
342 updatedAt: baseSource.updatedAt,
343 };
344
345 const source = yield* projectSourceFromStorage({
346 sourceRecord,
347 runtimeAuthArtifact,
348 importAuthArtifact,
349 });
350
351 return {
352 source,
353 configKey: input.configKey,
354 };
355 });
356
357const resolveLocalSourceConfigKey = (input: {
358 context: {
359 workspaceRoot: string;
360 };
361 loadedConfig: Awaited<ReturnType<typeof loadLocalExecutorConfig>>;
362 workspaceState: LocalWorkspaceState;
363 sourceId: Source["id"];
364}): string | null => {
365 const configKeys = Object.keys(input.loadedConfig.config?.sources ?? {});
366 for (const configKey of configKeys) {
367 const storedId = input.workspaceState.sources[configKey]?.id;
368 if (storedId === input.sourceId) {
369 return configKey;
370 }
371
372 if (
373 storedId === undefined
374 && localSourceIdForConfigKey({
375 workspaceRoot: input.context.workspaceRoot,
376 configKey,
377 }) === input.sourceId
378 ) {
379 return configKey;
380 }
381 }
382
383 return null;
384};
385
82export const loadSourcesInWorkspace = (386export const loadSourcesInWorkspace = (
83 rows: SqlControlPlaneRows,387 rows: SqlControlPlaneRows,
84 workspaceId: WorkspaceId,388 workspaceId: WorkspaceId,
@@ -87,6 +391,27 @@ export const loadSourcesInWorkspace = ( · 2 unmodified lines
87 } = {},391 } = {},
88) =>392) =>
89 Effect.gen(function* () {393 Effect.gen(function* () {
394 const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId);
395 if (localWorkspace !== null) {
396 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
397 return yield* Effect.forEach(
398 Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}),
399 (configKey) =>
400 Effect.map(
401 buildLocalSourceRecord({
402 context: localWorkspace.context,
403 workspaceId,
404 loadedConfig: localWorkspace.loadedConfig,
405 workspaceState: localWorkspace.workspaceState,
406 configKey,
407 actorAccountId: options.actorAccountId,
408 authArtifacts,
409 }),
410 ({ source }) => source,
411 ),
412 );
413 }
414
90 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);415 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
91 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);416 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
92 const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => {417 const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => {
@@ -118,6 +443,35 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 25 unmodified lines
118 actorAccountId?: AccountId | null;443 actorAccountId?: AccountId | null;
119}) =>444}) =>
120 Effect.gen(function* () {445 Effect.gen(function* () {
446 const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);
447 if (localWorkspace !== null) {
448 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId);
449 const configKey = resolveLocalSourceConfigKey({
450 context: localWorkspace.context,
451 loadedConfig: localWorkspace.loadedConfig,
452 workspaceState: localWorkspace.workspaceState,
453 sourceId: input.sourceId,
454 });
455
456 if (configKey === null) {
457 return yield* Effect.fail(
458 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
459 );
460 }
461
462 const localSource = yield* buildLocalSourceRecord({
463 context: localWorkspace.context,
464 workspaceId: input.workspaceId,
465 loadedConfig: localWorkspace.loadedConfig,
466 workspaceState: localWorkspace.workspaceState,
467 configKey,
468 actorAccountId: input.actorAccountId,
469 authArtifacts,
470 });
471
472 return localSource.source;
473 }
474
121 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(475 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
122 input.workspaceId,476 input.workspaceId,
123 input.sourceId,477 input.sourceId,
@@ -151,6 +505,62 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 27 unmodified lines
151 });505 });
152 });506 });
153 507
508const configSourceFromLocalSource = (input: {
509 source: Source;
510 configKey: string;
511 existingConfigAuth: LocalConfigSecretInput | undefined;
512 config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"];
513}): LocalConfigSource => {
514 const auth = configAuthFromSource({
515 source: input.source,
516 existingConfigAuth: input.existingConfigAuth,
517 config: input.config,
518 });
519
520 const common = {
521 ...(trimOrNull(input.source.name) !== trimOrNull(input.configKey)
522 ? { name: input.source.name }
523 : {}),
524 ...(trimOrNull(input.source.namespace) !== trimOrNull(input.configKey)
525 ? { namespace: input.source.namespace ?? undefined }
526 : {}),
527 ...(input.source.enabled === false ? { enabled: false } : {}),
528 connection: {
529 endpoint: input.source.endpoint,
530 ...(auth !== undefined ? { auth } : {}),
531 },
532 };
533
534 switch (input.source.kind) {
535 case "openapi":
536 return {
537 kind: "openapi",
538 ...common,
539 binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "openapi" }>["binding"],
540 };
541 case "graphql":
542 return {
543 kind: "graphql",
544 ...common,
545 binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "graphql" }>["binding"],
546 };
547 case "google_discovery":
548 return {
549 kind: "google_discovery",
550 ...common,
551 binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "google_discovery" }>["binding"],
552 };
553 case "mcp":
554 return {
555 kind: "mcp",
556 ...common,
557 binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "mcp" }>["binding"],
558 };
559 default:
560 throw new Error(`Unsupported source kind for local config: ${input.source.kind}`);
561 }
562};
563
154const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {564const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {
155 workspaceId: WorkspaceId;565 workspaceId: WorkspaceId;
156 sourceId: Source["id"];566 sourceId: Source["id"];
@@ -227,6 +637,75 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 70 unmodified lines
227 sourceId: Source["id"];637 sourceId: Source["id"];
228}) =>638}) =>
229 Effect.gen(function* () {639 Effect.gen(function* () {
640 const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);
641 if (localWorkspace !== null) {
642 const configKey = resolveLocalSourceConfigKey({
643 context: localWorkspace.context,
644 loadedConfig: localWorkspace.loadedConfig,
645 workspaceState: localWorkspace.workspaceState,
646 sourceId: input.sourceId,
647 });
648 if (configKey === null) {
649 return false;
650 }
651
652 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
653 const sources = {
654 ...(projectConfig.sources ?? {}),
655 };
656 delete sources[configKey];
657 yield* Effect.tryPromise({
658 try: () =>
659 writeProjectLocalExecutorConfig({
660 context: localWorkspace.context,
661 config: {
662 ...projectConfig,
663 sources,
664 },
665 }),
666 catch: (cause) =>
667 cause instanceof Error ? cause : new Error(String(cause)),
668 });
669
670 const {
671 [configKey]: _removedSource,
672 ...remainingSources
673 } = localWorkspace.workspaceState.sources;
674 const workspaceState: LocalWorkspaceState = {
675 ...localWorkspace.workspaceState,
676 sources: remainingSources,
677 };
678 yield* Effect.tryPromise({
679 try: () =>
680 writeLocalWorkspaceState({
681 context: localWorkspace.context,
682 state: workspaceState,
683 }),
684 catch: (cause) =>
685 cause instanceof Error ? cause : new Error(String(cause)),
686 });
687 yield* Effect.tryPromise({
688 try: () =>
689 removeLocalSourceArtifact({
690 context: localWorkspace.context,
691 configKey,
692 }),
693 catch: (cause) =>
694 cause instanceof Error ? cause : new Error(String(cause)),
695 });
696
697 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
698 input.workspaceId,
699 input.sourceId,
700 );
701 yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({
702 workspaceId: input.workspaceId,
703 sourceId: input.sourceId,
704 });
705 yield* removeAuthArtifactsForSource(rows, input);
706 return true;
707 }
708
230 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);709 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);
231 if (Option.isNone(sourceRecord)) {710 if (Option.isNone(sourceRecord)) {
232 return false;711 return false;
@@ -262,6 +741,158 @@ export const persistSource = ( · 29 unmodified lines
262 } = {},741 } = {},
263) =>742) =>
264 Effect.gen(function* () {743 Effect.gen(function* () {
744 const localWorkspace = yield* resolveRuntimeLocalWorkspace(source.workspaceId);
745 if (localWorkspace !== null) {
746 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
747 workspaceId: source.workspaceId,
748 sourceId: source.id,
749 });
750 const existingRuntimeAuthArtifact = selectExactAuthArtifact({
751 authArtifacts: existingAuthArtifacts,
752 actorAccountId: options.actorAccountId,
753 slot: "runtime",
754 });
755 const existingImportAuthArtifact = selectExactAuthArtifact({
756 authArtifacts: existingAuthArtifacts,
757 actorAccountId: options.actorAccountId,
758 slot: "import",
759 });
760 const configKey =
761 source.configKey
762 ?? resolveLocalSourceConfigKey({
763 context: localWorkspace.context,
764 loadedConfig: localWorkspace.loadedConfig,
765 workspaceState: localWorkspace.workspaceState,
766 sourceId: source.id,
767 })
768 ?? deriveLocalSourceConfigKey(source);
769 const nextSource = {
770 ...source,
771 configKey,
772 } satisfies Source;
773 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
774 const sources = {
775 ...(projectConfig.sources ?? {}),
776 };
777 const existingConfigSource = sources[configKey];
778 sources[configKey] = configSourceFromLocalSource({
779 source: nextSource,
780 configKey,
781 existingConfigAuth: existingConfigSource?.connection.auth,
782 config: localWorkspace.loadedConfig.config,
783 });
784 yield* Effect.tryPromise({
785 try: () =>
786 writeProjectLocalExecutorConfig({
787 context: localWorkspace.context,
788 config: {
789 ...projectConfig,
790 sources,
791 },
792 }),
793 catch: (cause) =>
794 cause instanceof Error ? cause : new Error(String(cause)),
795 });
796
797 const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
798 source: nextSource,
799 recipeId: stableSourceRecipeId(nextSource),
800 recipeRevisionId: stableSourceRecipeRevisionId(nextSource),
801 actorAccountId: options.actorAccountId,
802 existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,
803 existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,
804 });
805
806 if (runtimeAuthArtifact === null) {
807 if (existingRuntimeAuthArtifact !== null) {
808 yield* removeAuthLeaseAndSecrets(rows, {
809 authArtifactId: existingRuntimeAuthArtifact.id,
810 });
811 }
812 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
813 workspaceId: source.workspaceId,
814 sourceId: source.id,
815 actorAccountId: options.actorAccountId ?? null,
816 slot: "runtime",
817 });
818 } else {
819 yield* rows.authArtifacts.upsert(runtimeAuthArtifact);
820 if (
821 existingRuntimeAuthArtifact !== null
822 && existingRuntimeAuthArtifact.id !== runtimeAuthArtifact.id
823 ) {
824 yield* removeAuthLeaseAndSecrets(rows, {
825 authArtifactId: existingRuntimeAuthArtifact.id,
826 });
827 }
828 }
829
830 yield* cleanupAuthArtifactSecretRefs(rows, {
831 previous: existingRuntimeAuthArtifact ?? null,
832 next: runtimeAuthArtifact,
833 });
834
835 if (importAuthArtifact === null) {
836 if (existingImportAuthArtifact !== null) {
837 yield* removeAuthLeaseAndSecrets(rows, {
838 authArtifactId: existingImportAuthArtifact.id,
839 });
840 }
841 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
842 workspaceId: source.workspaceId,
843 sourceId: source.id,
844 actorAccountId: options.actorAccountId ?? null,
845 slot: "import",
846 });
847 } else {
848 yield* rows.authArtifacts.upsert(importAuthArtifact);
849 if (
850 existingImportAuthArtifact !== null
851 && existingImportAuthArtifact.id !== importAuthArtifact.id
852 ) {
853 yield* removeAuthLeaseAndSecrets(rows, {
854 authArtifactId: existingImportAuthArtifact.id,
855 });
856 }
857 }
858
859 yield* cleanupAuthArtifactSecretRefs(rows, {
860 previous: existingImportAuthArtifact ?? null,
861 next: importAuthArtifact,
862 });
863
864 const existingSourceState = localWorkspace.workspaceState.sources[configKey];
865 const workspaceState: LocalWorkspaceState = {
866 ...localWorkspace.workspaceState,
867 sources: {
868 ...localWorkspace.workspaceState.sources,
869 [configKey]: {
870 id: nextSource.id,
871 status: nextSource.status,
872 lastError: nextSource.lastError,
873 sourceHash: nextSource.sourceHash,
874 createdAt: existingSourceState?.createdAt ?? nextSource.createdAt,
875 updatedAt: nextSource.updatedAt,
876 },
877 },
878 };
879 yield* Effect.tryPromise({
880 try: () =>
881 writeLocalWorkspaceState({
882 context: localWorkspace.context,
883 state: workspaceState,
884 }),
885 catch: (cause) =>
886 cause instanceof Error ? cause : new Error(String(cause)),
887 });
888
889 return yield* loadSourceById(rows, {
890 workspaceId: source.workspaceId,
891 sourceId: nextSource.id,
892 actorAccountId: options.actorAccountId,
893 });
894 }
895
265 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);896 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
266 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({897 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
267 workspaceId: source.workspaceId,898 workspaceId: source.workspaceId,
packages/control-plane/src/runtime/sources-operations.ts+39−31
@@ -140,16 +140,18 @@ const syncArtifactsForSource = (input: { · 139 unmodified lines
140export const listSources = (workspaceId: WorkspaceId) =>140export const listSources = (workspaceId: WorkspaceId) =>
141 Effect.flatMap(ControlPlaneStore, (store) =>141 Effect.flatMap(ControlPlaneStore, (store) =>
142 Effect.flatMap(Actor, (actor) =>142 Effect.flatMap(Actor, (actor) =>
143 loadSourcesInWorkspace(store, workspaceId, {143 Effect.gen(function* () {
144 actorAccountId: actor.principal.accountId,144 return yield* loadSourcesInWorkspace(store, workspaceId, {
145 }).pipe(145 actorAccountId: actor.principal.accountId,
146 Effect.mapError((error) =>146 }).pipe(
147 sourceOps.list.unknownStorage(147 Effect.mapError((error) =>
148 error,148 sourceOps.list.unknownStorage(
149 "Failed projecting stored sources",149 error,
150 "Failed projecting stored sources",
151 ),
150 ),152 ),
151 ),153 );
152 ),154 }),
153 ));155 ));
154 156
155export const createSource = (input: {157export const createSource = (input: {
@@ -175,19 +177,21 @@ export const createSource = (input: { · 19 unmodified lines
175 ),177 ),
176 );178 );
177 179
178 yield* mapPersistenceError(180 const persistedSource = yield* mapPersistenceError(
179 sourceOps.create.child("persist"),181 sourceOps.create.child("persist"),
180 persistSource(store, source, {182 persistSource(store, source, {
181 actorAccountId: actor.principal.accountId,183 actorAccountId: actor.principal.accountId,
182 }),184 }),
183 );185 );
184 186
185 return yield* syncArtifactsForSource({187 const synchronizedSource = yield* syncArtifactsForSource({
186 store,188 store,
187 source,189 source: persistedSource,
188 actorAccountId: actor.principal.accountId,190 actorAccountId: actor.principal.accountId,
189 operation: sourceOps.create,191 operation: sourceOps.create,
190 });192 });
193
194 return synchronizedSource;
191 }),195 }),
192 ));196 ));
193 197
@@ -197,23 +201,25 @@ export const getSource = (input: { · 3 unmodified lines
197}) =>201}) =>
198 Effect.flatMap(ControlPlaneStore, (store) =>202 Effect.flatMap(ControlPlaneStore, (store) =>
199 Effect.flatMap(Actor, (actor) =>203 Effect.flatMap(Actor, (actor) =>
200 loadSourceById(store, {204 Effect.gen(function* () {
201 workspaceId: input.workspaceId,205 return yield* loadSourceById(store, {
202 sourceId: input.sourceId,206 workspaceId: input.workspaceId,
203 actorAccountId: actor.principal.accountId,207 sourceId: input.sourceId,
204 }).pipe(208 actorAccountId: actor.principal.accountId,
205 Effect.mapError((cause) =>209 }).pipe(
206 cause instanceof Error && cause.message.startsWith("Source not found:")210 Effect.mapError((cause) =>
207 ? sourceOps.get.notFound(211 cause instanceof Error && cause.message.startsWith("Source not found:")
208 "Source not found",212 ? sourceOps.get.notFound(
209 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,213 "Source not found",
210 )214 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
211 : sourceOps.get.unknownStorage(215 )
212 cause,216 : sourceOps.get.unknownStorage(
213 "Failed projecting stored source",217 cause,
218 "Failed projecting stored source",
219 ),
214 ),220 ),
215 ),221 );
216 ),222 }),
217 ));223 ));
218 224
219export const updateSource = (input: {225export const updateSource = (input: {
@@ -255,19 +261,21 @@ export const updateSource = (input: { · 35 unmodified lines
255 ),261 ),
256 );262 );
257 263
258 yield* mapPersistenceError(264 const persistedSource = yield* mapPersistenceError(
259 sourceOps.update.child("persist"),265 sourceOps.update.child("persist"),
260 persistSource(store, updatedSource, {266 persistSource(store, updatedSource, {
261 actorAccountId: actor.principal.accountId,267 actorAccountId: actor.principal.accountId,
262 }),268 }),
263 );269 );
264 270
265 return yield* syncArtifactsForSource({271 const synchronizedSource = yield* syncArtifactsForSource({
266 store,272 store,
267 source: updatedSource,273 source: persistedSource,
268 actorAccountId: actor.principal.accountId,274 actorAccountId: actor.principal.accountId,
269 operation: sourceOps.update,275 operation: sourceOps.update,
270 });276 });
277
278 return synchronizedSource;
271 }),279 }),
272 ));280 ));
273 281
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+2−0
@@ -589,6 +589,7 @@ const persistConnectedEchoTool = (input: { · 588 unmodified lines
589 const source: Source = {589 const source: Source = {
590 id: input.sourceId,590 id: input.sourceId,
591 workspaceId: input.workspaceId,591 workspaceId: input.workspaceId,
592 configKey: null,
592 name: "Counted MCP",593 name: "Counted MCP",
593 kind: "mcp",594 kind: "mcp",
594 endpoint: input.endpoint,595 endpoint: input.endpoint,
@@ -801,6 +802,7 @@ const persistConnectedGithubOpenApiSource = (input: { · 206 unmodified lines
801 const source: Source = {802 const source: Source = {
802 id: input.sourceId,803 id: input.sourceId,
803 workspaceId: input.workspaceId,804 workspaceId: input.workspaceId,
805 configKey: null,
804 name: "GitHub",806 name: "GitHub",
805 kind: "openapi",807 kind: "openapi",
806 endpoint: input.endpoint ?? "https://api.github.com",808 endpoint: input.endpoint ?? "https://api.github.com",
packages/control-plane/src/runtime/workspace-execution-environment.ts+104−51
@@ -37,8 +37,10 @@ import { · 36 unmodified lines
37 type RuntimeSourceAuthService,37 type RuntimeSourceAuthService,
38} from "./source-auth-service";38} from "./source-auth-service";
39import {39import {
40 loadSourceWithRecipe,
40 loadWorkspaceSourceRecipeToolByPath,41 loadWorkspaceSourceRecipeToolByPath,
41 loadWorkspaceSourceRecipeToolIndex,42 loadWorkspaceSourceRecipeToolIndex,
43 loadWorkspaceSourceRecipes,
42 type LoadedSourceRecipeToolIndexEntry,44 type LoadedSourceRecipeToolIndexEntry,
43} from "./source-recipes-runtime";45} from "./source-recipes-runtime";
44import { resolveSourceAuthMaterial } from "./source-auth-material";46import { resolveSourceAuthMaterial } from "./source-auth-material";
@@ -53,6 +55,14 @@ import { · 8 unmodified lines
53 evaluateInvocationPolicy,55 evaluateInvocationPolicy,
54 type InvocationDescriptor,56 type InvocationDescriptor,
55} from "./invocation-policy-engine";57} from "./invocation-policy-engine";
58import {
59 loadRuntimeLocalWorkspacePolicies,
60} from "./policies-operations";
61import {
62 getRuntimeLocalWorkspaceOption,
63 RuntimeLocalWorkspaceService,
64 type RuntimeLocalWorkspaceState,
65} from "./local-runtime-context";
56 66
57const asToolPath = (value: string): ToolPath => value as ToolPath;67const asToolPath = (value: string): ToolPath => value as ToolPath;
58 68
@@ -183,28 +193,40 @@ const loadWorkspaceSchemaBundle = (input: { · 124 unmodified lines
183 const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(193 const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(
184 input.id,194 input.id,
185 );195 );
186 if (Option.isNone(bundle)) {196 if (Option.isSome(bundle)) {
187 return null;197 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
188 }198 input.workspaceId,
189 199 );
190 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(200 if (
191 input.workspaceId,201 sourceRecords.some(
192 );202 (sourceRecord) =>
193 if (203 sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
194 !sourceRecords.some(204 )
195 (sourceRecord) =>205 ) {
196 sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,206 return {
197 )207 id: bundle.value.id,
198 ) {208 kind: bundle.value.bundleKind,
199 return null;209 hash: bundle.value.contentHash,
210 refsJson: bundle.value.refsJson,
211 };
212 }
200 }213 }
201 214
202 return {215 const recipes = yield* loadWorkspaceSourceRecipes({
203 id: bundle.value.id,216 rows: input.rows,
204 kind: bundle.value.bundleKind,217 workspaceId: input.workspaceId,
205 hash: bundle.value.contentHash,218 });
206 refsJson: bundle.value.refsJson,219 const localBundle = recipes
207 };220 .flatMap((recipe) => recipe.schemaBundles)
221 .find((schemaBundle) => schemaBundle.id === input.id);
222 return localBundle
223 ? {
224 id: localBundle.id,
225 kind: localBundle.bundleKind,
226 hash: localBundle.contentHash,
227 refsJson: localBundle.refsJson,
228 }
229 : null;
208 });230 });
209 231
210const scoreRecipeTool = (232const scoreRecipeTool = (
@@ -380,7 +402,7 @@ const authorizePersistedToolInvocation = (input: { · 169 unmodified lines
380 cause instanceof Error ? cause : new Error(String(cause)),402 cause instanceof Error ? cause : new Error(String(cause)),
381 ),403 ),
382 );404 );
383 const policies = Option.isSome(workspace)405 const storedPolicies = Option.isSome(workspace)
384 ? yield* input.rows.policies406 ? yield* input.rows.policies
385 .listForWorkspaceContext({407 .listForWorkspaceContext({
386 organizationId: workspace.value.organizationId,408 organizationId: workspace.value.organizationId,
@@ -392,6 +414,20 @@ const authorizePersistedToolInvocation = (input: { · 5 unmodified lines
392 ),414 ),
393 )415 )
394 : [];416 : [];
417 const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies({
418 store: input.rows,
419 workspaceId: input.workspaceId,
420 }).pipe(
421 Effect.mapError((cause) =>
422 cause instanceof Error ? cause : new Error(String(cause)),
423 ),
424 );
425 const policies = localWorkspacePolicies === null
426 ? storedPolicies
427 : [
428 ...storedPolicies.filter((policy) => policy.scopeType !== "workspace"),
429 ...localWorkspacePolicies.policies,
430 ];
395 431
396 const decision = evaluateInvocationPolicy({432 const decision = evaluateInvocationPolicy({
397 descriptor: input.descriptor,433 descriptor: input.descriptor,
@@ -471,14 +507,28 @@ const authorizePersistedToolInvocation = (input: { · 73 unmodified lines
471 }507 }
472 });508 });
473 509
510const provideRuntimeLocalWorkspace = <A, E, R>(
511 effect: Effect.Effect<A, E, R>,
512 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null,
513): Effect.Effect<A, E, R> =>
514 runtimeLocalWorkspace === null
515 ? effect
516 : effect.pipe(
517 Effect.provideService(
518 RuntimeLocalWorkspaceService,
519 runtimeLocalWorkspace,
520 ),
521 );
522
474const createWorkspaceToolCatalog = (input: {523const createWorkspaceToolCatalog = (input: {
475 workspaceId: Source["workspaceId"];524 workspaceId: Source["workspaceId"];
476 accountId: AccountId;525 accountId: AccountId;
477 rows: SqlControlPlaneRows;526 rows: SqlControlPlaneRows;
478 executorCatalog: ToolCatalog;527 executorCatalog: ToolCatalog;
528 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
479}): ToolCatalog => ({529}): ToolCatalog => ({
480 listNamespaces: ({ limit }) =>530 listNamespaces: ({ limit }) =>
481 Effect.gen(function* () {531 provideRuntimeLocalWorkspace(Effect.gen(function* () {
482 const [recipeTools, executor] = yield* Effect.all([532 const [recipeTools, executor] = yield* Effect.all([
483 loadWorkspaceRecipeTools({533 loadWorkspaceRecipeTools({
484 rows: input.rows,534 rows: input.rows,
@@ -513,10 +563,10 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines
513 return [...merged.values()]563 return [...merged.values()]
514 .sort((left, right) => left.namespace.localeCompare(right.namespace))564 .sort((left, right) => left.namespace.localeCompare(right.namespace))
515 .slice(0, limit);565 .slice(0, limit);
516 }),566 }), input.runtimeLocalWorkspace),
517 567
518 listTools: ({ namespace, query, limit, includeSchemas = false }) =>568 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
519 Effect.gen(function* () {569 provideRuntimeLocalWorkspace(Effect.gen(function* () {
520 const [recipeTools, executor] = yield* Effect.all([570 const [recipeTools, executor] = yield* Effect.all([
521 loadWorkspaceRecipeTools({571 loadWorkspaceRecipeTools({
522 rows: input.rows,572 rows: input.rows,
@@ -549,10 +599,10 @@ const createWorkspaceToolCatalog = (input: { · 26 unmodified lines
549 return [...recipeTools.map((tool) => tool.descriptor), ...executor]599 return [...recipeTools.map((tool) => tool.descriptor), ...executor]
550 .sort((left, right) => left.path.localeCompare(right.path))600 .sort((left, right) => left.path.localeCompare(right.path))
551 .slice(0, limit);601 .slice(0, limit);
552 }),602 }), input.runtimeLocalWorkspace),
553 603
554 getToolByPath: ({ path, includeSchemas }) =>604 getToolByPath: ({ path, includeSchemas }) =>
555 Effect.gen(function* () {605 provideRuntimeLocalWorkspace(Effect.gen(function* () {
556 const executor = yield* input.executorCatalog.getToolByPath({606 const executor = yield* input.executorCatalog.getToolByPath({
557 path,607 path,
558 includeSchemas,608 includeSchemas,
@@ -569,17 +619,17 @@ const createWorkspaceToolCatalog = (input: { · 10 unmodified lines
569 includeSchemas,619 includeSchemas,
570 });620 });
571 return recipeTool?.descriptor ?? null;621 return recipeTool?.descriptor ?? null;
572 }),622 }), input.runtimeLocalWorkspace),
573 623
574 getSchemaBundle: ({ id }) =>624 getSchemaBundle: ({ id }) =>
575 loadWorkspaceSchemaBundle({625 provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({
576 rows: input.rows,626 rows: input.rows,
577 workspaceId: input.workspaceId,627 workspaceId: input.workspaceId,
578 id: SourceRecipeSchemaBundleIdSchema.make(id),628 id: SourceRecipeSchemaBundleIdSchema.make(id),
579 }),629 }), input.runtimeLocalWorkspace),
580 630
581 searchTools: ({ query, namespace, limit }) =>631 searchTools: ({ query, namespace, limit }) =>
582 Effect.gen(function* () {632 provideRuntimeLocalWorkspace(Effect.gen(function* () {
583 const queryTokens = tokenize(query);633 const queryTokens = tokenize(query);
584 const [recipeTools, executor] = yield* Effect.all([634 const [recipeTools, executor] = yield* Effect.all([
585 loadWorkspaceRecipeTools({635 loadWorkspaceRecipeTools({
@@ -614,7 +664,7 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines
614 right.score - left.score || left.path.localeCompare(right.path),664 right.score - left.score || left.path.localeCompare(right.path),
615 )665 )
616 .slice(0, limit);666 .slice(0, limit);
617 }),667 }), input.runtimeLocalWorkspace),
618});668});
619 669
620const createWorkspaceToolInvoker = (input: {670const createWorkspaceToolInvoker = (input: {
@@ -623,6 +673,7 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
623 rows: SqlControlPlaneRows;673 rows: SqlControlPlaneRows;
624 resolveSecretMaterial: ResolveSecretMaterial;674 resolveSecretMaterial: ResolveSecretMaterial;
625 sourceAuthService: RuntimeSourceAuthService;675 sourceAuthService: RuntimeSourceAuthService;
676 runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
626 onElicitation?: Parameters<677 onElicitation?: Parameters<
627 typeof makeToolInvokerFromTools678 typeof makeToolInvokerFromTools
628 >[0]["onElicitation"];679 >[0]["onElicitation"];
@@ -634,6 +685,7 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines
634 workspaceId: input.workspaceId,685 workspaceId: input.workspaceId,
635 accountId: input.accountId,686 accountId: input.accountId,
636 sourceAuthService: input.sourceAuthService,687 sourceAuthService: input.sourceAuthService,
688 runtimeLocalWorkspace: input.runtimeLocalWorkspace,
637 });689 });
638 const executorCatalog = createToolCatalogFromTools({690 const executorCatalog = createToolCatalogFromTools({
639 tools: executorTools,691 tools: executorTools,
@@ -643,6 +695,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines
643 accountId: input.accountId,695 accountId: input.accountId,
644 rows: input.rows,696 rows: input.rows,
645 executorCatalog,697 executorCatalog,
698 runtimeLocalWorkspace: input.runtimeLocalWorkspace,
646 });699 });
647 const systemTools = createSystemToolMap({ catalog });700 const systemTools = createSystemToolMap({ catalog });
648 const systemToolPaths = new Set(Object.keys(systemTools));701 const systemToolPaths = new Set(Object.keys(systemTools));
@@ -661,7 +714,7 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines
661 args: unknown;714 args: unknown;
662 context?: Record<string, unknown>;715 context?: Record<string, unknown>;
663 }) =>716 }) =>
664 Effect.gen(function* () {717 provideRuntimeLocalWorkspace(Effect.gen(function* () {
665 const recipeTool = yield* loadWorkspaceRecipeToolByPath({718 const recipeTool = yield* loadWorkspaceRecipeToolByPath({
666 rows: input.rows,719 rows: input.rows,
667 workspaceId: input.workspaceId,720 workspaceId: input.workspaceId,
@@ -700,17 +753,12 @@ const createWorkspaceToolInvoker = (input: { · 32 unmodified lines
700 id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),753 id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),
701 })754 })
702 : null;755 : null;
703 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(756 const recipe = yield* loadSourceWithRecipe({
704 input.workspaceId,757 rows: input.rows,
705 recipeTool.source.id,758 workspaceId: input.workspaceId,
706 );759 sourceId: recipeTool.source.id,
707 let manifestJson: string | null = null;760 actorAccountId: input.accountId,
708 if (Option.isSome(sourceRecord)) {761 });
709 const revision = yield* input.rows.sourceRecipeRevisions.getById(
710 sourceRecord.value.recipeRevisionId,
711 );
712 manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null;
713 }
714 762
715 return yield* getSourceAdapterForOperation(recipeTool.operation)763 return yield* getSourceAdapterForOperation(recipeTool.operation)
716 .invokePersistedTool({764 .invokePersistedTool({
@@ -721,23 +769,26 @@ const createWorkspaceToolInvoker = (input: { · 4 unmodified lines
721 path: invocation.path,769 path: invocation.path,
722 operation: recipeTool.operation,770 operation: recipeTool.operation,
723 schemaBundle,771 schemaBundle,
724 manifestJson,772 manifestJson: recipe.revision.manifestJson,
725 auth,773 auth,
726 args: invocation.args,774 args: invocation.args,
727 context: invocation.context,775 context: invocation.context,
728 onElicitation: input.onElicitation,776 onElicitation: input.onElicitation,
729 });777 });
730 });778 }), input.runtimeLocalWorkspace);
731 779
732 return {780 return {
733 catalog,781 catalog,
734 toolInvoker: {782 toolInvoker: {
735 invoke: ({ path, args, context }) =>783 invoke: ({ path, args, context }) =>
736 systemToolPaths.has(path)784 provideRuntimeLocalWorkspace(
737 ? systemInvoker.invoke({ path, args, context })785 systemToolPaths.has(path)
738 : executorToolPaths.has(path)786 ? systemInvoker.invoke({ path, args, context })
739 ? executorInvoker.invoke({ path, args, context })787 : executorToolPaths.has(path)
740 : invokePersistedTool({ path, args, context }),788 ? executorInvoker.invoke({ path, args, context })
789 : invokePersistedTool({ path, args, context }),
790 input.runtimeLocalWorkspace,
791 ),
741 },792 },
742 };793 };
743};794};
@@ -754,13 +805,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 10 unmodified lines
754 });805 });
755 806
756 return ({ workspaceId, accountId, onElicitation }) =>807 return ({ workspaceId, accountId, onElicitation }) =>
757 Effect.sync(() => {808 Effect.gen(function* () {
809 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
758 const { catalog, toolInvoker } = createWorkspaceToolInvoker({810 const { catalog, toolInvoker } = createWorkspaceToolInvoker({
759 workspaceId,811 workspaceId,
760 accountId,812 accountId,
761 rows: input.rows,813 rows: input.rows,
762 resolveSecretMaterial,814 resolveSecretMaterial,
763 sourceAuthService: input.sourceAuthService,815 sourceAuthService: input.sourceAuthService,
816 runtimeLocalWorkspace,
764 onElicitation,817 onElicitation,
765 });818 });
766 819
packages/control-plane/src/schema/index.ts+1−0
@@ -13,6 +13,7 @@ export * from "./models/secret-material"; · 12 unmodified lines
13export * from "./models/source-auth-session";13export * from "./models/source-auth-session";
14export * from "./models/policy";14export * from "./models/policy";
15export * from "./models/local-installation";15export * from "./models/local-installation";
16export * from "./models/local-config";
16export * from "./models/execution";17export * from "./models/execution";
17export * from "./models/source-inspection";18export * from "./models/source-inspection";
18export * from "./models/source-discovery";19export * from "./models/source-discovery";
packages/control-plane/src/schema/models/local-config.ts+184−0
@@ -0,0 +1,184 @@
1import { Schema } from "effect";
2
3import { SourceTransportSchema, StringMapSchema } from "./source";
4
5export const LocalConfigSecretProviderSourceSchema = Schema.Literal(
6 "env",
7 "file",
8 "exec",
9);
10
11export const LocalConfigEnvSecretProviderSchema = Schema.Struct({
12 source: Schema.Literal("env"),
13});
14
15export const LocalConfigFileSecretProviderModeSchema = Schema.Literal(
16 "singleValue",
17 "json",
18);
19
20export const LocalConfigFileSecretProviderSchema = Schema.Struct({
21 source: Schema.Literal("file"),
22 path: Schema.String,
23 mode: Schema.optional(LocalConfigFileSecretProviderModeSchema),
24});
25
26export const LocalConfigExecSecretProviderSchema = Schema.Struct({
27 source: Schema.Literal("exec"),
28 command: Schema.String,
29 args: Schema.optional(Schema.Array(Schema.String)),
30 env: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })),
31 allowSymlinkCommand: Schema.optional(Schema.Boolean),
32 trustedDirs: Schema.optional(Schema.Array(Schema.String)),
33});
34
35export const LocalConfigSecretProviderSchema = Schema.Union(
36 LocalConfigEnvSecretProviderSchema,
37 LocalConfigFileSecretProviderSchema,
38 LocalConfigExecSecretProviderSchema,
39);
40
41export const LocalConfigExplicitSecretRefSchema = Schema.Struct({
42 source: LocalConfigSecretProviderSourceSchema,
43 provider: Schema.String,
44 id: Schema.String,
45});
46
47export const LocalConfigSecretInputSchema = Schema.Union(
48 Schema.String,
49 LocalConfigExplicitSecretRefSchema,
50);
51
52export const LocalConfigSourceConnectionSchema = Schema.Struct({
53 endpoint: Schema.String,
54 auth: Schema.optional(LocalConfigSecretInputSchema),
55});
56
57export const LocalConfigOpenApiBindingSchema = Schema.Struct({
58 specUrl: Schema.String,
59 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
60});
61
62export const LocalConfigGraphqlBindingSchema = Schema.Struct({
63 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
64});
65
66export const LocalConfigMcpBindingSchema = Schema.Struct({
67 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
68 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
69 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
70});
71
72export const LocalConfigGoogleDiscoveryBindingSchema = Schema.Struct({
73 service: Schema.String,
74 version: Schema.String,
75 discoveryUrl: Schema.optional(Schema.NullOr(Schema.String)),
76 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
77 scopes: Schema.optional(Schema.Array(Schema.String)),
78});
79
80const LocalConfigSourceEntryBaseSchema = Schema.Struct({
81 name: Schema.optional(Schema.String),
82 namespace: Schema.optional(Schema.String),
83 enabled: Schema.optional(Schema.Boolean),
84 connection: LocalConfigSourceConnectionSchema,
85});
86
87export const LocalConfigOpenApiSourceSchema = Schema.extend(
88 LocalConfigSourceEntryBaseSchema,
89 Schema.Struct({
90 kind: Schema.Literal("openapi"),
91 binding: LocalConfigOpenApiBindingSchema,
92 }),
93);
94
95export const LocalConfigGraphqlSourceSchema = Schema.extend(
96 LocalConfigSourceEntryBaseSchema,
97 Schema.Struct({
98 kind: Schema.Literal("graphql"),
99 binding: LocalConfigGraphqlBindingSchema,
100 }),
101);
102
103export const LocalConfigMcpSourceSchema = Schema.extend(
104 LocalConfigSourceEntryBaseSchema,
105 Schema.Struct({
106 kind: Schema.Literal("mcp"),
107 binding: LocalConfigMcpBindingSchema,
108 }),
109);
110
111export const LocalConfigGoogleDiscoverySourceSchema = Schema.extend(
112 LocalConfigSourceEntryBaseSchema,
113 Schema.Struct({
114 kind: Schema.Literal("google_discovery"),
115 binding: LocalConfigGoogleDiscoveryBindingSchema,
116 }),
117);
118
119export const LocalConfigSourceSchema = Schema.Union(
120 LocalConfigOpenApiSourceSchema,
121 LocalConfigGraphqlSourceSchema,
122 LocalConfigMcpSourceSchema,
123 LocalConfigGoogleDiscoverySourceSchema,
124);
125
126export const LocalConfigPolicyActionSchema = Schema.Literal("allow", "deny");
127export const LocalConfigPolicyApprovalSchema = Schema.Literal("auto", "manual");
128
129export const LocalConfigPolicySchema = Schema.Struct({
130 match: Schema.String,
131 action: LocalConfigPolicyActionSchema,
132 approval: LocalConfigPolicyApprovalSchema,
133 enabled: Schema.optional(Schema.Boolean),
134 priority: Schema.optional(Schema.Number),
135});
136
137export const LocalConfigSecretsSchema = Schema.Struct({
138 providers: Schema.optional(
139 Schema.Record({
140 key: Schema.String,
141 value: LocalConfigSecretProviderSchema,
142 }),
143 ),
144 defaults: Schema.optional(
145 Schema.Struct({
146 env: Schema.optional(Schema.String),
147 file: Schema.optional(Schema.String),
148 exec: Schema.optional(Schema.String),
149 }),
150 ),
151});
152
153export const LocalConfigWorkspaceSchema = Schema.Struct({
154 name: Schema.optional(Schema.String),
155});
156
157export const LocalExecutorConfigSchema = Schema.Struct({
158 workspace: Schema.optional(LocalConfigWorkspaceSchema),
159 sources: Schema.optional(
160 Schema.Record({
161 key: Schema.String,
162 value: LocalConfigSourceSchema,
163 }),
164 ),
165 policies: Schema.optional(
166 Schema.Record({
167 key: Schema.String,
168 value: LocalConfigPolicySchema,
169 }),
170 ),
171 secrets: Schema.optional(LocalConfigSecretsSchema),
172});
173
174export type LocalConfigSecretProviderSource =
175 typeof LocalConfigSecretProviderSourceSchema.Type;
176export type LocalConfigSecretProvider =
177 typeof LocalConfigSecretProviderSchema.Type;
178export type LocalConfigExplicitSecretRef =
179 typeof LocalConfigExplicitSecretRefSchema.Type;
180export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type;
181export type LocalConfigSource = typeof LocalConfigSourceSchema.Type;
182export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type;
183export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type;
184export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type;
packages/control-plane/src/schema/models/policy.ts+51−10
@@ -1,7 +1,5 @@
1import {1import {
2 createInsertSchema,
3 createSelectSchema,2 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";3} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";4import { Schema } from "effect";
7 5
@@ -56,18 +54,61 @@ const policySchemaOverrides = { · 48 unmodified lines
56 updatedAt: TimestampMsSchema,54 updatedAt: TimestampMsSchema,
57} as const;55} as const;
58 56
59export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides);57const PolicyStorageSchema = createSelectSchema(policiesTable, policySchemaOverrides);
60 58
61export const PolicyInsertSchema = createInsertSchema(59const PolicyTypeSchema = Schema.Struct({
62 policiesTable,60 id: PolicyIdSchema,
63 policySchemaOverrides,61 configKey: Schema.NullOr(Schema.String),
64);62 scopeType: PolicyScopeTypeSchema,
63 organizationId: OrganizationIdSchema,
64 workspaceId: Schema.NullOr(WorkspaceIdSchema),
65 targetAccountId: Schema.NullOr(AccountIdSchema),
66 clientId: Schema.NullOr(Schema.String),
67 resourceType: PolicyResourceTypeSchema,
68 resourcePattern: Schema.String,
69 matchType: PolicyMatchTypeSchema,
70 effect: PolicyEffectSchema,
71 approvalMode: PolicyApprovalModeSchema,
72 argumentConditionsJson: Schema.NullOr(Schema.String),
73 priority: Schema.Number,
74 enabled: Schema.Boolean,
75 createdAt: TimestampMsSchema,
76 updatedAt: TimestampMsSchema,
77});
65 78
66export const PolicyUpdateSchema = createUpdateSchema(79export const PolicySchema = Schema.transform(
67 policiesTable,80 PolicyStorageSchema,
68 policySchemaOverrides,81 PolicyTypeSchema,
82 {
83 strict: false,
84 decode: (row, _input) => ({
85 ...row,
86 configKey: null,
87 }),
88 encode: (policy, _output) => ({
89 id: policy.id,
90 scopeType: policy.scopeType,
91 organizationId: policy.organizationId,
92 workspaceId: policy.workspaceId,
93 targetAccountId: policy.targetAccountId,
94 clientId: policy.clientId,
95 resourceType: policy.resourceType,
96 resourcePattern: policy.resourcePattern,
97 matchType: policy.matchType,
98 effect: policy.effect,
99 approvalMode: policy.approvalMode,
100 argumentConditionsJson: policy.argumentConditionsJson,
101 priority: policy.priority,
102 enabled: policy.enabled,
103 createdAt: policy.createdAt,
104 updatedAt: policy.updatedAt,
105 }),
106 },
69);107);
70 108
109export const PolicyInsertSchema = PolicyTypeSchema;
110export const PolicyUpdateSchema = Schema.partial(PolicyTypeSchema);
111
71export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type;112export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type;
72export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type;113export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type;
73export type PolicyEffect = typeof PolicyEffectSchema.Type;114export type PolicyEffect = typeof PolicyEffectSchema.Type;
packages/control-plane/src/schema/models/source.ts+3−0
@@ -97,6 +97,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 96 unmodified lines
97 Schema.Struct({97 Schema.Struct({
98 id: SourceIdSchema,98 id: SourceIdSchema,
99 workspaceId: WorkspaceIdSchema,99 workspaceId: WorkspaceIdSchema,
100 configKey: Schema.NullOr(Schema.String),
100 recipeId: SourceRecipeIdSchema,101 recipeId: SourceRecipeIdSchema,
101 recipeRevisionId: SourceRecipeRevisionIdSchema,102 recipeRevisionId: SourceRecipeRevisionIdSchema,
102 name: Schema.String,103 name: Schema.String,
@@ -117,6 +118,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 14 unmodified lines
117 decode: (row, _input) => ({118 decode: (row, _input) => ({
118 id: row.sourceId,119 id: row.sourceId,
119 workspaceId: row.workspaceId,120 workspaceId: row.workspaceId,
121 configKey: null,
120 recipeId: row.recipeId,122 recipeId: row.recipeId,
121 recipeRevisionId: row.recipeRevisionId,123 recipeRevisionId: row.recipeRevisionId,
122 name: row.name,124 name: row.name,
@@ -156,6 +158,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 33 unmodified lines
156export const SourceSchema = Schema.Struct({158export const SourceSchema = Schema.Struct({
157 id: SourceIdSchema,159 id: SourceIdSchema,
158 workspaceId: WorkspaceIdSchema,160 workspaceId: WorkspaceIdSchema,
161 configKey: Schema.NullOr(Schema.String),
159 name: Schema.String,162 name: Schema.String,
160 kind: SourceKindSchema,163 kind: SourceKindSchema,
161 endpoint: Schema.String,164 endpoint: Schema.String,
packages/kitchen-sink/src/source-runtime.test.ts+1−0
@@ -492,6 +492,7 @@ const openApiSource = (input: { · 491 unmodified lines
492}): Source => ({492}): Source => ({
493 id: input.sourceId,493 id: input.sourceId,
494 workspaceId: input.workspaceId,494 workspaceId: input.workspaceId,
495 configKey: null,
495 name: input.displayName,496 name: input.displayName,
496 kind: "openapi",497 kind: "openapi",
497 endpoint: input.baseUrl,498 endpoint: input.baseUrl,
packages/react/src/index.test.tsx+7−1
@@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 94 unmodified lines
95 95
96const startControlPlaneServer = async (): Promise<ApiServer> => {96const startControlPlaneServer = async (): Promise<ApiServer> => {
97 const runtime = await Effect.runPromise(97 const runtime = await Effect.runPromise(
98 createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),98 createSqlControlPlaneRuntime({
99 localDataDir: ":memory:",
100 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")),
101 }),
99 );102 );
100 const scope = await Effect.runPromise(Scope.make());103 const scope = await Effect.runPromise(Scope.make());
101 104
@@ -743,3 +746,6 @@ describe("executor-react source hooks", () => { · 641 unmodified lines
743 }),746 }),
744 );747 );
745});748});
749import { mkdtempSync } from "node:fs";
750import { tmpdir } from "node:os";
751import { join } from "node:path";
packages/react/src/index.ts+1−0
@@ -661,6 +661,7 @@ const createOptimisticSource = (input: { · 660 unmodified lines
661 return {661 return {
662 id: `src_optimistic_${crypto.randomUUID()}` as Source["id"],662 id: `src_optimistic_${crypto.randomUUID()}` as Source["id"],
663 workspaceId: input.workspaceId,663 workspaceId: input.workspaceId,
664 configKey: null,
664 name: input.payload.name,665 name: input.payload.name,
665 kind: input.payload.kind,666 kind: input.payload.kind,
666 endpoint: input.payload.endpoint,667 endpoint: input.payload.endpoint,
packages/runtime-ses/src/index.ts+0−1
@@ -237,4 +237,3 @@ export const makeSesExecutor = ( · 236 unmodified lines
237): CodeExecutor => ({237): CodeExecutor => ({
238 execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker),238 execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker),
239});239});
240
packages/runtime-ses/src/sandbox-worker.mjs+9−1
@@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines
24 24
25const formatLogLine = (args) => args.map(formatLogArg).join(" ");25const formatLogLine = (args) => args.map(formatLogArg).join(" ");
26 26
27const toSerializableValue = (value) => {
28 if (typeof value === "undefined") {
29 return undefined;
30 }
31
32 return JSON.parse(JSON.stringify(value));
33};
34
27const buildExecutionSource = (code) => {35const buildExecutionSource = (code) => {
28 const trimmed = code.trim();36 const trimmed = code.trim();
29 const looksLikeArrowFunction =37 const looksLikeArrowFunction =
@@ -157,7 +165,7 @@ process.on("message", async (message) => { · 127 unmodified lines
157 process.send?.({165 process.send?.({
158 type: "result",166 type: "result",
159 id: message.id,167 id: message.id,
160 value: result,168 value: toSerializableValue(result),
161 logs,169 logs,
162 });170 });
163 } catch (error) {171 } catch (error) {
packages/server/src/index.ts+2−0
@@ -68,6 +68,7 @@ export type StartLocalExecutorServerOptions = { · 67 unmodified lines
68 readonly port?: number;68 readonly port?: number;
69 readonly host?: string;69 readonly host?: string;
70 readonly localDataDir?: string;70 readonly localDataDir?: string;
71 readonly workspaceRoot?: string;
71 readonly migrationsFolder?: string;72 readonly migrationsFolder?: string;
72 readonly pidFile?: string;73 readonly pidFile?: string;
73 readonly executionResolver?: ResolveExecutionEnvironment;74 readonly executionResolver?: ResolveExecutionEnvironment;
@@ -100,6 +101,7 @@ const createRuntime = ( · 26 unmodified lines
100 createSqlControlPlaneRuntime({101 createSqlControlPlaneRuntime({
101 localDataDir,102 localDataDir,
102 migrationsFolder: options.migrationsFolder,103 migrationsFolder: options.migrationsFolder,
104 workspaceRoot: options.workspaceRoot,
103 executionResolver: options.executionResolver,105 executionResolver: options.executionResolver,
104 resolveSecretMaterial: options.resolveSecretMaterial,106 resolveSecretMaterial: options.resolveSecretMaterial,
105 getLocalServerBaseUrl,107 getLocalServerBaseUrl,