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
100100 const distDir = join(tempRoot, "dist");
101101 const homeDir = join(tempRoot, "home");
102102 const binDir = join(homeDir, ".local", "bin");
103− const installHome = join(homeDir, ".local", "share", "executor");
103+ const installHome = process.platform === "darwin"
104+ ? join(homeDir, "Library", "Application Support", "Executor")
105+ : join(homeDir, ".local", "share", "executor");
106+ const runHome = process.platform === "darwin"
107+ ? join(installHome, "run")
108+ : join(homeDir, ".local", "state", "executor", "run");
104109 const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor");
105110 const baseUrl = `http://127.0.0.1:${await allocatePort()}`;
106111
@@ -176,9 +181,9 @@ describe("portable distribution flow", () => { · 69 unmodified lines
176181 expect(status.reachable).toBe(true);
177182 expect(status.pidRunning).toBe(true);
178183 expect(status.installation).not.toBeNull();
179− expect(status.logFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true);
184+ expect(status.logFile.startsWith(runHome)).toBe(true);
180185 expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true);
181− expect(status.pidFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true);
186+ expect(status.pidFile.startsWith(runHome)).toBe(true);
182187
183188 const html = await fetch(new URL("/", baseUrl));
184189 expect(html.status).toBe(200);
apps/executor/src/server/server.test.ts+192−169
@@ -1,5 +1,7 @@
11 import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2−import { randomUUID } from "node:crypto";
2+import { createHash, randomUUID } from "node:crypto";
3+import { mkdir, writeFile } from "node:fs/promises";
4+import { join } from "node:path";
35
46 import {
57 HttpApi,
@@ -7,7 +9,9 @@ import { · 1 unmodified line
79 HttpApiGroup,
810 HttpApiSchema,
911 OpenApi,
12+ FileSystem,
1013 } from "@effect/platform";
14+import { NodeFileSystem } from "@effect/platform-node";
1115 import { describe, expect, it } from "@effect/vitest";
1216 import { assertTrue } from "@effect/vitest/utils";
1317 import * as Effect from "effect/Effect";
@@ -57,7 +61,160 @@ const executionResolver: ResolveExecutionEnvironment = () => · 43 unmodified lines
5761 }),
5862 });
5963
60−const makeServer = createLocalExecutorServer({
64+const makeTempWorkspaceRoot = () =>
65+ FileSystem.FileSystem.pipe(
66+ Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "executor-server-test-" })),
67+ Effect.provide(NodeFileSystem.layer),
68+ );
69+
70+const 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+
85+const 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+
96+const 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+
217+const makeServer = createIsolatedLocalExecutorServer({
61218 port: 0,
62219 localDataDir: ":memory:",
63220 executionResolver,
@@ -578,28 +735,6 @@ const extractUrlInteractionUrl = (payloadJson: string): string => { · 514 unmodified lines
578735 return url;
579736 };
580737
581−const 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−
603738 describe("local-executor-server", () => {
604739 it.scoped("serves the control-plane API and executes code", () =>
605740 Effect.gen(function* () {
@@ -662,7 +797,7 @@ describe("local-executor-server", () => { · 56 unmodified lines
662797
663798 it.scoped("serves only execute over MCP when elicitation is supported", () =>
664799 Effect.gen(function* () {
665− const server = yield* createLocalExecutorServer({
800+ const server = yield* createIsolatedLocalExecutorServer({
666801 port: 0,
667802 localDataDir: ":memory:",
668803 executionResolver: gatedExecutionResolver,
@@ -719,7 +854,7 @@ describe("local-executor-server", () => { · 50 unmodified lines
719854
720855 it.scoped("serves execute and resume over MCP when elicitation is unavailable", () =>
721856 Effect.gen(function* () {
722− const server = yield* createLocalExecutorServer({
857+ const server = yield* createIsolatedLocalExecutorServer({
723858 port: 0,
724859 localDataDir: ":memory:",
725860 executionResolver: gatedExecutionResolver,
@@ -798,7 +933,7 @@ describe("local-executor-server", () => { · 72 unmodified lines
798933 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
799934 );
800935
801− const server = yield* createLocalExecutorServer({
936+ const server = yield* createIsolatedLocalExecutorServer({
802937 port: 0,
803938 localDataDir: ":memory:",
804939 });
@@ -879,7 +1014,7 @@ describe("local-executor-server", () => { · 74 unmodified lines
8791014 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
8801015 );
8811016
882− const server = yield* createLocalExecutorServer({
1017+ const server = yield* createIsolatedLocalExecutorServer({
8831018 port: 0,
8841019 localDataDir: ":memory:",
8851020 });
@@ -957,7 +1092,7 @@ describe("local-executor-server", () => { · 71 unmodified lines
9571092 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
9581093 );
9591094
960− const server = yield* createLocalExecutorServer({
1095+ const server = yield* createIsolatedLocalExecutorServer({
9611096 port: 0,
9621097 localDataDir: ":memory:",
9631098 });
@@ -1046,7 +1181,7 @@ describe("local-executor-server", () => { · 82 unmodified lines
10461181 }),
10471182 );
10481183
1049− const server = yield* createLocalExecutorServer({
1184+ const server = yield* createIsolatedLocalExecutorServer({
10501185 port: 0,
10511186 localDataDir: ":memory:",
10521187 });
@@ -1092,7 +1227,7 @@ describe("local-executor-server", () => { · 39 unmodified lines
10921227 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
10931228 );
10941229
1095− const server = yield* createLocalExecutorServer({
1230+ const server = yield* createIsolatedLocalExecutorServer({
10961231 port: 0,
10971232 localDataDir: ":memory:",
10981233 });
@@ -1129,29 +1264,28 @@ describe("local-executor-server", () => { · 30 unmodified lines
11291264 }),
11301265 );
11311266 assertTrue(callbackResponse.ok);
1132− expect(yield* Effect.promise(() => callbackResponse.text())).toContain("Source connected:");
1133−
1134− const completed = yield* waitForExecutionCompletion({
1135− client,
1136− workspaceId: installation.workspaceId,
1137− executionId: added.execution.id,
1138− });
1139−
1140− expect(completed.execution.status).toBe("completed");
1141− expect(completed.pendingInteraction).toBeNull();
1142− expect(completed.execution.resultJson).toContain('"name":"Axiom"');
1143− expect(completed.execution.resultJson).toContain('"status":"connected"');
1267+ const callbackText = yield* Effect.promise(() => callbackResponse.text());
1268+ expect(callbackText).toContain("Source connected:");
1269+
1270+ const connectedSource = yield* Effect.gen(function* () {
1271+ while (true) {
1272+ const sources = yield* client.sources.list({
1273+ path: {
1274+ workspaceId: installation.workspaceId,
1275+ },
1276+ });
1277+ const source = sources.find((entry) => entry.namespace === "axiom");
1278+ if (source?.status === "connected" && source.auth.kind === "oauth2") {
1279+ return source;
1280+ }
11441281
1145− const sources = yield* client.sources.list({
1146− path: {
1147− workspaceId: installation.workspaceId,
1148− },
1282+ yield* Effect.sleep("100 millis");
1283+ }
11491284 });
11501285
1151− expect(sources).toHaveLength(1);
1152− expect(sources[0]?.name).toBe("Axiom");
1153− expect(sources[0]?.status).toBe("connected");
1154− expect(sources[0]?.auth.kind).toBe("oauth2");
1286+ expect(connectedSource.name).toBe("Axiom");
1287+ expect(connectedSource.status).toBe("connected");
1288+ expect(connectedSource.auth.kind).toBe("oauth2");
11551289
11561290 const toolCall = yield* client.executions.create({
11571291 path: {
@@ -1175,7 +1309,7 @@ describe("local-executor-server", () => { · 17 unmodified lines
11751309 Effect.promise(() => startMutatingOpenApiDemoServer()),
11761310 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
11771311 );
1178− const server = yield* createLocalExecutorServer({
1312+ const server = yield* createIsolatedLocalExecutorServer({
11791313 port: 0,
11801314 localDataDir: ":memory:",
11811315 });
@@ -1277,7 +1411,7 @@ describe("local-executor-server", () => { · 95 unmodified lines
12771411 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
12781412 );
12791413
1280− const server = yield* createLocalExecutorServer({
1414+ const server = yield* createIsolatedLocalExecutorServer({
12811415 port: 0,
12821416 localDataDir: ":memory:",
12831417 });
@@ -1345,7 +1479,7 @@ describe("local-executor-server", () => { · 61 unmodified lines
13451479
13461480 it.scoped("marks execution failed when a configured MCP endpoint is invalid", () =>
13471481 Effect.gen(function* () {
1348− const server = yield* createLocalExecutorServer({
1482+ const server = yield* createIsolatedLocalExecutorServer({
13491483 port: 0,
13501484 localDataDir: ":memory:",
13511485 });
@@ -1358,123 +1492,12 @@ describe("local-executor-server", () => { · 6 unmodified lines
13581492 baseUrl: server.baseUrl,
13591493 accountId: installation.accountId,
13601494 });
1361−
1362− const sourceId = SourceIdSchema.make("src_invalid_mcp_endpoint");
1363− const recipeId = SourceRecipeIdSchema.make(`src_recipe_${sourceId}`);
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",
1495+ yield* writeConfiguredLocalMcpSource({
1496+ workspaceRoot: server.workspaceRoot,
1497+ configKey: "demo",
14211498 endpoint: "http://127.0.0.1:PORT/mcp",
1422− status: "connected",
1423− enabled: true,
1499+ name: "Demo",
14241500 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− }],
14781501 });
14791502
14801503 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
140140 yield* input.persistence.rows.sources.insert({
141141 id: input.sourceId,
142142 workspaceId: input.workspaceId,
143+ configKey: null,
143144 recipeId,
144145 recipeRevisionId,
145146 name: "Github",
@@ -277,6 +278,7 @@ describe("control-plane-persistence-drizzle", () => { · 131 unmodified lines
277278 yield* persistence.rows.sources.insert({
278279 id: SourceIdSchema.make("src_1"),
279280 workspaceId,
281+ configKey: null,
280282 recipeId: SourceRecipeIdSchema.make("src_recipe_1"),
281283 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"),
282284 name: "Github",
@@ -295,6 +297,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
295297
296298 yield* persistence.rows.policies.insert({
297299 id: PolicyIdSchema.make("pol_1"),
300+ configKey: null,
298301 scopeType: "workspace",
299302 organizationId,
300303 workspaceId,
@@ -860,6 +863,7 @@ describe("control-plane-persistence-drizzle", () => { · 559 unmodified lines
860863 yield* persistence.rows.sources.insert({
861864 id: SourceIdSchema.make("src_shared_removed"),
862865 workspaceId: removedWorkspaceId,
866+ configKey: null,
863867 recipeId: sharedRecipeId,
864868 recipeRevisionId: sharedRecipeRevisionId,
865869 name: "Github Removed",
@@ -878,6 +882,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
878882 yield* persistence.rows.sources.insert({
879883 id: SourceIdSchema.make("src_shared_remaining"),
880884 workspaceId: remainingWorkspaceId,
885+ configKey: null,
881886 recipeId: sharedRecipeId,
882887 recipeRevisionId: sharedRecipeRevisionId,
883888 name: "Github Remaining",
packages/control-plane/src/persistence/post-migrations.test.ts+38−18
@@ -188,6 +188,7 @@ const seedMigratedSourceRecipe = (input: { · 187 unmodified lines
188188 yield* input.persistence.rows.sources.insert({
189189 id: input.sourceId,
190190 workspaceId: input.workspaceId,
191+ configKey: null,
191192 recipeId,
192193 recipeRevisionId,
193194 name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",
@@ -823,24 +824,43 @@ describe("code-migrations", () => { · 629 unmodified lines
823824 createdAt: now,
824825 updatedAt: now,
825826 })));
826− await Effect.runPromise(legacyPersistence.rows.sources.insert({
827− id: sourceId,
828− workspaceId,
829− recipeId,
830− recipeRevisionId,
831− name: "Legacy OpenAPI",
832− kind: "openapi",
833− endpoint: "https://api.example.com",
834− status: "connected",
835− enabled: true,
836− namespace: "legacy.openapi",
837− importAuthPolicy: "reuse_runtime",
838− bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"),
839− sourceHash: null,
840− lastError: null,
841− createdAt: now,
842− updatedAt: now,
843− }));
827+ await legacyPersistence.db.execute(sql`
828+ INSERT INTO "sources" (
829+ "source_id",
830+ "workspace_id",
831+ "recipe_id",
832+ "recipe_revision_id",
833+ "name",
834+ "kind",
835+ "endpoint",
836+ "status",
837+ "enabled",
838+ "namespace",
839+ "import_auth_policy",
840+ "binding_config_json",
841+ "source_hash",
842+ "last_error",
843+ "created_at",
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+ `);
844864 await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({
845865 id: authArtifactId as any,
846866 workspaceId,
packages/control-plane/src/persistence/repos/policies-repo.ts+4−2
@@ -86,7 +86,8 @@ export const createPoliciesRepo = ( · 85 unmodified lines
8686
8787 insert: (policy: Policy) =>
8888 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);
9091 }),
9192
9293 update: (
@@ -94,9 +95,10 @@ export const createPoliciesRepo = ( · 1 unmodified line
9495 patch: Partial<Omit<Policy, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">>,
9596 ) =>
9697 client.use("rows.policies.update", async (db) => {
98+ const { configKey: _configKey, ...rowPatch } = patch;
9799 const rows = await db
98100 .update(tables.policiesTable)
99− .set(patch)
101+ .set(rowPatch)
100102 .where(eq(tables.policiesTable.id, policyId))
101103 .returning();
102104
packages/control-plane/src/runtime/control-plane-runtime.test.ts+117−47
@@ -1,4 +1,7 @@
11 import { createServer } from "node:http";
2+import { mkdtempSync, readFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
25
36 import { describe, expect, it } from "@effect/vitest";
47 import { assertTrue } from "@effect/vitest/utils";
@@ -12,8 +15,6 @@ import { · 7 unmodified lines
1215 ExecutionInteractionIdSchema,
1316 SecretMaterialIdSchema,
1417 SourceIdSchema,
15− SourceRecipeIdSchema,
16− SourceRecipeRevisionIdSchema,
1718 } from "#schema";
1819 import type { ToolPath } from "@executor/codemode-core";
1920
@@ -21,24 +22,19 @@ import { · 1 unmodified line
2122 createSqlControlPlaneRuntime,
2223 LiveExecutionManagerService,
2324 } from "./index";
25+import { createSourceFromPayload } from "./source-definitions";
2426 import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
27+import { persistSource } from "./source-store";
2528 import { withControlPlaneClient } from "./test-http-client";
2629
2730 const makeRuntime = Effect.acquireRelease(
28− createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),
31+ createSqlControlPlaneRuntime({
32+ localDataDir: ":memory:",
33+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")),
34+ }),
2935 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
3036 );
3137
32−const openApiBindingConfigJson = (specUrl: string): string =>
33− JSON.stringify({
34− adapterKey: "openapi",
35− version: 1,
36− payload: {
37− specUrl,
38− defaultHeaders: null,
39− },
40− });
41−
4238 type OpenApiSpecServer = {
4339 baseUrl: string;
4440 specUrl: string;
@@ -123,6 +119,64 @@ const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => · 78 unmodified lines
123119 );
124120
125121 describe("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+
126180 it.scoped("supports full CRUD flow over HTTP API", () =>
127181 Effect.gen(function* () {
128182 const runtime = yield* makeRuntime;
@@ -293,24 +347,32 @@ describe("control-plane-runtime", () => { · 164 unmodified lines
293347 updatedAt: now,
294348 });
295349
296− yield* runtime.persistence.rows.sources.insert({
297− id: sourceId,
350+ const localSource = yield* createSourceFromPayload({
298351 workspaceId: installation.workspaceId,
299− recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`),
300− recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`),
301− name: "GitHub",
302− kind: "openapi",
303− endpoint: "https://api.github.com",
304− status: "auth_required",
305− enabled: true,
306− namespace: "github",
307− importAuthPolicy: "reuse_runtime",
308− bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
309− sourceHash: null,
310− lastError: null,
311− createdAt: now,
312− updatedAt: now,
313− });
352+ sourceId,
353+ payload: {
354+ name: "GitHub",
355+ kind: "openapi",
356+ endpoint: "https://api.github.com",
357+ status: "auth_required",
358+ enabled: true,
359+ namespace: "github",
360+ importAuthPolicy: "reuse_runtime",
361+ binding: {
362+ specUrl: "https://example.com/github-openapi.yaml",
363+ defaultHeaders: null,
364+ },
365+ importAuth: { kind: "none" },
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+ );
314376
315377 const interactionFiber = yield* Effect.gen(function* () {
316378 const liveExecutionManager = yield* LiveExecutionManagerService;
@@ -448,24 +510,32 @@ describe("control-plane-runtime", () => { · 131 unmodified lines
448510 updatedAt: now,
449511 });
450512
451− yield* runtime.persistence.rows.sources.insert({
452− id: sourceId,
513+ const localSource = yield* createSourceFromPayload({
453514 workspaceId: installation.workspaceId,
454− recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`),
455− recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`),
456− name: "GitHub",
457− kind: "openapi",
458− endpoint: "https://api.github.com",
459− status: "auth_required",
460− enabled: true,
461− namespace: "github",
462− importAuthPolicy: "reuse_runtime",
463− bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
464− sourceHash: null,
465− lastError: null,
466− createdAt: now,
467− updatedAt: now,
468− });
515+ sourceId,
516+ payload: {
517+ name: "GitHub",
518+ kind: "openapi",
519+ endpoint: "https://api.github.com",
520+ status: "auth_required",
521+ enabled: true,
522+ namespace: "github",
523+ importAuthPolicy: "reuse_runtime",
524+ binding: {
525+ specUrl: "https://example.com/github-openapi.yaml",
526+ defaultHeaders: null,
527+ },
528+ importAuth: { kind: "none" },
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+ );
469539
470540 const interactionFiber = yield* Effect.gen(function* () {
471541 const liveExecutionManager = yield* LiveExecutionManagerService;
packages/control-plane/src/runtime/execution-http.test.ts+4−0
@@ -1,3 +1,6 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
14 import { describe, expect, it } from "@effect/vitest";
25 import * as Effect from "effect/Effect";
36 import * as Schema from "effect/Schema";
@@ -39,6 +42,7 @@ const makeExecutionResolver = () => { · 35 unmodified lines
3942 const makeRuntime = Effect.acquireRelease(
4043 createSqlControlPlaneRuntime({
4144 localDataDir: ":memory:",
45+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")),
4246 executionResolver: makeExecutionResolver(),
4347 }),
4448 (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
55 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
66 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
77 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8+import { mkdtempSync } from "node:fs";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
811 import { describe, expect, it } from "@effect/vitest";
912 import { assertTrue } from "@effect/vitest/utils";
1013 import * as Effect from "effect/Effect";
@@ -283,6 +286,7 @@ describe("execution-mcp-resume", () => { · 272 unmodified lines
283286 const runtime = yield* Effect.acquireRelease(
284287 createSqlControlPlaneRuntime({
285288 localDataDir: ":memory:",
289+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")),
286290 executionResolver: makeMcpExecutionResolver(mcpServer.endpoint),
287291 }),
288292 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
packages/control-plane/src/runtime/execution-service.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
22 ControlPlaneBadRequestError,
33 ControlPlaneNotFoundError,
44 ControlPlaneStorageError,
5−} from "#api";
5+} from "../api/errors";
66 import type {
77 CreateExecutionPayload,
88 ResumeExecutionPayload,
packages/control-plane/src/runtime/executor-tools.ts+29−5
@@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines
1919 import * as Cause from "effect/Cause";
2020 import * as Exit from "effect/Exit";
2121 import * as Schema from "effect/Schema";
22+import type { RuntimeLocalWorkspaceState } from "./local-runtime-context";
23+import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
2224
2325 /** Run an Effect as a Promise, preserving the original error (not FiberFailure). */
24−const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => {
25− const exit = await Effect.runPromiseExit(effect);
26+const runEffect = async <A>(
27+ effect: Effect.Effect<A, unknown, never>,
28+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null = null,
29+): Promise<A> => {
30+ const provided =
31+ runtimeLocalWorkspace === null
32+ ? effect
33+ : effect.pipe(
34+ Effect.provideService(
35+ RuntimeLocalWorkspaceService,
36+ runtimeLocalWorkspace,
37+ ),
38+ );
39+ const exit = await Effect.runPromiseExit(provided);
2640 if (Exit.isSuccess(exit)) return exit.value;
2741 throw Cause.squash(exit.cause);
2842 };
@@ -106,6 +120,9 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 77 unmodified lines
106120 return trimmed.length > 0 ? trimmed : null;
107121 };
108122
123+const toSerializableValue = <A>(value: A): A =>
124+ JSON.parse(JSON.stringify(value)) as A;
125+
109126 const resolveLocalCredentialUrl = (input: {
110127 baseUrl: string;
111128 workspaceId: WorkspaceId;
@@ -205,6 +222,7 @@ export const createExecutorToolMap = (input: { · 93 unmodified lines
205222 workspaceId: WorkspaceId;
206223 accountId: AccountId;
207224 sourceAuthService: RuntimeSourceAuthService;
225+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
208226 }): ToolMap => ({
209227 "executor.sources.add": toTool({
210228 tool: {
@@ -281,10 +299,11 @@ export const createExecutorToolMap = (input: { · 70 unmodified lines
281299 }
282300 : undefined,
283301 ),
302+ input.runtimeLocalWorkspace,
284303 );
285304
286305 if (result.kind === "connected") {
287− return result.source;
306+ return toSerializableValue(result.source);
288307 }
289308
290309 if (result.kind === "credential_required") {
@@ -313,6 +332,7 @@ export const createExecutorToolMap = (input: { · 22 unmodified lines
313332 invocation: context?.invocation,
314333 onElicitation: context?.onElicitation,
315334 }),
335+ input.runtimeLocalWorkspace,
316336 );
317337
318338 pendingArgs = pendingResult.credentialSlot === "import"
@@ -342,10 +362,11 @@ export const createExecutorToolMap = (input: { · 23 unmodified lines
342362 }
343363 : undefined,
344364 ),
365+ input.runtimeLocalWorkspace,
345366 );
346367
347368 if (completed.kind === "connected") {
348− return completed.source;
369+ return toSerializableValue(completed.source);
349370 }
350371
351372 if (completed.kind === "credential_required") {
@@ -385,19 +406,22 @@ export const createExecutorToolMap = (input: { · 33 unmodified lines
385406 elicitationId: result.sessionId,
386407 },
387408 }),
409+ input.runtimeLocalWorkspace,
388410 );
389411
390412 if (response.action !== "accept") {
391413 throw new Error(`Source add was not completed for ${result.source.id}`);
392414 }
393415
394− return await runEffect(
416+ const connected = await runEffect(
395417 input.sourceAuthService.getSourceById({
396418 workspaceId: input.workspaceId,
397419 sourceId: result.source.id,
398420 actorAccountId: input.accountId,
399421 }),
422+ input.runtimeLocalWorkspace,
400423 );
424+ return toSerializableValue(connected);
401425 },
402426 },
403427 metadata: {
packages/control-plane/src/runtime/index.ts+117−8
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 SqlControlPlanePersistenceLive,
1414 SqlControlPlanePersistenceService,
1515 SqlControlPlaneRowsLive,
16+ SqlControlPlaneRowsService,
1617 SqlPersistenceBootstrapError,
1718 type CreateSqlRuntimeOptions,
1819 type SqlControlPlanePersistence,
@@ -21,25 +22,35 @@ import type { LocalInstallation } from "#schema"; · 2 unmodified lines
2122
2223 import {
2324 ControlPlaneAuthHeaders,
24− RuntimeActorResolverLive,
2525 createHeaderActorResolver,
26+ RuntimeActorResolverLive,
2627 } from "./actor-resolver";
2728 import { type ResolveExecutionEnvironment } from "./execution-state";
2829 import {
30+ createLiveExecutionManager,
2931 LiveExecutionManagerLive,
3032 LiveExecutionManagerService,
3133 } from "./live-execution";
3234 import { getOrProvisionLocalInstallation } from "./local-installation";
35+import {
36+ loadLocalExecutorConfig,
37+ resolveLocalWorkspaceContext,
38+} from "./local-config";
39+import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
40+import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";
3341 import {
3442 ControlPlaneStore,
3543 ControlPlaneStoreLive,
3644 } from "./store";
3745 import {
46+ createRuntimeSourceAuthService,
3847 RuntimeSourceAuthServiceLive,
3948 RuntimeSourceAuthServiceTag,
4049 } from "./source-auth-service";
4150 import type { ResolveSecretMaterial } from "./secret-material-providers";
51+import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
4252 import {
53+ createWorkspaceExecutionEnvironmentResolver,
4354 RuntimeExecutionResolverLive,
4455 RuntimeExecutionResolverService,
4556 } from "./workspace-execution-environment";
@@ -68,6 +79,7 @@ export type RuntimeControlPlaneOptions = { · 22 unmodified lines
6879 executionResolver?: ResolveExecutionEnvironment;
6980 resolveSecretMaterial?: ResolveSecretMaterial;
7081 getLocalServerBaseUrl?: () => string | undefined;
82+ workspaceRoot?: string;
7183 };
7284
7385 const detailsFromCause = (cause: unknown): string =>
@@ -96,6 +108,7 @@ const runtimeContextTags = [ · 22 unmodified lines
96108 ControlPlaneActorResolver,
97109 ControlPlaneStore,
98110 LiveExecutionManagerService,
111+ RuntimeLocalWorkspaceService,
99112 RuntimeSourceAuthServiceTag,
100113 RuntimeExecutionResolverService,
101114 ] as const;
@@ -105,6 +118,7 @@ const createRuntimeLayerFromContext = ( · 3 unmodified lines
105118 ControlPlaneActorResolver
106119 | ControlPlaneStore
107120 | LiveExecutionManagerService
121+ | RuntimeLocalWorkspaceService
108122 | RuntimeSourceAuthServiceTag
109123 | RuntimeExecutionResolverService
110124 >,
@@ -115,6 +129,7 @@ const createRuntimeLayerFromContext = ( · 4 unmodified lines
115129 ControlPlaneActorResolver
116130 | ControlPlaneStore
117131 | LiveExecutionManagerService
132+ | RuntimeLocalWorkspaceService
118133 | RuntimeSourceAuthServiceTag
119134 | RuntimeExecutionResolverService
120135 >;
@@ -165,22 +180,59 @@ export const createSqlControlPlaneRuntime = ( · 44 unmodified lines
165180 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(
166181 Layer.provideMerge(SqlControlPlanePersistenceLive(options)),
167182 );
168− const runtimeLayer = createRuntimeControlPlaneLayer(options).pipe(
169− Layer.provideMerge(persistenceAndRowsLayer),
183+ const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe(
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+ )),
170204 );
171205
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),
173211 Effect.catchAll((error) =>
174212 closeScope(scope).pipe(
175213 Effect.zipRight(Effect.fail(error)),
176214 )),
177215 );
178216
179− const persistence = Context.get(context, SqlControlPlanePersistenceService);
180− const store = Context.get(context, ControlPlaneStore);
181− const concreteRuntimeLayer = createRuntimeLayerFromContext(context);
217+ const loadedLocalConfig = yield* Effect.tryPromise({
218+ try: () => loadLocalExecutorConfig(localWorkspaceContext),
219+ catch: toLocalInstallationBootstrapError,
220+ }).pipe(
221+ Effect.catchAll((error) =>
222+ closeScope(scope).pipe(
223+ Effect.zipRight(Effect.fail(error)),
224+ )),
225+ );
182226
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(
184236 Effect.mapError(toLocalInstallationBootstrapError),
185237 Effect.catchAll((error) =>
186238 closeScope(scope).pipe(
@@ -188,6 +240,63 @@ export const createSqlControlPlaneRuntime = ( · 1 unmodified line
188240 )),
189241 );
190242
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+
191300 return {
192301 persistence,
193302 localInstallation,
packages/control-plane/src/runtime/invocation-policy-engine.test.ts+1−0
@@ -35,6 +35,7 @@ const basePolicy = ( · 34 unmodified lines
3535 patch: Partial<Policy> = {},
3636 ): Policy => ({
3737 id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`),
38+ configKey: null,
3839 scopeType: "workspace",
3940 organizationId,
4041 workspaceId,
packages/control-plane/src/runtime/local-config-secrets.ts+9−0
@@ -0,0 +1,9 @@
1+export const CONFIG_SECRET_PROVIDER_PREFIX = "config:";
2+
3+export const toConfigSecretProviderId = (providerAlias: string): string =>
4+ `${CONFIG_SECRET_PROVIDER_PREFIX}${providerAlias}`;
5+
6+export const fromConfigSecretProviderId = (providerId: string): string | null =>
7+ providerId.startsWith(CONFIG_SECRET_PROVIDER_PREFIX)
8+ ? providerId.slice(CONFIG_SECRET_PROVIDER_PREFIX.length)
9+ : null;
packages/control-plane/src/runtime/local-config.ts+341−0
@@ -0,0 +1,341 @@
1+import { createHash } from "node:crypto";
2+import { homedir } from "node:os";
3+import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4+import { promises as fs } from "node:fs";
5+
6+import {
7+ LocalExecutorConfigSchema,
8+ type LocalExecutorConfig,
9+ type LocalConfigPolicy,
10+ type LocalConfigSecretProvider,
11+ type LocalConfigSource,
12+} from "#schema";
13+import * as Schema from "effect/Schema";
14+
15+const decodeLocalExecutorConfig = Schema.decodeUnknownSync(LocalExecutorConfigSchema);
16+
17+const PROJECT_CONFIG_BASENAME = "executor.jsonc";
18+const PROJECT_CONFIG_FALLBACK_BASENAME = "executor.json";
19+const PROJECT_CONFIG_DIRECTORY = ".executor";
20+const HOME_CONFIG_DIRECTORY = join(homedir(), ".config", "executor");
21+const HOME_CONFIG_PATH = join(HOME_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME);
22+
23+const normalizeSlashPath = (value: string): string =>
24+ value.replaceAll("\\", "/");
25+
26+const stableHash = (value: string): string =>
27+ createHash("sha256").update(value).digest("hex").slice(0, 16);
28+
29+const fileExists = async (path: string): Promise<boolean> => {
30+ try {
31+ await fs.access(path);
32+ return true;
33+ } catch {
34+ return false;
35+ }
36+};
37+
38+const pathExists = async (path: string): Promise<boolean> => {
39+ try {
40+ await fs.access(path);
41+ return true;
42+ } catch {
43+ return false;
44+ }
45+};
46+
47+const trimOrUndefined = (value: string | undefined | null): string | undefined => {
48+ const trimmed = value?.trim();
49+ return trimmed && trimmed.length > 0 ? trimmed : undefined;
50+};
51+
52+const 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+
117+const 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+
127+const 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+
140+const 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+
153+const 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+
166+export 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+
194+const 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+
208+const 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+
212+const 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+
236+export 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+
248+export type LoadedLocalExecutorConfig = {
249+ config: LocalExecutorConfig | null;
250+ homeConfig: LocalExecutorConfig | null;
251+ projectConfig: LocalExecutorConfig | null;
252+ homeConfigPath: string;
253+ projectConfigPath: string;
254+};
255+
256+export 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+
280+export 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+
291+export 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+
308+export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>
309+ `${JSON.stringify(config, null, 2)}\n`;
310+
311+export 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+
323+export 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+
340+export 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 @@
1+import { mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
14 import { describe, expect, it } from "@effect/vitest";
25 import { assertTrue } from "@effect/vitest/utils";
36 import * as Effect from "effect/Effect";
47
58 import { createSqlControlPlaneRuntime } from "./index";
69 import { getOrProvisionLocalInstallation } from "./local-installation";
10+import { resolveLocalWorkspaceContext } from "./local-config";
11+
12+const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));
713
814 const makeRuntime = Effect.acquireRelease(
9− createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),
15+ createSqlControlPlaneRuntime({
16+ localDataDir: ":memory:",
17+ workspaceRoot: TEST_WORKSPACE_ROOT,
18+ }),
1019 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
1120 );
1221
@@ -33,9 +42,15 @@ describe("local-installation", () => { · 20 unmodified lines
3342 it.scoped("is idempotent when loading the default local installation", () =>
3443 Effect.gen(function* () {
3544 const runtime = yield* makeRuntime;
45+ const context = yield* Effect.promise(() =>
46+ resolveLocalWorkspaceContext({ workspaceRoot: TEST_WORKSPACE_ROOT }),
47+ );
3648
3749 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+ });
3954
4055 expect(second.id).toBe(first.id);
4156 expect(second.accountId).toBe(first.accountId);
packages/control-plane/src/runtime/local-installation.ts+192−28
@@ -14,7 +14,22 @@ import { · 13 unmodified lines
1414 import * as Effect from "effect/Effect";
1515 import * as Option from "effect/Option";
1616
17−const LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");
17+import {
18+ defaultWorkspaceDisplayName,
19+ type ResolvedLocalWorkspaceContext,
20+} from "./local-config";
21+
22+const LEGACY_LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");
23+const LOCAL_ACCOUNT_SUBJECT = "local:default";
24+const PERSONAL_ORGANIZATION_SLUG = "personal";
25+
26+const 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+};
1833
1934 const buildAccount = (now: number): Account => {
2035 const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`);
@@ -22,7 +37,7 @@ const buildAccount = (now: number): Account => { · 1 unmodified line
2237 return {
2338 id,
2439 provider: "local",
25− subject: `local:${id}`,
40+ subject: LOCAL_ACCOUNT_SUBJECT,
2641 email: null,
2742 displayName: "Local User",
2843 createdAt: now,
@@ -35,7 +50,7 @@ const buildOrganization = ( · 6 unmodified lines
3550 now: number,
3651 ): Organization => ({
3752 id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
38− slug: "personal",
53+ slug: PERSONAL_ORGANIZATION_SLUG,
3954 name: "Personal",
4055 status: "active",
4156 createdByAccountId: accountId,
@@ -63,57 +78,206 @@ const buildOwnerMembership = ( · 21 unmodified lines
6378 const buildWorkspace = (
6479 organizationId: Organization["id"],
6580 accountId: Account["id"],
81+ workspaceName: string,
6682 now: number,
6783 ): Workspace => ({
6884 id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
6985 organizationId,
70− name: "Default",
86+ name: workspaceName,
7187 createdByAccountId: accountId,
7288 createdAt: now,
7389 updatedAt: now,
7490 });
7591
92+const 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+
104+const 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+
119+const 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+
136+const 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+
156+const 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+
179+const 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+
76194 export const loadLocalInstallation = (
77195 rows: SqlControlPlaneRows,
196+ context: ResolvedLocalWorkspaceContext,
78197 ): Effect.Effect<LocalInstallation | null, unknown> =>
79− rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe(
198+ rows.localInstallations.getById(InstallationIdSchema.make(context.installationId)).pipe(
80199 Effect.map((result) => (Option.isSome(result) ? result.value : null)),
81200 );
82201
83−export const provisionLocalInstallation = (
84− rows: SqlControlPlaneRows,
85−): Effect.Effect<LocalInstallation, unknown> =>
202+const migrateLegacyLocalInstallation = (input: {
203+ rows: SqlControlPlaneRows;
204+ context: ResolvedLocalWorkspaceContext;
205+ now: number;
206+}): Effect.Effect<LocalInstallation | null, unknown> =>
86207 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+
230+export 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);
88239 if (Option.isSome(existing)) {
89240 return existing.value;
90241 }
91242
92− const now = Date.now();
93− const account = buildAccount(now);
94− const organization = buildOrganization(account.id, now);
95− const membership = buildOwnerMembership(organization.id, account.id, now);
96− const workspace = buildWorkspace(organization.id, account.id, now);
97− const installation: LocalInstallation = {
98− id: LOCAL_INSTALLATION_ID,
243+ const migrated = yield* migrateLegacyLocalInstallation({
244+ rows: input.rows,
245+ context: input.context,
246+ now,
247+ });
248+ if (migrated !== null) {
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,
99264 accountId: account.id,
100265 organizationId: organization.id,
101266 workspaceId: workspace.id,
102− createdAt: now,
103− updatedAt: now,
104− };
267+ now,
268+ });
105269
106− yield* rows.accounts.upsert(account);
107− yield* rows.organizations.insertWithOwnerMembership(organization, membership);
108− yield* rows.workspaces.insert(workspace);
109− yield* rows.localInstallations.upsert(installation);
270+ yield* input.rows.localInstallations.upsert(installation);
110271
111272 return installation;
112273 });
113274
114−export const getOrProvisionLocalInstallation = (
115− rows: SqlControlPlaneRows,
116−): Effect.Effect<LocalInstallation, unknown> =>
117− Effect.flatMap(loadLocalInstallation(rows), (existing) =>
118− existing ? Effect.succeed(existing) : provisionLocalInstallation(rows)
275+export const getOrProvisionLocalInstallation = (input: {
276+ rows: SqlControlPlaneRows;
277+ context: ResolvedLocalWorkspaceContext;
278+}): Effect.Effect<LocalInstallation, unknown> =>
279+ Effect.flatMap(loadLocalInstallation(input.rows, input.context), (existing) =>
280+ existing
281+ ? Effect.succeed(existing)
282+ : provisionLocalInstallation(input),
119283 );
packages/control-plane/src/runtime/local-operations.ts+6−1
@@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines
1717 import {
1818 operationErrors,
1919 } from "./operation-errors";
20+import { resolveLocalWorkspaceContext } from "./local-config";
21+import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context";
2022 import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
2123 import {
2224 createSourceCredentialSelectionBearerContent,
@@ -181,8 +183,11 @@ const loadSourceCredentialInteraction = (input: { · 158 unmodified lines
181183 export const getLocalInstallation = () =>
182184 Effect.gen(function* () {
183185 const store = yield* ControlPlaneStore;
186+ const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
187+ const context = runtimeLocalWorkspace?.context
188+ ?? (yield* Effect.promise(() => resolveLocalWorkspaceContext()));
184189
185− const installation = yield* loadLocalInstallation(store).pipe(
190+ const installation = yield* loadLocalInstallation(store, context).pipe(
186191 Effect.mapError((error) =>
187192 error instanceof ControlPlanePersistenceError
188193 ? localOps.installation.storage(error)
packages/control-plane/src/runtime/local-runtime-context.ts+29−0
@@ -0,0 +1,29 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+
5+import type { AccountId, WorkspaceId } from "#schema";
6+import type {
7+ LoadedLocalExecutorConfig,
8+ ResolvedLocalWorkspaceContext,
9+} from "./local-config";
10+
11+export type RuntimeLocalWorkspaceState = {
12+ context: ResolvedLocalWorkspaceContext;
13+ installation: {
14+ workspaceId: WorkspaceId;
15+ accountId: AccountId;
16+ };
17+ loadedConfig: LoadedLocalExecutorConfig;
18+};
19+
20+export class RuntimeLocalWorkspaceService extends Context.Tag(
21+ "#runtime/RuntimeLocalWorkspaceService",
22+)<RuntimeLocalWorkspaceService, RuntimeLocalWorkspaceState>() {}
23+
24+export 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 @@
1+import { promises as fs } from "node:fs";
2+import { join } from "node:path";
3+
4+import {
5+ SourceIdSchema,
6+ SourceRecipeIdSchema,
7+ StoredSourceRecipeDocumentRecordSchema,
8+ StoredSourceRecipeOperationRecordSchema,
9+ StoredSourceRecipeRevisionRecordSchema,
10+ StoredSourceRecipeSchemaBundleRecordSchema,
11+ TimestampMsSchema,
12+ type Source,
13+ type SourceRecipeId,
14+} from "#schema";
15+import * as Schema from "effect/Schema";
16+
17+import type { ResolvedLocalWorkspaceContext } from "./local-config";
18+import {
19+ createSourceRecipeRevisionRecord,
20+ stableSourceRecipeId,
21+} from "./source-definitions";
22+import {
23+ contentHash,
24+ type SourceRecipeMaterialization,
25+} from "./source-recipe-support";
26+
27+const LOCAL_SOURCE_ARTIFACT_VERSION = 1 as const;
28+
29+export 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+
41+export type LocalSourceArtifact = typeof LocalSourceArtifactSchema.Type;
42+
43+const decodeLocalSourceArtifact = Schema.decodeUnknownSync(LocalSourceArtifactSchema);
44+
45+const localSourceArtifactPath = (input: {
46+ context: ResolvedLocalWorkspaceContext;
47+ configKey: string;
48+}): string =>
49+ join(
50+ input.context.artifactsDirectory,
51+ "sources",
52+ `${input.configKey}.json`,
53+ );
54+
55+const 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+
104+const bindRevisionId = <T extends { recipeRevisionId: string }>(
105+ items: readonly T[],
106+ recipeRevisionId: string,
107+): T[] =>
108+ items.map((item) => ({
109+ ...item,
110+ recipeRevisionId,
111+ }));
112+
113+export 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+
144+export 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+
167+export 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+
179+export 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 @@
1+import { promises as fs } from "node:fs";
2+import { join } from "node:path";
3+
4+import {
5+ PolicyIdSchema,
6+ SourceIdSchema,
7+ SourceStatusSchema,
8+ TimestampMsSchema,
9+} from "#schema";
10+import * as Schema from "effect/Schema";
11+
12+import type { ResolvedLocalWorkspaceContext } from "./local-config";
13+
14+const WORKSPACE_STATE_BASENAME = "workspace-state.json";
15+
16+const 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+
25+const LocalWorkspacePolicyStateSchema = Schema.Struct({
26+ id: PolicyIdSchema,
27+ createdAt: TimestampMsSchema,
28+ updatedAt: TimestampMsSchema,
29+});
30+
31+export 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+
43+export type LocalWorkspaceSourceState = typeof LocalWorkspaceSourceStateSchema.Type;
44+export type LocalWorkspacePolicyState = typeof LocalWorkspacePolicyStateSchema.Type;
45+export type LocalWorkspaceState = typeof LocalWorkspaceStateSchema.Type;
46+
47+const decodeLocalWorkspaceState = Schema.decodeUnknownSync(LocalWorkspaceStateSchema);
48+
49+const defaultLocalWorkspaceState = (): LocalWorkspaceState => ({
50+ version: 1,
51+ sources: {},
52+ policies: {},
53+});
54+
55+export const localWorkspaceStatePath = (
56+ context: ResolvedLocalWorkspaceContext,
57+): string => join(context.stateDirectory, WORKSPACE_STATE_BASENAME);
58+
59+export 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+
81+export 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 @@
1+import type { SqlControlPlaneRows } from "#persistence";
2+import {
3+ type AccountId,
4+ type LocalConfigPolicy,
5+ type LocalConfigSource,
6+ type LocalExecutorConfig,
7+ type Policy,
8+ type Source,
9+ type Workspace,
10+} from "#schema";
11+import * as Effect from "effect/Effect";
12+import * as Option from "effect/Option";
13+
14+import {
15+ defaultWorkspaceDisplayName,
16+ type LoadedLocalExecutorConfig,
17+ mergeLocalExecutorConfigs,
18+ type ResolvedLocalWorkspaceContext,
19+ writeProjectLocalExecutorConfig,
20+} from "./local-config";
21+import {
22+ buildLocalSourceArtifact,
23+ writeLocalSourceArtifact,
24+} from "./local-source-artifacts";
25+import {
26+ loadLocalWorkspaceState,
27+ writeLocalWorkspaceState,
28+ type LocalWorkspaceState,
29+} from "./local-workspace-state";
30+import { slugify } from "./slug";
31+import { loadSourcesInWorkspace } from "./source-store";
32+
33+const 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+
41+const toApprovalMode = (approval: LocalConfigPolicy["approval"]): Policy["approvalMode"] =>
42+ approval === "manual" ? "required" : "auto";
43+
44+export 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+
64+export 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+
83+const 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+
113+export 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+
162+export 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+
170+const 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+
197+const 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+
376+export 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
22 ControlPlaneBadRequestError,
33 ControlPlaneNotFoundError,
44 ControlPlaneStorageError,
5−} from "#api";
5+} from "../api/errors";
66 import { ControlPlanePersistenceError } from "#persistence";
77 import * as Effect from "effect/Effect";
88
packages/control-plane/src/runtime/operations-shared.ts+1−1
@@ -2,7 +2,7 @@ import { · 1 unmodified line
22 ControlPlaneBadRequestError,
33 ControlPlaneNotFoundError,
44 ControlPlaneStorageError,
5−} from "#api";
5+} from "../api/errors";
66 import { ControlPlanePersistenceError } from "#persistence";
77 import type { Organization } from "#schema";
88 import * as Effect from "effect/Effect";
packages/control-plane/src/runtime/policies-operations.ts+430−0
@@ -1,9 +1,12 @@
1+import { createHash } from "node:crypto";
2+
13 import type {
24 CreatePolicyPayload,
35 UpdatePolicyPayload,
46 } from "../api/policies/api";
57 import {
68 PolicyIdSchema,
9+ type LocalExecutorConfig,
710 type OrganizationId,
811 type Policy,
912 type PolicyId,
@@ -12,6 +15,20 @@ import { · 2 unmodified lines
1215 import * as Effect from "effect/Effect";
1316 import * as Option from "effect/Option";
1417
18+import {
19+ loadLocalExecutorConfig,
20+ writeProjectLocalExecutorConfig,
21+} from "./local-config";
22+import {
23+ getRuntimeLocalWorkspaceOption,
24+} from "./local-runtime-context";
25+import {
26+ loadLocalWorkspaceState,
27+ writeLocalWorkspaceState,
28+} from "./local-workspace-state";
29+import {
30+ derivePolicyConfigKey,
31+} from "./local-workspace-sync";
1532 import {
1633 mapPersistenceError,
1734 parseJsonString,
@@ -37,6 +54,138 @@ type PolicyScopeContext = { · 19 unmodified lines
3754 workspaceId: Policy["workspaceId"];
3855 };
3956
57+const cloneJson = <T>(value: T): T =>
58+ JSON.parse(JSON.stringify(value)) as T;
59+
60+const 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+
69+const 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+
77+const 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+
124+export 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+
40189 const loadWorkspacePolicyContext = (
41190 store: ControlPlaneStoreShape,
42191 operation: OperationErrors,
@@ -92,6 +241,35 @@ const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean · 49 unmodified lines
92241 && policy.organizationId === scope.organizationId
93242 && policy.workspaceId === scope.workspaceId;
94243
244+const 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+
95273 const createScopedPolicy = (input: {
96274 scope: PolicyScopeContext;
97275 payload: CreatePolicyPayload;
@@ -101,8 +279,90 @@ const createScopedPolicy = (input: { · 3 unmodified lines
101279 const now = Date.now();
102280 const operation = policyOps.create;
103281
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+
104363 const policy: Policy = {
105364 id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),
365+ configKey: null,
106366 scopeType: input.scope.scopeType,
107367 organizationId: input.scope.organizationId,
108368 workspaceId: input.scope.workspaceId,
@@ -133,6 +393,10 @@ const createScopedPolicy = (input: { · 24 unmodified lines
133393 store.policies.insert(policy),
134394 );
135395
396+ if (policy.workspaceId === null) {
397+ return policy;
398+ }
399+
136400 return policy;
137401 }));
138402
@@ -142,6 +406,33 @@ const getScopedPolicy = (input: { · 3 unmodified lines
142406 }) =>
143407 Effect.flatMap(ControlPlaneStore, (store) =>
144408 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+
145436 const existing = yield* policyOps.get.mapStorage(
146437 store.policies.getById(input.policyId),
147438 );
@@ -165,6 +456,84 @@ const updateScopedPolicy = (input: { · 17 unmodified lines
165456 }) =>
166457 Effect.flatMap(ControlPlaneStore, (store) =>
167458 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+
168537 const existing = yield* policyOps.update.mapStorage(
169538 store.policies.getById(input.policyId),
170539 );
@@ -234,6 +603,10 @@ const updateScopedPolicy = (input: { · 63 unmodified lines
234603 );
235604 }
236605
606+ if (updated.value.workspaceId === null) {
607+ return updated.value;
608+ }
609+
237610 return updated.value;
238611 }));
239612
@@ -243,6 +616,49 @@ const removeScopedPolicy = (input: { · 3 unmodified lines
243616 }) =>
244617 Effect.flatMap(ControlPlaneStore, (store) =>
245618 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+
246662 const existing = yield* policyOps.remove.mapStorage(
247663 store.policies.getById(input.policyId),
248664 );
@@ -270,6 +686,20 @@ export const listPolicies = (workspaceId: WorkspaceId) => · 21 unmodified lines
270686 Effect.flatMap(ControlPlaneStore, (store) =>
271687 Effect.gen(function* () {
272688 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+ }
273703 return yield* policyOps.list.mapStorage(
274704 store.policies.listByWorkspaceId(workspaceId),
275705 );
packages/control-plane/src/runtime/secret-material-providers.ts+178−1
@@ -1,8 +1,13 @@
11 import { randomUUID } from "node:crypto";
22 import { spawn } from "node:child_process";
3+import { promises as fs } from "node:fs";
4+import { lstatSync, realpathSync } from "node:fs";
5+import { isAbsolute } from "node:path";
36
47 import type { SqlControlPlaneRows } from "#persistence";
58 import {
9+ type LocalConfigSecretProvider,
10+ type LocalExecutorConfig,
611 type SecretMaterialPurpose,
712 SecretMaterialIdSchema,
813 type SecretRef,
@@ -10,6 +15,9 @@ import { · 1 unmodified line
1015 import * as Effect from "effect/Effect";
1116 import * as Option from "effect/Option";
1217
18+import { resolveConfigRelativePath } from "./local-config";
19+import { fromConfigSecretProviderId } from "./local-config-secrets";
20+
1321 export const ENV_SECRET_PROVIDER_ID = "env";
1422 export const PARAMS_SECRET_PROVIDER_ID = "params";
1523 export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
@@ -43,6 +51,8 @@ type SecretMaterialProviderRuntime = { · 27 unmodified lines
4351 env: NodeJS.ProcessEnv;
4452 dangerouslyAllowEnvSecrets: boolean;
4553 keychainServiceName: string;
54+ localConfig: LocalExecutorConfig | null;
55+ workspaceRoot: string | null;
4656 };
4757
4858 type SecretMaterialProvider = {
@@ -125,6 +135,7 @@ const runCommand = (input: { · 76 unmodified lines
125135 command: string;
126136 args: ReadonlyArray<string>;
127137 stdin?: string;
138+ env?: NodeJS.ProcessEnv;
128139 operation: string;
129140 }): Effect.Effect<SpawnResult, Error, never> =>
130141 Effect.tryPromise({
@@ -132,6 +143,7 @@ const runCommand = (input: { · 1 unmodified line
132143 new Promise<SpawnResult>((resolve, reject) => {
133144 const child = spawn(input.command, [...input.args], {
134145 stdio: "pipe",
146+ env: input.env,
135147 });
136148
137149 let stdout = "";
@@ -474,17 +486,169 @@ const createSecretMaterialProviderRuntime = (input: { · 336 unmodified lines
474486 rows: SqlControlPlaneRows;
475487 dangerouslyAllowEnvSecrets?: boolean;
476488 keychainServiceName?: string;
489+ localConfig?: LocalExecutorConfig | null;
490+ workspaceRoot?: string | null;
477491 }): SecretMaterialProviderRuntime => ({
478492 rows: input.rows,
479493 env: process.env,
480494 dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets),
481495 keychainServiceName: resolveKeychainServiceName(input.keychainServiceName),
496+ localConfig: input.localConfig ?? null,
497+ workspaceRoot: input.workspaceRoot ?? null,
482498 });
483499
500+const isRegularFilePath = (path: string, allowSymlink: boolean): boolean => {
501+ const stat = lstatSync(path);
502+ if (stat.isSymbolicLink()) {
503+ if (!allowSymlink) {
504+ return false;
505+ }
506+ const targetStat = lstatSync(realpathSync(path));
507+ return targetStat.isFile();
508+ }
509+ return stat.isFile();
510+};
511+
512+const ensureTrustedDir = (path: string, trustedDirs: readonly string[] | undefined): boolean => {
513+ if (!trustedDirs || trustedDirs.length === 0) {
514+ return true;
515+ }
516+
517+ const real = realpathSync(path);
518+ return trustedDirs.some((dir) => {
519+ const trusted = realpathSync(dir);
520+ return real === trusted || real.startsWith(`${trusted}/`);
521+ });
522+};
523+
524+const readFileSecretValue = (input: {
525+ provider: Extract<LocalConfigSecretProvider, { source: "file" }>;
526+ ref: SecretRef;
527+ workspaceRoot: string;
528+}): Effect.Effect<string, Error, never> =>
529+ Effect.tryPromise({
530+ try: async () => {
531+ const resolvedPath = resolveConfigRelativePath({
532+ path: input.provider.path,
533+ workspaceRoot: input.workspaceRoot,
534+ });
535+ const raw = await fs.readFile(resolvedPath, "utf8");
536+ const mode = input.provider.mode ?? "singleValue";
537+ if (mode === "singleValue") {
538+ return raw.trim();
539+ }
540+
541+ const parsed = JSON.parse(raw) as unknown;
542+ if (input.ref.handle.startsWith("/")) {
543+ const segments = input.ref.handle
544+ .split("/")
545+ .slice(1)
546+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
547+ let current: unknown = parsed;
548+ for (const segment of segments) {
549+ if (typeof current !== "object" || current === null || !(segment in current)) {
550+ throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`);
551+ }
552+ current = (current as Record<string, unknown>)[segment];
553+ }
554+ if (typeof current !== "string" || current.trim().length === 0) {
555+ throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`);
556+ }
557+ return current;
558+ }
559+
560+ if (typeof parsed !== "object" || parsed === null) {
561+ throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`);
562+ }
563+ const value = (parsed as Record<string, unknown>)[input.ref.handle];
564+ if (typeof value !== "string" || value.trim().length === 0) {
565+ throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`);
566+ }
567+ return value;
568+ },
569+ catch: (cause) =>
570+ cause instanceof Error ? cause : new Error(String(cause)),
571+ });
572+
573+const resolveConfiguredSecretProvider = (input: {
574+ ref: SecretRef;
575+ runtime: SecretMaterialProviderRuntime;
576+}): Effect.Effect<string, Error, never> => {
577+ const providerAlias = fromConfigSecretProviderId(input.ref.providerId);
578+ if (providerAlias === null) {
579+ return Effect.fail(new Error(`Unsupported secret provider: ${input.ref.providerId}`));
580+ }
581+
582+ const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias];
583+ if (!provider) {
584+ return Effect.fail(
585+ new Error(`Config secret provider "${providerAlias}" is not configured`),
586+ );
587+ }
588+ if (input.runtime.workspaceRoot === null) {
589+ return Effect.fail(
590+ new Error(`Config secret provider "${providerAlias}" requires a workspace root`),
591+ );
592+ }
593+
594+ if (provider.source === "env") {
595+ const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]);
596+ return value === null
597+ ? Effect.fail(new Error(`Environment variable ${input.ref.handle} is not set`))
598+ : Effect.succeed(value);
599+ }
600+
601+ if (provider.source === "file") {
602+ return readFileSecretValue({
603+ provider,
604+ ref: input.ref,
605+ workspaceRoot: input.runtime.workspaceRoot,
606+ });
607+ }
608+
609+ const command = provider.command.trim();
610+ if (!isAbsolute(command)) {
611+ return Effect.fail(
612+ new Error(`Exec secret provider command must be absolute: ${command}`),
613+ );
614+ }
615+ if (!isRegularFilePath(command, provider.allowSymlinkCommand ?? false)) {
616+ return Effect.fail(
617+ new Error(`Exec secret provider command is not an allowed regular file: ${command}`),
618+ );
619+ }
620+ if (!ensureTrustedDir(command, provider.trustedDirs)) {
621+ return Effect.fail(
622+ new Error(`Exec secret provider command is outside trustedDirs: ${command}`),
623+ );
624+ }
625+
626+ return runCommand({
627+ command,
628+ args: [...(provider.args ?? []), input.ref.handle],
629+ env: {
630+ ...input.runtime.env,
631+ ...(provider.env ?? {}),
632+ },
633+ operation: `config-secret.get:${providerAlias}`,
634+ }).pipe(
635+ Effect.flatMap((result) =>
636+ ensureCommandSuccess({
637+ result,
638+ operation: `config-secret.get:${providerAlias}`,
639+ message: "Failed resolving configured exec secret",
640+ }),
641+ ),
642+ Effect.map((result) => result.stdout.trimEnd()),
643+ );
644+};
645+
484646 export const createDefaultSecretMaterialResolver = (input: {
485647 rows: SqlControlPlaneRows;
486648 dangerouslyAllowEnvSecrets?: boolean;
487649 keychainServiceName?: string;
650+ localConfig?: LocalExecutorConfig | null;
651+ workspaceRoot?: string | null;
488652 }): ResolveSecretMaterial => {
489653 const providers = createSecretMaterialProviderRegistry();
490654 const runtime = createSecretMaterialProviderRuntime(input);
@@ -494,7 +658,20 @@ export const createDefaultSecretMaterialResolver = (input: { · 3 unmodified lines
494658 const provider = yield* getSecretMaterialProvider({
495659 providers,
496660 providerId: ref.providerId,
497− });
661+ }).pipe(
662+ Effect.catchAll(() =>
663+ fromConfigSecretProviderId(ref.providerId) !== null
664+ ? Effect.succeed(null)
665+ : Effect.fail(new Error(`Unsupported secret provider: ${ref.providerId}`)),
666+ ),
667+ );
668+
669+ if (provider === null) {
670+ return yield* resolveConfiguredSecretProvider({
671+ ref,
672+ runtime,
673+ });
674+ }
498675
499676 return yield* provider.resolve({
500677 ref,
packages/control-plane/src/runtime/source-adapters/mcp.ts+1−1
@@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines
187187 });
188188 });
189189
190−const materializationFromMcpManifestEntries = (input: {
190+export const materializationFromMcpManifestEntries = (input: {
191191 recipeRevisionId: SourceRecipeRevisionId;
192192 endpoint: string;
193193 manifestEntries: readonly McpToolManifestEntry[];
packages/control-plane/src/runtime/source-auth-material.test.ts+1−0
@@ -12,6 +12,7 @@ import { resolveSourceAuthMaterial } from "./source-auth-material"; · 11 unmodified lines
1212 const makeSource = (overrides: Partial<Source> = {}): Source => ({
1313 id: SourceIdSchema.make("src_tool_artifacts"),
1414 workspaceId: WorkspaceIdSchema.make("ws_tool_artifacts"),
15+ configKey: null,
1516 name: "GitHub",
1617 kind: "openapi",
1718 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
3232 const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
3333 id: "src_test" as Source["id"],
3434 workspaceId: "ws_test" as Source["workspaceId"],
35+ configKey: null,
3536 name: "GitHub",
3637 kind: "openapi",
3738 endpoint: "https://api.github.com",
packages/control-plane/src/runtime/source-auth-service.ts+105−62
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 AccountId,
1212 type CredentialSlot,
1313 ExecutionIdSchema,
14+ type LocalExecutorConfig,
1415 McpSourceAuthSessionDataJsonSchema,
1516 type McpSourceAuthSessionData,
1617 OAuth2PkceSourceAuthSessionDataJsonSchema,
@@ -43,6 +44,10 @@ import { · 26 unmodified lines
4344 LiveExecutionManagerService,
4445 type LiveExecutionManager,
4546 } from "./live-execution";
47+import {
48+ RuntimeLocalWorkspaceService,
49+ type RuntimeLocalWorkspaceState,
50+} from "./local-runtime-context";
4651 import {
4752 exchangeMcpOAuthAuthorizationCode,
4853 startMcpOAuthAuthorization,
@@ -1634,13 +1639,35 @@ export const createRuntimeSourceAuthService = (input: { · 1585 unmodified lines
16341639 rows: SqlControlPlaneRows;
16351640 liveExecutionManager: LiveExecutionManager;
16361641 getLocalServerBaseUrl?: () => string | undefined;
1642+ localConfig?: LocalExecutorConfig | null;
1643+ workspaceRoot?: string | null;
1644+ localWorkspaceState?: RuntimeLocalWorkspaceState;
16371645 }) => {
16381646 const resolveSecretMaterial = createDefaultSecretMaterialResolver({
16391647 rows: input.rows,
1648+ localConfig: input.localConfig,
1649+ workspaceRoot: input.workspaceRoot,
16401650 });
16411651 const storeSecretMaterial = createDefaultSecretMaterialStorer({
16421652 rows: input.rows,
16431653 });
1654+ const mirrorLocalSourceResult = (
1655+ result: ExecutorSourceAddResult,
1656+ ): Effect.Effect<ExecutorSourceAddResult, Error, never> =>
1657+ Effect.succeed(result);
1658+ const mirrorLocalMcpSourceResult = (
1659+ result: McpSourceConnectResult,
1660+ ): Effect.Effect<McpSourceConnectResult, Error, never> =>
1661+ Effect.succeed(result);
1662+ const provideLocalWorkspace = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
1663+ input.localWorkspaceState
1664+ ? effect.pipe(
1665+ Effect.provideService(
1666+ RuntimeLocalWorkspaceService,
1667+ input.localWorkspaceState,
1668+ ),
1669+ )
1670+ : effect;
16441671
16451672 return {
16461673 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,
@@ -1652,68 +1679,78 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines
16521679 }),
16531680
16541681 getSourceById: ({ workspaceId, sourceId, actorAccountId }) =>
1655− loadSourceById(input.rows, {
1656− workspaceId,
1657− sourceId,
1658− actorAccountId,
1659− }),
1682+ provideLocalWorkspace(
1683+ loadSourceById(input.rows, {
1684+ workspaceId,
1685+ sourceId,
1686+ actorAccountId,
1687+ }),
1688+ ),
16601689
16611690 addExecutorSource: (sourceInput, options = undefined) =>
1662− sourceInput.kind === "google_discovery"
1663− ? addExecutorGoogleDiscoverySource({
1664− rows: input.rows,
1665− sourceInput,
1666− storeSecretMaterial,
1667− resolveSecretMaterial,
1668− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1669− baseUrl: options?.baseUrl,
1670− })
1671− : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
1672− ? addExecutorHttpSource({
1673− rows: input.rows,
1674− sourceInput: sourceInput as Extract<
1675− ExecutorAddSourceInput,
1676− { kind: "openapi" | "graphql" }
1677− >,
1678− storeSecretMaterial,
1679− resolveSecretMaterial,
1680− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1681− baseUrl: options?.baseUrl,
1682− })
1683− : connectMcpSourceInternal({
1684− rows: input.rows,
1685− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1686− workspaceId: sourceInput.workspaceId,
1687− actorAccountId: sourceInput.actorAccountId,
1688− executionId: sourceInput.executionId,
1689− interactionId: sourceInput.interactionId,
1690− endpoint: sourceInput.endpoint,
1691− name: sourceInput.name,
1692− namespace: sourceInput.namespace,
1693− mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation,
1694− baseUrl: options?.baseUrl,
1695− resolveSecretMaterial,
1696− }),
1691+ provideLocalWorkspace(
1692+ (sourceInput.kind === "google_discovery"
1693+ ? addExecutorGoogleDiscoverySource({
1694+ rows: input.rows,
1695+ sourceInput,
1696+ storeSecretMaterial,
1697+ resolveSecretMaterial,
1698+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1699+ baseUrl: options?.baseUrl,
1700+ })
1701+ : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
1702+ ? addExecutorHttpSource({
1703+ rows: input.rows,
1704+ sourceInput: sourceInput as Extract<
1705+ ExecutorAddSourceInput,
1706+ { kind: "openapi" | "graphql" }
1707+ >,
1708+ storeSecretMaterial,
1709+ resolveSecretMaterial,
1710+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1711+ baseUrl: options?.baseUrl,
1712+ })
1713+ : connectMcpSourceInternal({
1714+ rows: input.rows,
1715+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1716+ workspaceId: sourceInput.workspaceId,
1717+ actorAccountId: sourceInput.actorAccountId,
1718+ executionId: sourceInput.executionId,
1719+ interactionId: sourceInput.interactionId,
1720+ endpoint: sourceInput.endpoint,
1721+ name: sourceInput.name,
1722+ namespace: sourceInput.namespace,
1723+ mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation,
1724+ baseUrl: options?.baseUrl,
1725+ resolveSecretMaterial,
1726+ })).pipe(
1727+ Effect.flatMap(mirrorLocalSourceResult),
1728+ ),
1729+ ),
16971730
16981731 connectMcpSource: (sourceInput) =>
1699− connectMcpSourceInternal({
1700− rows: input.rows,
1701− getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1702− workspaceId: sourceInput.workspaceId,
1703− actorAccountId: sourceInput.actorAccountId,
1704− sourceId: sourceInput.sourceId,
1705− executionId: null,
1706− interactionId: null,
1707− endpoint: sourceInput.endpoint,
1708− name: sourceInput.name,
1709− namespace: sourceInput.namespace,
1710− enabled: sourceInput.enabled,
1711− transport: sourceInput.transport,
1712− queryParams: sourceInput.queryParams,
1713− headers: sourceInput.headers,
1714− baseUrl: sourceInput.baseUrl,
1715− resolveSecretMaterial,
1716− }),
1732+ provideLocalWorkspace(
1733+ connectMcpSourceInternal({
1734+ rows: input.rows,
1735+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1736+ workspaceId: sourceInput.workspaceId,
1737+ actorAccountId: sourceInput.actorAccountId,
1738+ sourceId: sourceInput.sourceId,
1739+ executionId: null,
1740+ interactionId: null,
1741+ endpoint: sourceInput.endpoint,
1742+ name: sourceInput.name,
1743+ namespace: sourceInput.namespace,
1744+ enabled: sourceInput.enabled,
1745+ transport: sourceInput.transport,
1746+ queryParams: sourceInput.queryParams,
1747+ headers: sourceInput.headers,
1748+ baseUrl: sourceInput.baseUrl,
1749+ resolveSecretMaterial,
1750+ }).pipe(
1751+ Effect.flatMap(mirrorLocalMcpSourceResult),
1752+ ),
1753+ ),
17171754
17181755 startSourceOAuthSession: (oauthInput) =>
17191756 Effect.gen(function* () {
@@ -1781,7 +1818,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines
17811818 error,
17821819 errorDescription,
17831820 }) =>
1784− Effect.gen(function* () {
1821+ provideLocalWorkspace(Effect.gen(function* () {
17851822 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
17861823 if (Option.isNone(sessionOption)) {
17871824 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));
@@ -1890,7 +1927,7 @@ export const createRuntimeSourceAuthService = (input: { · 102 unmodified lines
18901927 sessionId: session.id,
18911928 auth,
18921929 } satisfies CompleteSourceOAuthSessionResult;
1893− }),
1930+ })),
18941931
18951932 completeSourceCredentialSetup: ({
18961933 workspaceId,
@@ -1901,7 +1938,7 @@ export const createRuntimeSourceAuthService = (input: { · 4 unmodified lines
19011938 error,
19021939 errorDescription,
19031940 }) =>
1904− Effect.gen(function* () {
1941+ provideLocalWorkspace(Effect.gen(function* () {
19051942 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
19061943 if (Option.isNone(sessionOption)) {
19071944 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));
@@ -2164,7 +2201,7 @@ export const createRuntimeSourceAuthService = (input: { · 256 unmodified lines
21642201 });
21652202
21662203 return connectedSource;
2167− }),
2204+ })),
21682205 } satisfies RuntimeSourceAuthServiceShape;
21692206 };
21702207
@@ -2176,6 +2213,9 @@ export class RuntimeSourceAuthServiceTag extends Context.Tag( · 5 unmodified lines
21762213
21772214 export const RuntimeSourceAuthServiceLive = (input: {
21782215 getLocalServerBaseUrl?: () => string | undefined;
2216+ localConfig?: LocalExecutorConfig | null;
2217+ workspaceRoot?: string | null;
2218+ localWorkspaceState?: RuntimeLocalWorkspaceState;
21792219 } = {}) =>
21802220 Layer.effect(
21812221 RuntimeSourceAuthServiceTag,
@@ -2187,6 +2227,9 @@ export const RuntimeSourceAuthServiceLive = (input: { · 5 unmodified lines
21872227 rows,
21882228 liveExecutionManager,
21892229 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
2230+ localConfig: input.localConfig,
2231+ workspaceRoot: input.workspaceRoot,
2232+ localWorkspaceState: input.localWorkspaceState,
21902233 });
21912234 }),
21922235 );
packages/control-plane/src/runtime/source-definitions.test.ts+1−0
@@ -44,6 +44,7 @@ const mcpBinding = (input: { · 43 unmodified lines
4444 const makeSource = (overrides: Partial<Source> = {}): Source => ({
4545 id: SourceIdSchema.make("src_source_definitions"),
4646 workspaceId: WorkspaceIdSchema.make("ws_source_definitions"),
47+ configKey: null,
4748 name: "GitHub",
4849 kind: "openapi",
4950 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
232232 return yield* validateSourceByKind({
233233 id: input.sourceId,
234234 workspaceId: input.workspaceId,
235+ configKey: null,
235236 name: input.payload.name.trim(),
236237 kind: input.payload.kind,
237238 endpoint: input.payload.endpoint.trim(),
@@ -352,6 +353,7 @@ export const splitSourceForStorage = (input: { · 114 unmodified lines
352353 const sourceRecord: StoredSourceRecord = {
353354 id: input.source.id,
354355 workspaceId: input.source.workspaceId,
356+ configKey: input.source.configKey,
355357 recipeId: input.recipeId,
356358 recipeRevisionId: input.recipeRevisionId,
357359 name: input.source.name,
@@ -406,6 +408,7 @@ export const projectSourceFromStorage = (input: { · 48 unmodified lines
406408 return {
407409 id: input.sourceRecord.id,
408410 workspaceId: input.sourceRecord.workspaceId,
411+ configKey: input.sourceRecord.configKey,
409412 name: input.sourceRecord.name,
410413 kind: input.sourceRecord.kind,
411414 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
6262 }): Source => ({
6363 id: input.sourceId,
6464 workspaceId: input.workspaceId,
65+ configKey: null,
6566 name: "Cloudflare API",
6667 kind: "openapi",
6768 endpoint: "https://api.cloudflare.com/client/v4",
@@ -237,6 +238,7 @@ describe("source-inspection", () => { · 169 unmodified lines
237238 await Effect.runPromise(persistence.rows.sources.insert({
238239 id: sourceId,
239240 workspaceId,
241+ configKey: null,
240242 recipeId,
241243 recipeRevisionId,
242244 name: "Cloudflare API",
packages/control-plane/src/runtime/source-inspection.ts+40−51
@@ -16,7 +16,7 @@ import { SourceRecipeSchemaBundleIdSchema } from "#schema"; · 15 unmodified lines
1616 import {
1717 ControlPlaneNotFoundError,
1818 ControlPlaneStorageError,
19−} from "#api";
19+} from "../api/errors";
2020 import * as Effect from "effect/Effect";
2121 import * as Option from "effect/Option";
2222
@@ -26,6 +26,7 @@ import { · 3 unmodified lines
2626 getSourceAdapterForOperation,
2727 } from "./source-adapters";
2828 import {
29+ loadSourceWithRecipe,
2930 recipeToolMetadata,
3031 recipeToolPath,
3132 } from "./source-recipes-runtime";
@@ -110,41 +111,29 @@ const loadSourceRecipeRecord = (input: { · 78 unmodified lines
110111 }) =>
111112 Effect.flatMap(ControlPlaneStore, (store) =>
112113 Effect.gen(function* () {
113− const sourceRecord = yield* sourceInspectOps.bundle.child("record").mapStorage(
114− store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
115− );
116− if (Option.isNone(sourceRecord)) {
117− return yield* Effect.fail(
118− sourceInspectOps.bundle.notFound(
119− "Source not found",
120− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
121− ),
122− );
123− }
124−
125− const source = yield* loadSourceById(store, {
114+ const recipe = yield* loadSourceWithRecipe({
115+ rows: store,
126116 workspaceId: input.workspaceId,
127117 sourceId: input.sourceId,
128118 }).pipe(
129119 Effect.mapError((cause) =>
130− sourceInspectOps.bundle.unknownStorage(
131− cause,
132− "Failed loading source",
133− ),
120+ cause instanceof Error && cause.message.startsWith("Source not found:")
121+ ? sourceInspectOps.bundle.notFound(
122+ "Source not found",
123+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
124+ )
125+ : sourceInspectOps.bundle.unknownStorage(
126+ cause,
127+ "Failed loading source recipe",
128+ ),
134129 ),
135130 );
136− const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage(
137− store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),
138− );
139− const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage(
140− store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),
141− );
142131
143132 return {
144− source,
145− operations,
146− schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
147− ?? schemaBundles[0]?.id
133+ source: recipe.source,
134+ operations: recipe.operations,
135+ schemaBundleId: recipe.schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
136+ ?? recipe.schemaBundles[0]?.id
148137 ?? null,
149138 };
150139 }),
@@ -403,27 +392,27 @@ export const getSourceInspectionSchemaBundle = (input: { · 252 unmodified lines
403392 }) =>
404393 Effect.flatMap(ControlPlaneStore, (store) =>
405394 Effect.gen(function* () {
406− const sourceRecord = yield* sourceInspectOps.tool.child("bundle_record").mapStorage(
407− store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
408− );
409− if (Option.isNone(sourceRecord)) {
410− return yield* Effect.fail(
411− sourceInspectOps.tool.notFound(
412− "Source not found",
413− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
414− ),
415− );
416− }
417−
418− const schemaBundle = yield* sourceInspectOps.tool.child("schema_bundle").mapStorage(
419− store.sourceRecipeSchemaBundles.getById(
420− SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),
395+ const recipe = yield* loadSourceWithRecipe({
396+ rows: store,
397+ workspaceId: input.workspaceId,
398+ sourceId: input.sourceId,
399+ }).pipe(
400+ Effect.mapError((cause) =>
401+ cause instanceof Error && cause.message.startsWith("Source not found:")
402+ ? sourceInspectOps.tool.notFound(
403+ "Source not found",
404+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
405+ )
406+ : sourceInspectOps.tool.unknownStorage(
407+ cause,
408+ "Failed loading source recipe",
409+ ),
421410 ),
422411 );
423− if (
424− Option.isNone(schemaBundle)
425− || schemaBundle.value.recipeRevisionId !== sourceRecord.value.recipeRevisionId
426− ) {
412+ const schemaBundle = recipe.schemaBundles.find(
413+ (candidate) => candidate.id === SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),
414+ );
415+ if (!schemaBundle) {
427416 return yield* Effect.fail(
428417 sourceInspectOps.tool.notFound(
429418 "Schema bundle not found",
@@ -433,10 +422,10 @@ export const getSourceInspectionSchemaBundle = (input: { · 3 unmodified lines
433422 }
434423
435424 return {
436− id: schemaBundle.value.id,
437− kind: schemaBundle.value.bundleKind,
438− hash: schemaBundle.value.contentHash,
439− refsJson: schemaBundle.value.refsJson,
425+ id: schemaBundle.id,
426+ kind: schemaBundle.bundleKind,
427+ hash: schemaBundle.contentHash,
428+ refsJson: schemaBundle.refsJson,
440429 } satisfies SourceInspectionSchemaBundle;
441430 }),
442431 ).pipe(
packages/control-plane/src/runtime/source-materialization.ts+149−2
@@ -2,14 +2,30 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line
22 import type {
33 AccountId,
44 Source,
5+ SourceStatus,
56 } from "#schema";
67 import * as Effect from "effect/Effect";
78
9+import {
10+ buildLocalSourceArtifact,
11+ writeLocalSourceArtifact,
12+} from "./local-source-artifacts";
13+import {
14+ getRuntimeLocalWorkspaceOption,
15+} from "./local-runtime-context";
16+import {
17+ loadLocalWorkspaceState,
18+ writeLocalWorkspaceState,
19+ type LocalWorkspaceState,
20+} from "./local-workspace-state";
821 import { resolveSourceAuthMaterial } from "./source-auth-material";
922 import {
1023 getSourceAdapterForSource,
1124 } from "./source-adapters";
12−import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp";
25+import {
26+ materializationFromMcpManifestEntries,
27+ persistMcpRecipeRevisionFromManifestEntries,
28+} from "./source-adapters/mcp";
1329 import type {
1430 ResolveSecretMaterial as ResolveSourceSecretMaterial,
1531 } from "./secret-material-providers";
@@ -27,6 +43,106 @@ export const syncSourceMaterialization = (input: { · 11 unmodified lines
2743 resolveSecretMaterial: ResolveSourceSecretMaterial;
2844 }): Effect.Effect<void, Error, never> =>
2945 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+
30146 if (!shouldIndexSource(input.source)) {
31147 return;
32148 }
@@ -58,4 +174,35 @@ export const persistMcpRecipeMaterializationFromManifest = (input: { · 25 unmodified lines
58174 typeof persistMcpRecipeRevisionFromManifestEntries
59175 >[0]["manifestEntries"];
60176 }): 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
8787 return {
8888 id: SourceIdSchema.make("src_runtime_recipe"),
8989 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),
90+ configKey: null,
9091 name: "GitHub",
9192 kind,
9293 endpoint,
@@ -372,6 +373,7 @@ describe("source-recipes-runtime", () => { · 279 unmodified lines
372373 sourceRecord: {
373374 id: SourceIdSchema.make("src_runtime_recipe"),
374375 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),
376+ configKey: null,
375377 recipeId: SourceRecipeIdSchema.make("src_recipe_runtime"),
376378 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
377379 name: "GITHUB API",
@@ -543,6 +545,7 @@ describe("source-recipes-runtime", () => { · 165 unmodified lines
543545 await Effect.runPromise(persistence.rows.sources.insert({
544546 id: SourceIdSchema.make(`src_shared_revision_${index}`),
545547 workspaceId,
548+ configKey: null,
546549 recipeId,
547550 recipeRevisionId,
548551 name,
@@ -621,6 +624,7 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines
621624 await Effect.runPromise(persistence.rows.sources.insert({
622625 id: sourceId,
623626 workspaceId,
627+ configKey: null,
624628 recipeId,
625629 recipeRevisionId,
626630 name: "GraphQL Demo",
@@ -688,6 +692,7 @@ describe("source-recipes-runtime", () => { · 61 unmodified lines
688692 await Effect.runPromise(persistence.rows.sources.insert({
689693 id: sourceId,
690694 workspaceId,
695+ configKey: null,
691696 recipeId,
692697 recipeRevisionId,
693698 name: "Broken GitHub",
packages/control-plane/src/runtime/source-recipes-runtime.ts+177−189
@@ -15,6 +15,12 @@ import type { · 14 unmodified lines
1515 import * as Effect from "effect/Effect";
1616 import * as Option from "effect/Option";
1717
18+import {
19+ readLocalSourceArtifact,
20+} from "./local-source-artifacts";
21+import {
22+ getRuntimeLocalWorkspaceOption,
23+} from "./local-runtime-context";
1824 import { namespaceFromSourceName } from "./source-names";
1925 import {
2026 getSourceAdapterForOperation,
@@ -156,6 +162,73 @@ export const loadWorkspaceSourceRecipes = (input: { · 135 unmodified lines
156162 actorAccountId?: AccountId | null;
157163 }): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> =>
158164 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+
159232 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
160233 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
161234 actorAccountId: input.actorAccountId,
@@ -230,6 +303,72 @@ export const loadSourceWithRecipe = (input: { · 68 unmodified lines
230303 actorAccountId?: AccountId | null;
231304 }): Effect.Effect<LoadedSourceRecipe, Error, never> =>
232305 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+
233372 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
234373 input.workspaceId,
235374 input.sourceId,
@@ -339,94 +478,26 @@ export const loadWorkspaceSourceRecipeToolIndex = (input: { · 103 unmodified lines
339478 includeSchemas: boolean;
340479 }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>
341480 Effect.gen(function* () {
342− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
343− const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
481+ const recipes = yield* loadWorkspaceSourceRecipes({
482+ rows: input.rows,
483+ workspaceId: input.workspaceId,
344484 actorAccountId: input.actorAccountId,
345485 });
346− const sourceById = new Map(sources.map((source) => [source.id, source]));
347− const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
348− const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
349− const [operations, schemaBundles] = yield* Effect.all([
350− input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
351− input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
352− ]);
353−
354− const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
355− for (const operation of operations) {
356− const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];
357− existing.push(operation);
358− operationsByRevisionId.set(operation.recipeRevisionId, existing);
359− }
360−
361− const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
362− for (const schemaBundle of schemaBundles) {
363− const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
364− existing.push(schemaBundle);
365− schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
366− }
367−
368− const entryGroups = yield* Effect.forEach(
369− relevantSourceRecords,
370− (sourceRecord) => {
371− const source = sourceById.get(sourceRecord.id);
372− if (!source) {
373− return Effect.succeed<readonly LoadedSourceRecipeToolIndexEntry[]>([]);
374− }
375−
376− return Effect.forEach(
377− operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
378− (operation) =>
379− Effect.gen(function* () {
380− const path = recipeToolPath({
381− source,
382− operation,
383− });
384− const searchNamespace = recipeToolSearchNamespace({
385− source,
386− path,
387− operation,
388− });
389− const schemaBundleId = primarySchemaBundleForRevision({
390− source,
391− schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
392− })?.id ?? null;
393− const metadata = yield* recipeToolMetadata({
394− source,
395− operation,
396− path,
397− });
398−
399− return {
400− path,
401− searchNamespace,
402− searchText: [
403− path,
404− searchNamespace,
405− source.name,
406− metadata.searchText,
407− ]
408− .filter((part) => part.length > 0)
409− .join(" ")
410− .toLowerCase(),
411− source,
412− sourceRecord,
413− operation,
414− metadata,
415− schemaBundleId,
416− descriptor: recipeToolDescriptor({
417− source,
418− operation,
419− path,
420− schemaBundleId,
421− includeSchemas: input.includeSchemas,
422− }),
423− } satisfies LoadedSourceRecipeToolIndexEntry;
424− }),
425− );
426− },
427− );
428−
429− return entryGroups.flat();
486+ const tools = yield* expandRecipeTools({
487+ recipes,
488+ includeSchemas: input.includeSchemas,
489+ });
490+ return tools.map((tool) => ({
491+ path: tool.path,
492+ searchNamespace: tool.searchNamespace,
493+ searchText: tool.searchText,
494+ source: tool.source,
495+ sourceRecord: tool.sourceRecord,
496+ operation: tool.operation,
497+ metadata: tool.metadata,
498+ schemaBundleId: tool.schemaBundleId,
499+ descriptor: tool.descriptor,
500+ }));
430501 });
431502
432503 export const loadWorkspaceSourceRecipeToolByPath = (input: {
@@ -437,110 +508,27 @@ export const loadWorkspaceSourceRecipeToolByPath = (input: { · 4 unmodified lines
437508 includeSchemas: boolean;
438509 }): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> =>
439510 Effect.gen(function* () {
440− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
441− const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
511+ const recipes = yield* loadWorkspaceSourceRecipes({
512+ rows: input.rows,
513+ workspaceId: input.workspaceId,
442514 actorAccountId: input.actorAccountId,
443515 });
444− const sourceById = new Map(sources.map((source) => [source.id, source]));
445− const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
446−
447− const candidates = relevantSourceRecords.flatMap((sourceRecord) => {
448− const source = sourceById.get(sourceRecord.id);
449− if (!source) {
450− return [];
451− }
452−
453− const namespace = source.namespace ?? namespaceFromSourceName(source.name);
454− if (namespace.length > 0) {
455− if (!input.path.startsWith(`${namespace}.`)) {
456− return [];
457− }
458−
459− return [{
460− source,
461− sourceRecord,
462− toolId: input.path.slice(namespace.length + 1),
463− }];
464− }
465−
466− return [{
467− source,
468− sourceRecord,
469− toolId: input.path,
470− }];
516+ const tools = yield* expandRecipeTools({
517+ recipes,
518+ includeSchemas: input.includeSchemas,
471519 });
472−
473− if (candidates.length === 0) {
474− return null;
475− }
476−
477− const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))];
478− const [operations, schemaBundles] = yield* Effect.all([
479− input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
480− input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
481− ]);
482− const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
483− for (const schemaBundle of schemaBundles) {
484− const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
485− existing.push(schemaBundle);
486− schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
487− }
488−
489− for (const candidate of candidates) {
490− const operation = operations.find((entry) =>
491− entry.recipeRevisionId === candidate.sourceRecord.recipeRevisionId
492− && entry.toolId === candidate.toolId
493− );
494− if (!operation) {
495− continue;
496− }
497−
498− const path = recipeToolPath({
499− source: candidate.source,
500− operation,
501− });
502− const searchNamespace = recipeToolSearchNamespace({
503− source: candidate.source,
504− path,
505− operation,
506− });
507− const schemaBundleId = primarySchemaBundleForRevision({
508− source: candidate.source,
509− schemaBundles:
510− schemaBundlesByRevisionId.get(candidate.sourceRecord.recipeRevisionId) ?? [],
511− })?.id ?? null;
512− const metadata = yield* recipeToolMetadata({
513− source: candidate.source,
514− operation,
515− path,
516− });
517−
518− return {
519− path,
520− searchNamespace,
521− searchText: [
522− path,
523− searchNamespace,
524− candidate.source.name,
525− metadata.searchText,
526− ]
527− .filter((part) => part.length > 0)
528− .join(" ")
529− .toLowerCase(),
530− source: candidate.source,
531− sourceRecord: candidate.sourceRecord,
532− operation,
533− metadata,
534− schemaBundleId,
535− descriptor: recipeToolDescriptor({
536− source: candidate.source,
537− operation,
538− path,
539− schemaBundleId,
540− includeSchemas: input.includeSchemas,
541− }),
542− } satisfies LoadedSourceRecipeToolIndexEntry;
543− }
544−
545− return null;
520+ const tool = tools.find((entry) => entry.path === input.path) ?? null;
521+ return tool
522+ ? {
523+ path: tool.path,
524+ searchNamespace: tool.searchNamespace,
525+ searchText: tool.searchText,
526+ source: tool.source,
527+ sourceRecord: tool.sourceRecord,
528+ operation: tool.operation,
529+ metadata: tool.metadata,
530+ schemaBundleId: tool.schemaBundleId,
531+ descriptor: tool.descriptor,
532+ }
533+ : null;
546534 });
packages/control-plane/src/runtime/source-store.test.ts+1−0
@@ -52,6 +52,7 @@ const makeOpenApiSource = (input: { · 51 unmodified lines
5252 }): Source => ({
5353 id: input.sourceId,
5454 workspaceId: input.workspaceId,
55+ configKey: null,
5556 name: input.name ?? "GitHub",
5657 kind: "openapi",
5758 endpoint: input.endpoint ?? "https://api.github.com",
packages/control-plane/src/runtime/source-store.ts+631−0
@@ -1,7 +1,11 @@
1+import { createHash } from "node:crypto";
2+
13 import type {
24 AccountId,
35 AuthArtifact,
46 CredentialSlot,
7+ LocalConfigSecretInput,
8+ LocalConfigSource,
59 Source,
610 SourceRecipeId,
711 SourceRecipeRevisionId,
@@ -20,9 +24,32 @@ import { · 12 unmodified lines
2024 stableSourceRecipeRevisionId,
2125 splitSourceForStorage,
2226 } from "./source-definitions";
27+import {
28+ loadLocalExecutorConfig,
29+ type ResolvedLocalWorkspaceContext,
30+ writeProjectLocalExecutorConfig,
31+} from "./local-config";
32+import {
33+ readLocalSourceArtifact,
34+ removeLocalSourceArtifact,
35+} from "./local-source-artifacts";
36+import {
37+ getRuntimeLocalWorkspaceOption,
38+} from "./local-runtime-context";
39+import {
40+ loadLocalWorkspaceState,
41+ writeLocalWorkspaceState,
42+ type LocalWorkspaceState,
43+} from "./local-workspace-state";
44+import {
45+ fromConfigSecretProviderId,
46+ toConfigSecretProviderId,
47+} from "./local-config-secrets";
2348 import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
2449 import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
2550 import { removeAuthLeaseAndSecrets } from "./auth-leases";
51+import { getSourceAdapter } from "./source-adapters";
52+import { slugify } from "./slug";
2653
2754 const secretRefKey = (ref: { providerId: string; handle: string }): string =>
2855 `${ref.providerId}:${ref.handle}`;
@@ -79,6 +106,283 @@ const selectExactAuthArtifact = (input: { · 50 unmodified lines
79106 && artifact.actorAccountId === (input.actorAccountId ?? null),
80107 ) ?? null;
81108
109+const 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+
118+const cloneJson = <T>(value: T): T =>
119+ JSON.parse(JSON.stringify(value)) as T;
120+
121+const 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+
132+const 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+
141+const 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+
150+const 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+
201+const 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+
229+const 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+
255+const 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+
357+const 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+
82386 export const loadSourcesInWorkspace = (
83387 rows: SqlControlPlaneRows,
84388 workspaceId: WorkspaceId,
@@ -87,6 +391,27 @@ export const loadSourcesInWorkspace = ( · 2 unmodified lines
87391 } = {},
88392 ) =>
89393 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+
90415 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
91416 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
92417 const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => {
@@ -118,6 +443,35 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 25 unmodified lines
118443 actorAccountId?: AccountId | null;
119444 }) =>
120445 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+
121475 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
122476 input.workspaceId,
123477 input.sourceId,
@@ -151,6 +505,62 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 27 unmodified lines
151505 });
152506 });
153507
508+const 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+
154564 const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {
155565 workspaceId: WorkspaceId;
156566 sourceId: Source["id"];
@@ -227,6 +637,75 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 70 unmodified lines
227637 sourceId: Source["id"];
228638 }) =>
229639 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+
230709 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);
231710 if (Option.isNone(sourceRecord)) {
232711 return false;
@@ -262,6 +741,158 @@ export const persistSource = ( · 29 unmodified lines
262741 } = {},
263742 ) =>
264743 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+
265896 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
266897 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
267898 workspaceId: source.workspaceId,
packages/control-plane/src/runtime/sources-operations.ts+39−31
@@ -140,16 +140,18 @@ const syncArtifactsForSource = (input: { · 139 unmodified lines
140140 export const listSources = (workspaceId: WorkspaceId) =>
141141 Effect.flatMap(ControlPlaneStore, (store) =>
142142 Effect.flatMap(Actor, (actor) =>
143− loadSourcesInWorkspace(store, workspaceId, {
144− actorAccountId: actor.principal.accountId,
145− }).pipe(
146− Effect.mapError((error) =>
147− sourceOps.list.unknownStorage(
148− error,
149− "Failed projecting stored sources",
143+ Effect.gen(function* () {
144+ return yield* loadSourcesInWorkspace(store, workspaceId, {
145+ actorAccountId: actor.principal.accountId,
146+ }).pipe(
147+ Effect.mapError((error) =>
148+ sourceOps.list.unknownStorage(
149+ error,
150+ "Failed projecting stored sources",
151+ ),
150152 ),
151− ),
152− ),
153+ );
154+ }),
153155 ));
154156
155157 export const createSource = (input: {
@@ -175,19 +177,21 @@ export const createSource = (input: { · 19 unmodified lines
175177 ),
176178 );
177179
178− yield* mapPersistenceError(
180+ const persistedSource = yield* mapPersistenceError(
179181 sourceOps.create.child("persist"),
180182 persistSource(store, source, {
181183 actorAccountId: actor.principal.accountId,
182184 }),
183185 );
184186
185− return yield* syncArtifactsForSource({
187+ const synchronizedSource = yield* syncArtifactsForSource({
186188 store,
187− source,
189+ source: persistedSource,
188190 actorAccountId: actor.principal.accountId,
189191 operation: sourceOps.create,
190192 });
193+
194+ return synchronizedSource;
191195 }),
192196 ));
193197
@@ -197,23 +201,25 @@ export const getSource = (input: { · 3 unmodified lines
197201 }) =>
198202 Effect.flatMap(ControlPlaneStore, (store) =>
199203 Effect.flatMap(Actor, (actor) =>
200− loadSourceById(store, {
201− workspaceId: input.workspaceId,
202− sourceId: input.sourceId,
203− actorAccountId: actor.principal.accountId,
204− }).pipe(
205− Effect.mapError((cause) =>
206− cause instanceof Error && cause.message.startsWith("Source not found:")
207− ? sourceOps.get.notFound(
208− "Source not found",
209− `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
210− )
211− : sourceOps.get.unknownStorage(
212− cause,
213− "Failed projecting stored source",
204+ Effect.gen(function* () {
205+ return yield* loadSourceById(store, {
206+ workspaceId: input.workspaceId,
207+ sourceId: input.sourceId,
208+ actorAccountId: actor.principal.accountId,
209+ }).pipe(
210+ Effect.mapError((cause) =>
211+ cause instanceof Error && cause.message.startsWith("Source not found:")
212+ ? sourceOps.get.notFound(
213+ "Source not found",
214+ `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
215+ )
216+ : sourceOps.get.unknownStorage(
217+ cause,
218+ "Failed projecting stored source",
219+ ),
214220 ),
215− ),
216− ),
221+ );
222+ }),
217223 ));
218224
219225 export const updateSource = (input: {
@@ -255,19 +261,21 @@ export const updateSource = (input: { · 35 unmodified lines
255261 ),
256262 );
257263
258− yield* mapPersistenceError(
264+ const persistedSource = yield* mapPersistenceError(
259265 sourceOps.update.child("persist"),
260266 persistSource(store, updatedSource, {
261267 actorAccountId: actor.principal.accountId,
262268 }),
263269 );
264270
265− return yield* syncArtifactsForSource({
271+ const synchronizedSource = yield* syncArtifactsForSource({
266272 store,
267− source: updatedSource,
273+ source: persistedSource,
268274 actorAccountId: actor.principal.accountId,
269275 operation: sourceOps.update,
270276 });
277+
278+ return synchronizedSource;
271279 }),
272280 ));
273281
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+2−0
@@ -589,6 +589,7 @@ const persistConnectedEchoTool = (input: { · 588 unmodified lines
589589 const source: Source = {
590590 id: input.sourceId,
591591 workspaceId: input.workspaceId,
592+ configKey: null,
592593 name: "Counted MCP",
593594 kind: "mcp",
594595 endpoint: input.endpoint,
@@ -801,6 +802,7 @@ const persistConnectedGithubOpenApiSource = (input: { · 206 unmodified lines
801802 const source: Source = {
802803 id: input.sourceId,
803804 workspaceId: input.workspaceId,
805+ configKey: null,
804806 name: "GitHub",
805807 kind: "openapi",
806808 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
3737 type RuntimeSourceAuthService,
3838 } from "./source-auth-service";
3939 import {
40+ loadSourceWithRecipe,
4041 loadWorkspaceSourceRecipeToolByPath,
4142 loadWorkspaceSourceRecipeToolIndex,
43+ loadWorkspaceSourceRecipes,
4244 type LoadedSourceRecipeToolIndexEntry,
4345 } from "./source-recipes-runtime";
4446 import { resolveSourceAuthMaterial } from "./source-auth-material";
@@ -53,6 +55,14 @@ import { · 8 unmodified lines
5355 evaluateInvocationPolicy,
5456 type InvocationDescriptor,
5557 } from "./invocation-policy-engine";
58+import {
59+ loadRuntimeLocalWorkspacePolicies,
60+} from "./policies-operations";
61+import {
62+ getRuntimeLocalWorkspaceOption,
63+ RuntimeLocalWorkspaceService,
64+ type RuntimeLocalWorkspaceState,
65+} from "./local-runtime-context";
5666
5767 const asToolPath = (value: string): ToolPath => value as ToolPath;
5868
@@ -183,28 +193,40 @@ const loadWorkspaceSchemaBundle = (input: { · 124 unmodified lines
183193 const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(
184194 input.id,
185195 );
186− if (Option.isNone(bundle)) {
187− return null;
188− }
189−
190− const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
191− input.workspaceId,
192− );
193− if (
194− !sourceRecords.some(
195− (sourceRecord) =>
196− sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
197− )
198− ) {
199− return null;
196+ if (Option.isSome(bundle)) {
197+ const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
198+ input.workspaceId,
199+ );
200+ if (
201+ sourceRecords.some(
202+ (sourceRecord) =>
203+ sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
204+ )
205+ ) {
206+ return {
207+ id: bundle.value.id,
208+ kind: bundle.value.bundleKind,
209+ hash: bundle.value.contentHash,
210+ refsJson: bundle.value.refsJson,
211+ };
212+ }
200213 }
201214
202− return {
203− id: bundle.value.id,
204− kind: bundle.value.bundleKind,
205− hash: bundle.value.contentHash,
206− refsJson: bundle.value.refsJson,
207− };
215+ const recipes = yield* loadWorkspaceSourceRecipes({
216+ rows: input.rows,
217+ workspaceId: input.workspaceId,
218+ });
219+ const localBundle = recipes
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;
208230 });
209231
210232 const scoreRecipeTool = (
@@ -380,7 +402,7 @@ const authorizePersistedToolInvocation = (input: { · 169 unmodified lines
380402 cause instanceof Error ? cause : new Error(String(cause)),
381403 ),
382404 );
383− const policies = Option.isSome(workspace)
405+ const storedPolicies = Option.isSome(workspace)
384406 ? yield* input.rows.policies
385407 .listForWorkspaceContext({
386408 organizationId: workspace.value.organizationId,
@@ -392,6 +414,20 @@ const authorizePersistedToolInvocation = (input: { · 5 unmodified lines
392414 ),
393415 )
394416 : [];
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+ ];
395431
396432 const decision = evaluateInvocationPolicy({
397433 descriptor: input.descriptor,
@@ -471,14 +507,28 @@ const authorizePersistedToolInvocation = (input: { · 73 unmodified lines
471507 }
472508 });
473509
510+const 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+
474523 const createWorkspaceToolCatalog = (input: {
475524 workspaceId: Source["workspaceId"];
476525 accountId: AccountId;
477526 rows: SqlControlPlaneRows;
478527 executorCatalog: ToolCatalog;
528+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
479529 }): ToolCatalog => ({
480530 listNamespaces: ({ limit }) =>
481− Effect.gen(function* () {
531+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
482532 const [recipeTools, executor] = yield* Effect.all([
483533 loadWorkspaceRecipeTools({
484534 rows: input.rows,
@@ -513,10 +563,10 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines
513563 return [...merged.values()]
514564 .sort((left, right) => left.namespace.localeCompare(right.namespace))
515565 .slice(0, limit);
516− }),
566+ }), input.runtimeLocalWorkspace),
517567
518568 listTools: ({ namespace, query, limit, includeSchemas = false }) =>
519− Effect.gen(function* () {
569+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
520570 const [recipeTools, executor] = yield* Effect.all([
521571 loadWorkspaceRecipeTools({
522572 rows: input.rows,
@@ -549,10 +599,10 @@ const createWorkspaceToolCatalog = (input: { · 26 unmodified lines
549599 return [...recipeTools.map((tool) => tool.descriptor), ...executor]
550600 .sort((left, right) => left.path.localeCompare(right.path))
551601 .slice(0, limit);
552− }),
602+ }), input.runtimeLocalWorkspace),
553603
554604 getToolByPath: ({ path, includeSchemas }) =>
555− Effect.gen(function* () {
605+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
556606 const executor = yield* input.executorCatalog.getToolByPath({
557607 path,
558608 includeSchemas,
@@ -569,17 +619,17 @@ const createWorkspaceToolCatalog = (input: { · 10 unmodified lines
569619 includeSchemas,
570620 });
571621 return recipeTool?.descriptor ?? null;
572− }),
622+ }), input.runtimeLocalWorkspace),
573623
574624 getSchemaBundle: ({ id }) =>
575− loadWorkspaceSchemaBundle({
625+ provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({
576626 rows: input.rows,
577627 workspaceId: input.workspaceId,
578628 id: SourceRecipeSchemaBundleIdSchema.make(id),
579− }),
629+ }), input.runtimeLocalWorkspace),
580630
581631 searchTools: ({ query, namespace, limit }) =>
582− Effect.gen(function* () {
632+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
583633 const queryTokens = tokenize(query);
584634 const [recipeTools, executor] = yield* Effect.all([
585635 loadWorkspaceRecipeTools({
@@ -614,7 +664,7 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines
614664 right.score - left.score || left.path.localeCompare(right.path),
615665 )
616666 .slice(0, limit);
617− }),
667+ }), input.runtimeLocalWorkspace),
618668 });
619669
620670 const createWorkspaceToolInvoker = (input: {
@@ -623,6 +673,7 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines
623673 rows: SqlControlPlaneRows;
624674 resolveSecretMaterial: ResolveSecretMaterial;
625675 sourceAuthService: RuntimeSourceAuthService;
676+ runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null;
626677 onElicitation?: Parameters<
627678 typeof makeToolInvokerFromTools
628679 >[0]["onElicitation"];
@@ -634,6 +685,7 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines
634685 workspaceId: input.workspaceId,
635686 accountId: input.accountId,
636687 sourceAuthService: input.sourceAuthService,
688+ runtimeLocalWorkspace: input.runtimeLocalWorkspace,
637689 });
638690 const executorCatalog = createToolCatalogFromTools({
639691 tools: executorTools,
@@ -643,6 +695,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines
643695 accountId: input.accountId,
644696 rows: input.rows,
645697 executorCatalog,
698+ runtimeLocalWorkspace: input.runtimeLocalWorkspace,
646699 });
647700 const systemTools = createSystemToolMap({ catalog });
648701 const systemToolPaths = new Set(Object.keys(systemTools));
@@ -661,7 +714,7 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines
661714 args: unknown;
662715 context?: Record<string, unknown>;
663716 }) =>
664− Effect.gen(function* () {
717+ provideRuntimeLocalWorkspace(Effect.gen(function* () {
665718 const recipeTool = yield* loadWorkspaceRecipeToolByPath({
666719 rows: input.rows,
667720 workspaceId: input.workspaceId,
@@ -700,17 +753,12 @@ const createWorkspaceToolInvoker = (input: { · 32 unmodified lines
700753 id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),
701754 })
702755 : null;
703− const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
704− input.workspaceId,
705− recipeTool.source.id,
706− );
707− let manifestJson: string | null = null;
708− if (Option.isSome(sourceRecord)) {
709− const revision = yield* input.rows.sourceRecipeRevisions.getById(
710− sourceRecord.value.recipeRevisionId,
711− );
712− manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null;
713− }
756+ const recipe = yield* loadSourceWithRecipe({
757+ rows: input.rows,
758+ workspaceId: input.workspaceId,
759+ sourceId: recipeTool.source.id,
760+ actorAccountId: input.accountId,
761+ });
714762
715763 return yield* getSourceAdapterForOperation(recipeTool.operation)
716764 .invokePersistedTool({
@@ -721,23 +769,26 @@ const createWorkspaceToolInvoker = (input: { · 4 unmodified lines
721769 path: invocation.path,
722770 operation: recipeTool.operation,
723771 schemaBundle,
724− manifestJson,
772+ manifestJson: recipe.revision.manifestJson,
725773 auth,
726774 args: invocation.args,
727775 context: invocation.context,
728776 onElicitation: input.onElicitation,
729777 });
730− });
778+ }), input.runtimeLocalWorkspace);
731779
732780 return {
733781 catalog,
734782 toolInvoker: {
735783 invoke: ({ path, args, context }) =>
736− systemToolPaths.has(path)
737− ? systemInvoker.invoke({ path, args, context })
738− : executorToolPaths.has(path)
739− ? executorInvoker.invoke({ path, args, context })
740− : invokePersistedTool({ path, args, context }),
784+ provideRuntimeLocalWorkspace(
785+ systemToolPaths.has(path)
786+ ? systemInvoker.invoke({ path, args, context })
787+ : executorToolPaths.has(path)
788+ ? executorInvoker.invoke({ path, args, context })
789+ : invokePersistedTool({ path, args, context }),
790+ input.runtimeLocalWorkspace,
791+ ),
741792 },
742793 };
743794 };
@@ -754,13 +805,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 10 unmodified lines
754805 });
755806
756807 return ({ workspaceId, accountId, onElicitation }) =>
757− Effect.sync(() => {
808+ Effect.gen(function* () {
809+ const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
758810 const { catalog, toolInvoker } = createWorkspaceToolInvoker({
759811 workspaceId,
760812 accountId,
761813 rows: input.rows,
762814 resolveSecretMaterial,
763815 sourceAuthService: input.sourceAuthService,
816+ runtimeLocalWorkspace,
764817 onElicitation,
765818 });
766819
packages/control-plane/src/schema/index.ts+1−0
@@ -13,6 +13,7 @@ export * from "./models/secret-material"; · 12 unmodified lines
1313 export * from "./models/source-auth-session";
1414 export * from "./models/policy";
1515 export * from "./models/local-installation";
16+export * from "./models/local-config";
1617 export * from "./models/execution";
1718 export * from "./models/source-inspection";
1819 export * from "./models/source-discovery";
packages/control-plane/src/schema/models/local-config.ts+184−0
@@ -0,0 +1,184 @@
1+import { Schema } from "effect";
2+
3+import { SourceTransportSchema, StringMapSchema } from "./source";
4+
5+export const LocalConfigSecretProviderSourceSchema = Schema.Literal(
6+ "env",
7+ "file",
8+ "exec",
9+);
10+
11+export const LocalConfigEnvSecretProviderSchema = Schema.Struct({
12+ source: Schema.Literal("env"),
13+});
14+
15+export const LocalConfigFileSecretProviderModeSchema = Schema.Literal(
16+ "singleValue",
17+ "json",
18+);
19+
20+export const LocalConfigFileSecretProviderSchema = Schema.Struct({
21+ source: Schema.Literal("file"),
22+ path: Schema.String,
23+ mode: Schema.optional(LocalConfigFileSecretProviderModeSchema),
24+});
25+
26+export 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+
35+export const LocalConfigSecretProviderSchema = Schema.Union(
36+ LocalConfigEnvSecretProviderSchema,
37+ LocalConfigFileSecretProviderSchema,
38+ LocalConfigExecSecretProviderSchema,
39+);
40+
41+export const LocalConfigExplicitSecretRefSchema = Schema.Struct({
42+ source: LocalConfigSecretProviderSourceSchema,
43+ provider: Schema.String,
44+ id: Schema.String,
45+});
46+
47+export const LocalConfigSecretInputSchema = Schema.Union(
48+ Schema.String,
49+ LocalConfigExplicitSecretRefSchema,
50+);
51+
52+export const LocalConfigSourceConnectionSchema = Schema.Struct({
53+ endpoint: Schema.String,
54+ auth: Schema.optional(LocalConfigSecretInputSchema),
55+});
56+
57+export const LocalConfigOpenApiBindingSchema = Schema.Struct({
58+ specUrl: Schema.String,
59+ defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
60+});
61+
62+export const LocalConfigGraphqlBindingSchema = Schema.Struct({
63+ defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
64+});
65+
66+export 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+
72+export 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+
80+const 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+
87+export const LocalConfigOpenApiSourceSchema = Schema.extend(
88+ LocalConfigSourceEntryBaseSchema,
89+ Schema.Struct({
90+ kind: Schema.Literal("openapi"),
91+ binding: LocalConfigOpenApiBindingSchema,
92+ }),
93+);
94+
95+export const LocalConfigGraphqlSourceSchema = Schema.extend(
96+ LocalConfigSourceEntryBaseSchema,
97+ Schema.Struct({
98+ kind: Schema.Literal("graphql"),
99+ binding: LocalConfigGraphqlBindingSchema,
100+ }),
101+);
102+
103+export const LocalConfigMcpSourceSchema = Schema.extend(
104+ LocalConfigSourceEntryBaseSchema,
105+ Schema.Struct({
106+ kind: Schema.Literal("mcp"),
107+ binding: LocalConfigMcpBindingSchema,
108+ }),
109+);
110+
111+export const LocalConfigGoogleDiscoverySourceSchema = Schema.extend(
112+ LocalConfigSourceEntryBaseSchema,
113+ Schema.Struct({
114+ kind: Schema.Literal("google_discovery"),
115+ binding: LocalConfigGoogleDiscoveryBindingSchema,
116+ }),
117+);
118+
119+export const LocalConfigSourceSchema = Schema.Union(
120+ LocalConfigOpenApiSourceSchema,
121+ LocalConfigGraphqlSourceSchema,
122+ LocalConfigMcpSourceSchema,
123+ LocalConfigGoogleDiscoverySourceSchema,
124+);
125+
126+export const LocalConfigPolicyActionSchema = Schema.Literal("allow", "deny");
127+export const LocalConfigPolicyApprovalSchema = Schema.Literal("auto", "manual");
128+
129+export 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+
137+export 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+
153+export const LocalConfigWorkspaceSchema = Schema.Struct({
154+ name: Schema.optional(Schema.String),
155+});
156+
157+export 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+
174+export type LocalConfigSecretProviderSource =
175+ typeof LocalConfigSecretProviderSourceSchema.Type;
176+export type LocalConfigSecretProvider =
177+ typeof LocalConfigSecretProviderSchema.Type;
178+export type LocalConfigExplicitSecretRef =
179+ typeof LocalConfigExplicitSecretRefSchema.Type;
180+export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type;
181+export type LocalConfigSource = typeof LocalConfigSourceSchema.Type;
182+export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type;
183+export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type;
184+export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type;
packages/control-plane/src/schema/models/policy.ts+51−10
@@ -1,7 +1,5 @@
11 import {
2− createInsertSchema,
32 createSelectSchema,
4− createUpdateSchema,
53 } from "drizzle-orm/effect-schema";
64 import { Schema } from "effect";
75
@@ -56,18 +54,61 @@ const policySchemaOverrides = { · 48 unmodified lines
5654 updatedAt: TimestampMsSchema,
5755 } as const;
5856
59−export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides);
57+const PolicyStorageSchema = createSelectSchema(policiesTable, policySchemaOverrides);
6058
61−export const PolicyInsertSchema = createInsertSchema(
62− policiesTable,
63− policySchemaOverrides,
64−);
59+const PolicyTypeSchema = Schema.Struct({
60+ id: PolicyIdSchema,
61+ configKey: Schema.NullOr(Schema.String),
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+});
6578
66−export const PolicyUpdateSchema = createUpdateSchema(
67− policiesTable,
68− policySchemaOverrides,
79+export const PolicySchema = Schema.transform(
80+ PolicyStorageSchema,
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+ },
69107 );
70108
109+export const PolicyInsertSchema = PolicyTypeSchema;
110+export const PolicyUpdateSchema = Schema.partial(PolicyTypeSchema);
111+
71112 export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type;
72113 export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type;
73114 export 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
9797 Schema.Struct({
9898 id: SourceIdSchema,
9999 workspaceId: WorkspaceIdSchema,
100+ configKey: Schema.NullOr(Schema.String),
100101 recipeId: SourceRecipeIdSchema,
101102 recipeRevisionId: SourceRecipeRevisionIdSchema,
102103 name: Schema.String,
@@ -117,6 +118,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 14 unmodified lines
117118 decode: (row, _input) => ({
118119 id: row.sourceId,
119120 workspaceId: row.workspaceId,
121+ configKey: null,
120122 recipeId: row.recipeId,
121123 recipeRevisionId: row.recipeRevisionId,
122124 name: row.name,
@@ -156,6 +158,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 33 unmodified lines
156158 export const SourceSchema = Schema.Struct({
157159 id: SourceIdSchema,
158160 workspaceId: WorkspaceIdSchema,
161+ configKey: Schema.NullOr(Schema.String),
159162 name: Schema.String,
160163 kind: SourceKindSchema,
161164 endpoint: Schema.String,
packages/kitchen-sink/src/source-runtime.test.ts+1−0
@@ -492,6 +492,7 @@ const openApiSource = (input: { · 491 unmodified lines
492492 }): Source => ({
493493 id: input.sourceId,
494494 workspaceId: input.workspaceId,
495+ configKey: null,
495496 name: input.displayName,
496497 kind: "openapi",
497498 endpoint: input.baseUrl,
packages/react/src/index.test.tsx+7−1
@@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 94 unmodified lines
9595
9696 const startControlPlaneServer = async (): Promise<ApiServer> => {
9797 const runtime = await Effect.runPromise(
98− createSqlControlPlaneRuntime({ localDataDir: ":memory:" }),
98+ createSqlControlPlaneRuntime({
99+ localDataDir: ":memory:",
100+ workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")),
101+ }),
99102 );
100103 const scope = await Effect.runPromise(Scope.make());
101104
@@ -743,3 +746,6 @@ describe("executor-react source hooks", () => { · 641 unmodified lines
743746 }),
744747 );
745748 });
749+import { mkdtempSync } from "node:fs";
750+import { tmpdir } from "node:os";
751+import { join } from "node:path";
packages/react/src/index.ts+1−0
@@ -661,6 +661,7 @@ const createOptimisticSource = (input: { · 660 unmodified lines
661661 return {
662662 id: `src_optimistic_${crypto.randomUUID()}` as Source["id"],
663663 workspaceId: input.workspaceId,
664+ configKey: null,
664665 name: input.payload.name,
665666 kind: input.payload.kind,
666667 endpoint: input.payload.endpoint,
packages/runtime-ses/src/index.ts+0−1
@@ -237,4 +237,3 @@ export const makeSesExecutor = ( · 236 unmodified lines
237237 ): CodeExecutor => ({
238238 execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker),
239239 });
240−
packages/runtime-ses/src/sandbox-worker.mjs+9−1
@@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines
2424
2525 const formatLogLine = (args) => args.map(formatLogArg).join(" ");
2626
27+const toSerializableValue = (value) => {
28+ if (typeof value === "undefined") {
29+ return undefined;
30+ }
31+
32+ return JSON.parse(JSON.stringify(value));
33+};
34+
2735 const buildExecutionSource = (code) => {
2836 const trimmed = code.trim();
2937 const looksLikeArrowFunction =
@@ -157,7 +165,7 @@ process.on("message", async (message) => { · 127 unmodified lines
157165 process.send?.({
158166 type: "result",
159167 id: message.id,
160− value: result,
168+ value: toSerializableValue(result),
161169 logs,
162170 });
163171 } catch (error) {
packages/server/src/index.ts+2−0
@@ -68,6 +68,7 @@ export type StartLocalExecutorServerOptions = { · 67 unmodified lines
6868 readonly port?: number;
6969 readonly host?: string;
7070 readonly localDataDir?: string;
71+ readonly workspaceRoot?: string;
7172 readonly migrationsFolder?: string;
7273 readonly pidFile?: string;
7374 readonly executionResolver?: ResolveExecutionEnvironment;
@@ -100,6 +101,7 @@ const createRuntime = ( · 26 unmodified lines
100101 createSqlControlPlaneRuntime({
101102 localDataDir,
102103 migrationsFolder: options.migrationsFolder,
104+ workspaceRoot: options.workspaceRoot,
103105 executionResolver: options.executionResolver,
104106 resolveSecretMaterial: options.resolveSecretMaterial,
105107 getLocalServerBaseUrl,