Unified · Split
| @@ -100,7 +100,12 @@ describe("portable distribution flow", () => { · 99 unmodified lines | ||
| 100 | 100 | const distDir = join(tempRoot, "dist"); |
| 101 | 101 | const homeDir = join(tempRoot, "home"); |
| 102 | 102 | const binDir = join(homeDir, ".local", "bin"); |
| 103 | − const installHome = join(homeDir, ".local", "share", "executor"); | |
| 103 | + const installHome = process.platform === "darwin" | |
| 104 | + ? join(homeDir, "Library", "Application Support", "Executor") | |
| 105 | + : join(homeDir, ".local", "share", "executor"); | |
| 106 | + const runHome = process.platform === "darwin" | |
| 107 | + ? join(installHome, "run") | |
| 108 | + : join(homeDir, ".local", "state", "executor", "run"); | |
| 104 | 109 | const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor"); |
| 105 | 110 | const baseUrl = `http://127.0.0.1:${await allocatePort()}`; |
| 106 | 111 | |
| @@ -176,9 +181,9 @@ describe("portable distribution flow", () => { · 69 unmodified lines | ||
| 176 | 181 | expect(status.reachable).toBe(true); |
| 177 | 182 | expect(status.pidRunning).toBe(true); |
| 178 | 183 | expect(status.installation).not.toBeNull(); |
| 179 | − expect(status.logFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true); | |
| 184 | + expect(status.logFile.startsWith(runHome)).toBe(true); | |
| 180 | 185 | expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true); |
| 181 | − expect(status.pidFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true); | |
| 186 | + expect(status.pidFile.startsWith(runHome)).toBe(true); | |
| 182 | 187 | |
| 183 | 188 | const html = await fetch(new URL("/", baseUrl)); |
| 184 | 189 | expect(html.status).toBe(200); |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 5 | |
| 4 | 6 | import { |
| 5 | 7 | HttpApi, |
| @@ -7,7 +9,9 @@ import { · 1 unmodified line | ||
| 7 | 9 | HttpApiGroup, |
| 8 | 10 | HttpApiSchema, |
| 9 | 11 | OpenApi, |
| 12 | + FileSystem, | |
| 10 | 13 | } from "@effect/platform"; |
| 14 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 11 | 15 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | 16 | import { assertTrue } from "@effect/vitest/utils"; |
| 13 | 17 | import * as Effect from "effect/Effect"; |
| @@ -57,7 +61,160 @@ const executionResolver: ResolveExecutionEnvironment = () => · 43 unmodified lines | ||
| 57 | 61 | }), |
| 58 | 62 | }); |
| 59 | 63 | |
| 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({ | |
| 61 | 218 | port: 0, |
| 62 | 219 | localDataDir: ":memory:", |
| 63 | 220 | executionResolver, |
| @@ -578,28 +735,6 @@ const extractUrlInteractionUrl = (payloadJson: string): string => { · 514 unmodified lines | ||
| 578 | 735 | return url; |
| 579 | 736 | }; |
| 580 | 737 | |
| 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 | − | |
| 603 | 738 | describe("local-executor-server", () => { |
| 604 | 739 | it.scoped("serves the control-plane API and executes code", () => |
| 605 | 740 | Effect.gen(function* () { |
| @@ -662,7 +797,7 @@ describe("local-executor-server", () => { · 56 unmodified lines | ||
| 662 | 797 | |
| 663 | 798 | it.scoped("serves only execute over MCP when elicitation is supported", () => |
| 664 | 799 | Effect.gen(function* () { |
| 665 | − const server = yield* createLocalExecutorServer({ | |
| 800 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 666 | 801 | port: 0, |
| 667 | 802 | localDataDir: ":memory:", |
| 668 | 803 | executionResolver: gatedExecutionResolver, |
| @@ -719,7 +854,7 @@ describe("local-executor-server", () => { · 50 unmodified lines | ||
| 719 | 854 | |
| 720 | 855 | it.scoped("serves execute and resume over MCP when elicitation is unavailable", () => |
| 721 | 856 | Effect.gen(function* () { |
| 722 | − const server = yield* createLocalExecutorServer({ | |
| 857 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 723 | 858 | port: 0, |
| 724 | 859 | localDataDir: ":memory:", |
| 725 | 860 | executionResolver: gatedExecutionResolver, |
| @@ -798,7 +933,7 @@ describe("local-executor-server", () => { · 72 unmodified lines | ||
| 798 | 933 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 799 | 934 | ); |
| 800 | 935 | |
| 801 | − const server = yield* createLocalExecutorServer({ | |
| 936 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 802 | 937 | port: 0, |
| 803 | 938 | localDataDir: ":memory:", |
| 804 | 939 | }); |
| @@ -879,7 +1014,7 @@ describe("local-executor-server", () => { · 74 unmodified lines | ||
| 879 | 1014 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 880 | 1015 | ); |
| 881 | 1016 | |
| 882 | − const server = yield* createLocalExecutorServer({ | |
| 1017 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 883 | 1018 | port: 0, |
| 884 | 1019 | localDataDir: ":memory:", |
| 885 | 1020 | }); |
| @@ -957,7 +1092,7 @@ describe("local-executor-server", () => { · 71 unmodified lines | ||
| 957 | 1092 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 958 | 1093 | ); |
| 959 | 1094 | |
| 960 | − const server = yield* createLocalExecutorServer({ | |
| 1095 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 961 | 1096 | port: 0, |
| 962 | 1097 | localDataDir: ":memory:", |
| 963 | 1098 | }); |
| @@ -1046,7 +1181,7 @@ describe("local-executor-server", () => { · 82 unmodified lines | ||
| 1046 | 1181 | }), |
| 1047 | 1182 | ); |
| 1048 | 1183 | |
| 1049 | − const server = yield* createLocalExecutorServer({ | |
| 1184 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1050 | 1185 | port: 0, |
| 1051 | 1186 | localDataDir: ":memory:", |
| 1052 | 1187 | }); |
| @@ -1092,7 +1227,7 @@ describe("local-executor-server", () => { · 39 unmodified lines | ||
| 1092 | 1227 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1093 | 1228 | ); |
| 1094 | 1229 | |
| 1095 | − const server = yield* createLocalExecutorServer({ | |
| 1230 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1096 | 1231 | port: 0, |
| 1097 | 1232 | localDataDir: ":memory:", |
| 1098 | 1233 | }); |
| @@ -1129,29 +1264,28 @@ describe("local-executor-server", () => { · 30 unmodified lines | ||
| 1129 | 1264 | }), |
| 1130 | 1265 | ); |
| 1131 | 1266 | 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 | + } | |
| 1144 | 1281 | |
| 1145 | − const sources = yield* client.sources.list({ | |
| 1146 | − path: { | |
| 1147 | − workspaceId: installation.workspaceId, | |
| 1148 | − }, | |
| 1282 | + yield* Effect.sleep("100 millis"); | |
| 1283 | + } | |
| 1149 | 1284 | }); |
| 1150 | 1285 | |
| 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"); | |
| 1155 | 1289 | |
| 1156 | 1290 | const toolCall = yield* client.executions.create({ |
| 1157 | 1291 | path: { |
| @@ -1175,7 +1309,7 @@ describe("local-executor-server", () => { · 17 unmodified lines | ||
| 1175 | 1309 | Effect.promise(() => startMutatingOpenApiDemoServer()), |
| 1176 | 1310 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1177 | 1311 | ); |
| 1178 | − const server = yield* createLocalExecutorServer({ | |
| 1312 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1179 | 1313 | port: 0, |
| 1180 | 1314 | localDataDir: ":memory:", |
| 1181 | 1315 | }); |
| @@ -1277,7 +1411,7 @@ describe("local-executor-server", () => { · 95 unmodified lines | ||
| 1277 | 1411 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1278 | 1412 | ); |
| 1279 | 1413 | |
| 1280 | − const server = yield* createLocalExecutorServer({ | |
| 1414 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1281 | 1415 | port: 0, |
| 1282 | 1416 | localDataDir: ":memory:", |
| 1283 | 1417 | }); |
| @@ -1345,7 +1479,7 @@ describe("local-executor-server", () => { · 61 unmodified lines | ||
| 1345 | 1479 | |
| 1346 | 1480 | it.scoped("marks execution failed when a configured MCP endpoint is invalid", () => |
| 1347 | 1481 | Effect.gen(function* () { |
| 1348 | − const server = yield* createLocalExecutorServer({ | |
| 1482 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1349 | 1483 | port: 0, |
| 1350 | 1484 | localDataDir: ":memory:", |
| 1351 | 1485 | }); |
| @@ -1358,123 +1492,12 @@ describe("local-executor-server", () => { · 6 unmodified lines | ||
| 1358 | 1492 | baseUrl: server.baseUrl, |
| 1359 | 1493 | accountId: installation.accountId, |
| 1360 | 1494 | }); |
| 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", | |
| 1421 | 1498 | endpoint: "http://127.0.0.1:PORT/mcp", |
| 1422 | − status: "connected", | |
| 1423 | − enabled: true, | |
| 1499 | + name: "Demo", | |
| 1424 | 1500 | namespace: "demo", |
| 1425 | − importAuthPolicy: "reuse_runtime", | |
| 1426 | − bindingConfigJson: JSON.stringify({ | |
| 1427 | − adapterKey: "mcp", | |
| 1428 | − version: 1, | |
| 1429 | − payload: { | |
| 1430 | − transport: "streamable-http", | |
| 1431 | − queryParams: null, | |
| 1432 | − headers: null, | |
| 1433 | − }, | |
| 1434 | − }), | |
| 1435 | − sourceHash: null, | |
| 1436 | − lastError: null, | |
| 1437 | − createdAt: now, | |
| 1438 | − updatedAt: now, | |
| 1439 | − }); | |
| 1440 | − yield* server.runtime.persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 1441 | − recipeRevisionId, | |
| 1442 | − documents: [{ | |
| 1443 | − id: `src_recipe_doc_${sourceId}`, | |
| 1444 | − recipeRevisionId, | |
| 1445 | − documentKind: "mcp_manifest", | |
| 1446 | − documentKey: "manifest", | |
| 1447 | − contentText: manifestJson, | |
| 1448 | − contentHash: `manifest_${sourceId}`, | |
| 1449 | − fetchedAt: now, | |
| 1450 | − createdAt: now, | |
| 1451 | − updatedAt: now, | |
| 1452 | − }], | |
| 1453 | − }); | |
| 1454 | − yield* server.runtime.persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 1455 | − recipeRevisionId, | |
| 1456 | − operations: [{ | |
| 1457 | − id: `src_recipe_op_${sourceId}`, | |
| 1458 | − recipeRevisionId, | |
| 1459 | − operationKey: "gated_echo", | |
| 1460 | − transportKind: "mcp", | |
| 1461 | − toolId: "gated_echo", | |
| 1462 | − title: "gated_echo", | |
| 1463 | − description: "Asks for approval before echoing a value", | |
| 1464 | − operationKind: "unknown", | |
| 1465 | − searchText: "demo.gated_echo gated echo approval", | |
| 1466 | − inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null, | |
| 1467 | − outputSchemaJson: null, | |
| 1468 | − providerKind: "mcp", | |
| 1469 | − providerDataJson: JSON.stringify({ | |
| 1470 | − kind: "mcp", | |
| 1471 | − toolId: "gated_echo", | |
| 1472 | − toolName: "gated_echo", | |
| 1473 | − description: "Asks for approval before echoing a value", | |
| 1474 | − }), | |
| 1475 | − createdAt: now, | |
| 1476 | − updatedAt: now, | |
| 1477 | − }], | |
| 1478 | 1501 | }); |
| 1479 | 1502 | |
| 1480 | 1503 | const execution = yield* client.executions.create({ |
| @@ -140,6 +140,7 @@ const seedWorkspaceSourceState = (input: { · 139 unmodified lines | ||
| 140 | 140 | yield* input.persistence.rows.sources.insert({ |
| 141 | 141 | id: input.sourceId, |
| 142 | 142 | workspaceId: input.workspaceId, |
| 143 | + configKey: null, | |
| 143 | 144 | recipeId, |
| 144 | 145 | recipeRevisionId, |
| 145 | 146 | name: "Github", |
| @@ -277,6 +278,7 @@ describe("control-plane-persistence-drizzle", () => { · 131 unmodified lines | ||
| 277 | 278 | yield* persistence.rows.sources.insert({ |
| 278 | 279 | id: SourceIdSchema.make("src_1"), |
| 279 | 280 | workspaceId, |
| 281 | + configKey: null, | |
| 280 | 282 | recipeId: SourceRecipeIdSchema.make("src_recipe_1"), |
| 281 | 283 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"), |
| 282 | 284 | name: "Github", |
| @@ -295,6 +297,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines | ||
| 295 | 297 | |
| 296 | 298 | yield* persistence.rows.policies.insert({ |
| 297 | 299 | id: PolicyIdSchema.make("pol_1"), |
| 300 | + configKey: null, | |
| 298 | 301 | scopeType: "workspace", |
| 299 | 302 | organizationId, |
| 300 | 303 | workspaceId, |
| @@ -860,6 +863,7 @@ describe("control-plane-persistence-drizzle", () => { · 559 unmodified lines | ||
| 860 | 863 | yield* persistence.rows.sources.insert({ |
| 861 | 864 | id: SourceIdSchema.make("src_shared_removed"), |
| 862 | 865 | workspaceId: removedWorkspaceId, |
| 866 | + configKey: null, | |
| 863 | 867 | recipeId: sharedRecipeId, |
| 864 | 868 | recipeRevisionId: sharedRecipeRevisionId, |
| 865 | 869 | name: "Github Removed", |
| @@ -878,6 +882,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines | ||
| 878 | 882 | yield* persistence.rows.sources.insert({ |
| 879 | 883 | id: SourceIdSchema.make("src_shared_remaining"), |
| 880 | 884 | workspaceId: remainingWorkspaceId, |
| 885 | + configKey: null, | |
| 881 | 886 | recipeId: sharedRecipeId, |
| 882 | 887 | recipeRevisionId: sharedRecipeRevisionId, |
| 883 | 888 | name: "Github Remaining", |
| @@ -188,6 +188,7 @@ const seedMigratedSourceRecipe = (input: { · 187 unmodified lines | ||
| 188 | 188 | yield* input.persistence.rows.sources.insert({ |
| 189 | 189 | id: input.sourceId, |
| 190 | 190 | workspaceId: input.workspaceId, |
| 191 | + configKey: null, | |
| 191 | 192 | recipeId, |
| 192 | 193 | recipeRevisionId, |
| 193 | 194 | name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo", |
| @@ -823,24 +824,43 @@ describe("code-migrations", () => { · 629 unmodified lines | ||
| 823 | 824 | createdAt: now, |
| 824 | 825 | updatedAt: now, |
| 825 | 826 | }))); |
| 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 | + `); | |
| 844 | 864 | await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({ |
| 845 | 865 | id: authArtifactId as any, |
| 846 | 866 | workspaceId, |
| @@ -86,7 +86,8 @@ export const createPoliciesRepo = ( · 85 unmodified lines | ||
| 86 | 86 | |
| 87 | 87 | insert: (policy: Policy) => |
| 88 | 88 | client.use("rows.policies.insert", async (db) => { |
| 89 | − await db.insert(tables.policiesTable).values(policy); | |
| 89 | + const { configKey: _configKey, ...row } = policy; | |
| 90 | + await db.insert(tables.policiesTable).values(row); | |
| 90 | 91 | }), |
| 91 | 92 | |
| 92 | 93 | update: ( |
| @@ -94,9 +95,10 @@ export const createPoliciesRepo = ( · 1 unmodified line | ||
| 94 | 95 | patch: Partial<Omit<Policy, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">>, |
| 95 | 96 | ) => |
| 96 | 97 | client.use("rows.policies.update", async (db) => { |
| 98 | + const { configKey: _configKey, ...rowPatch } = patch; | |
| 97 | 99 | const rows = await db |
| 98 | 100 | .update(tables.policiesTable) |
| 99 | − .set(patch) | |
| 101 | + .set(rowPatch) | |
| 100 | 102 | .where(eq(tables.policiesTable.id, policyId)) |
| 101 | 103 | .returning(); |
| 102 | 104 | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import { createServer } from "node:http"; |
| 2 | +import { mkdtempSync, readFileSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 2 | 5 | |
| 3 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 7 | import { assertTrue } from "@effect/vitest/utils"; |
| @@ -12,8 +15,6 @@ import { · 7 unmodified lines | ||
| 12 | 15 | ExecutionInteractionIdSchema, |
| 13 | 16 | SecretMaterialIdSchema, |
| 14 | 17 | SourceIdSchema, |
| 15 | − SourceRecipeIdSchema, | |
| 16 | − SourceRecipeRevisionIdSchema, | |
| 17 | 18 | } from "#schema"; |
| 18 | 19 | import type { ToolPath } from "@executor/codemode-core"; |
| 19 | 20 | |
| @@ -21,24 +22,19 @@ import { · 1 unmodified line | ||
| 21 | 22 | createSqlControlPlaneRuntime, |
| 22 | 23 | LiveExecutionManagerService, |
| 23 | 24 | } from "./index"; |
| 25 | +import { createSourceFromPayload } from "./source-definitions"; | |
| 24 | 26 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; |
| 27 | +import { persistSource } from "./source-store"; | |
| 25 | 28 | import { withControlPlaneClient } from "./test-http-client"; |
| 26 | 29 | |
| 27 | 30 | const makeRuntime = Effect.acquireRelease( |
| 28 | − createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | |
| 31 | + createSqlControlPlaneRuntime({ | |
| 32 | + localDataDir: ":memory:", | |
| 33 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")), | |
| 34 | + }), | |
| 29 | 35 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 30 | 36 | ); |
| 31 | 37 | |
| 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 | − | |
| 42 | 38 | type OpenApiSpecServer = { |
| 43 | 39 | baseUrl: string; |
| 44 | 40 | specUrl: string; |
| @@ -123,6 +119,64 @@ const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => · 78 unmodified lines | ||
| 123 | 119 | ); |
| 124 | 120 | |
| 125 | 121 | 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 | + | |
| 126 | 180 | it.scoped("supports full CRUD flow over HTTP API", () => |
| 127 | 181 | Effect.gen(function* () { |
| 128 | 182 | const runtime = yield* makeRuntime; |
| @@ -293,24 +347,32 @@ describe("control-plane-runtime", () => { · 164 unmodified lines | ||
| 293 | 347 | updatedAt: now, |
| 294 | 348 | }); |
| 295 | 349 | |
| 296 | − yield* runtime.persistence.rows.sources.insert({ | |
| 297 | − id: sourceId, | |
| 350 | + const localSource = yield* createSourceFromPayload({ | |
| 298 | 351 | 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 | + ); | |
| 314 | 376 | |
| 315 | 377 | const interactionFiber = yield* Effect.gen(function* () { |
| 316 | 378 | const liveExecutionManager = yield* LiveExecutionManagerService; |
| @@ -448,24 +510,32 @@ describe("control-plane-runtime", () => { · 131 unmodified lines | ||
| 448 | 510 | updatedAt: now, |
| 449 | 511 | }); |
| 450 | 512 | |
| 451 | − yield* runtime.persistence.rows.sources.insert({ | |
| 452 | − id: sourceId, | |
| 513 | + const localSource = yield* createSourceFromPayload({ | |
| 453 | 514 | 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 | + ); | |
| 469 | 539 | |
| 470 | 540 | const interactionFiber = yield* Effect.gen(function* () { |
| 471 | 541 | const liveExecutionManager = yield* LiveExecutionManagerService; |
| @@ -1,3 +1,6 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 1 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 5 | import * as Effect from "effect/Effect"; |
| 3 | 6 | import * as Schema from "effect/Schema"; |
| @@ -39,6 +42,7 @@ const makeExecutionResolver = () => { · 35 unmodified lines | ||
| 39 | 42 | const makeRuntime = Effect.acquireRelease( |
| 40 | 43 | createSqlControlPlaneRuntime({ |
| 41 | 44 | localDataDir: ":memory:", |
| 45 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")), | |
| 42 | 46 | executionResolver: makeExecutionResolver(), |
| 43 | 47 | }), |
| 44 | 48 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| @@ -5,6 +5,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines | ||
| 5 | 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | 7 | 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"; | |
| 8 | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | 12 | import { assertTrue } from "@effect/vitest/utils"; |
| 10 | 13 | import * as Effect from "effect/Effect"; |
| @@ -283,6 +286,7 @@ describe("execution-mcp-resume", () => { · 272 unmodified lines | ||
| 283 | 286 | const runtime = yield* Effect.acquireRelease( |
| 284 | 287 | createSqlControlPlaneRuntime({ |
| 285 | 288 | localDataDir: ":memory:", |
| 289 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")), | |
| 286 | 290 | executionResolver: makeMcpExecutionResolver(mcpServer.endpoint), |
| 287 | 291 | }), |
| 288 | 292 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| @@ -2,7 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | ControlPlaneBadRequestError, |
| 3 | 3 | ControlPlaneNotFoundError, |
| 4 | 4 | ControlPlaneStorageError, |
| 5 | −} from "#api"; | |
| 5 | +} from "../api/errors"; | |
| 6 | 6 | import type { |
| 7 | 7 | CreateExecutionPayload, |
| 8 | 8 | ResumeExecutionPayload, |
| @@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines | ||
| 19 | 19 | import * as Cause from "effect/Cause"; |
| 20 | 20 | import * as Exit from "effect/Exit"; |
| 21 | 21 | import * as Schema from "effect/Schema"; |
| 22 | +import type { RuntimeLocalWorkspaceState } from "./local-runtime-context"; | |
| 23 | +import { RuntimeLocalWorkspaceService } from "./local-runtime-context"; | |
| 22 | 24 | |
| 23 | 25 | /** 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); | |
| 26 | 40 | if (Exit.isSuccess(exit)) return exit.value; |
| 27 | 41 | throw Cause.squash(exit.cause); |
| 28 | 42 | }; |
| @@ -106,6 +120,9 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 77 unmodified lines | ||
| 106 | 120 | return trimmed.length > 0 ? trimmed : null; |
| 107 | 121 | }; |
| 108 | 122 | |
| 123 | +const toSerializableValue = <A>(value: A): A => | |
| 124 | + JSON.parse(JSON.stringify(value)) as A; | |
| 125 | + | |
| 109 | 126 | const resolveLocalCredentialUrl = (input: { |
| 110 | 127 | baseUrl: string; |
| 111 | 128 | workspaceId: WorkspaceId; |
| @@ -205,6 +222,7 @@ export const createExecutorToolMap = (input: { · 93 unmodified lines | ||
| 205 | 222 | workspaceId: WorkspaceId; |
| 206 | 223 | accountId: AccountId; |
| 207 | 224 | sourceAuthService: RuntimeSourceAuthService; |
| 225 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | |
| 208 | 226 | }): ToolMap => ({ |
| 209 | 227 | "executor.sources.add": toTool({ |
| 210 | 228 | tool: { |
| @@ -281,10 +299,11 @@ export const createExecutorToolMap = (input: { · 70 unmodified lines | ||
| 281 | 299 | } |
| 282 | 300 | : undefined, |
| 283 | 301 | ), |
| 302 | + input.runtimeLocalWorkspace, | |
| 284 | 303 | ); |
| 285 | 304 | |
| 286 | 305 | if (result.kind === "connected") { |
| 287 | − return result.source; | |
| 306 | + return toSerializableValue(result.source); | |
| 288 | 307 | } |
| 289 | 308 | |
| 290 | 309 | if (result.kind === "credential_required") { |
| @@ -313,6 +332,7 @@ export const createExecutorToolMap = (input: { · 22 unmodified lines | ||
| 313 | 332 | invocation: context?.invocation, |
| 314 | 333 | onElicitation: context?.onElicitation, |
| 315 | 334 | }), |
| 335 | + input.runtimeLocalWorkspace, | |
| 316 | 336 | ); |
| 317 | 337 | |
| 318 | 338 | pendingArgs = pendingResult.credentialSlot === "import" |
| @@ -342,10 +362,11 @@ export const createExecutorToolMap = (input: { · 23 unmodified lines | ||
| 342 | 362 | } |
| 343 | 363 | : undefined, |
| 344 | 364 | ), |
| 365 | + input.runtimeLocalWorkspace, | |
| 345 | 366 | ); |
| 346 | 367 | |
| 347 | 368 | if (completed.kind === "connected") { |
| 348 | − return completed.source; | |
| 369 | + return toSerializableValue(completed.source); | |
| 349 | 370 | } |
| 350 | 371 | |
| 351 | 372 | if (completed.kind === "credential_required") { |
| @@ -385,19 +406,22 @@ export const createExecutorToolMap = (input: { · 33 unmodified lines | ||
| 385 | 406 | elicitationId: result.sessionId, |
| 386 | 407 | }, |
| 387 | 408 | }), |
| 409 | + input.runtimeLocalWorkspace, | |
| 388 | 410 | ); |
| 389 | 411 | |
| 390 | 412 | if (response.action !== "accept") { |
| 391 | 413 | throw new Error(`Source add was not completed for ${result.source.id}`); |
| 392 | 414 | } |
| 393 | 415 | |
| 394 | − return await runEffect( | |
| 416 | + const connected = await runEffect( | |
| 395 | 417 | input.sourceAuthService.getSourceById({ |
| 396 | 418 | workspaceId: input.workspaceId, |
| 397 | 419 | sourceId: result.source.id, |
| 398 | 420 | actorAccountId: input.accountId, |
| 399 | 421 | }), |
| 422 | + input.runtimeLocalWorkspace, | |
| 400 | 423 | ); |
| 424 | + return toSerializableValue(connected); | |
| 401 | 425 | }, |
| 402 | 426 | }, |
| 403 | 427 | metadata: { |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | SqlControlPlanePersistenceLive, |
| 14 | 14 | SqlControlPlanePersistenceService, |
| 15 | 15 | SqlControlPlaneRowsLive, |
| 16 | + SqlControlPlaneRowsService, | |
| 16 | 17 | SqlPersistenceBootstrapError, |
| 17 | 18 | type CreateSqlRuntimeOptions, |
| 18 | 19 | type SqlControlPlanePersistence, |
| @@ -21,25 +22,35 @@ import type { LocalInstallation } from "#schema"; · 2 unmodified lines | ||
| 21 | 22 | |
| 22 | 23 | import { |
| 23 | 24 | ControlPlaneAuthHeaders, |
| 24 | − RuntimeActorResolverLive, | |
| 25 | 25 | createHeaderActorResolver, |
| 26 | + RuntimeActorResolverLive, | |
| 26 | 27 | } from "./actor-resolver"; |
| 27 | 28 | import { type ResolveExecutionEnvironment } from "./execution-state"; |
| 28 | 29 | import { |
| 30 | + createLiveExecutionManager, | |
| 29 | 31 | LiveExecutionManagerLive, |
| 30 | 32 | LiveExecutionManagerService, |
| 31 | 33 | } from "./live-execution"; |
| 32 | 34 | 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"; | |
| 33 | 41 | import { |
| 34 | 42 | ControlPlaneStore, |
| 35 | 43 | ControlPlaneStoreLive, |
| 36 | 44 | } from "./store"; |
| 37 | 45 | import { |
| 46 | + createRuntimeSourceAuthService, | |
| 38 | 47 | RuntimeSourceAuthServiceLive, |
| 39 | 48 | RuntimeSourceAuthServiceTag, |
| 40 | 49 | } from "./source-auth-service"; |
| 41 | 50 | import type { ResolveSecretMaterial } from "./secret-material-providers"; |
| 51 | +import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | |
| 42 | 52 | import { |
| 53 | + createWorkspaceExecutionEnvironmentResolver, | |
| 43 | 54 | RuntimeExecutionResolverLive, |
| 44 | 55 | RuntimeExecutionResolverService, |
| 45 | 56 | } from "./workspace-execution-environment"; |
| @@ -68,6 +79,7 @@ export type RuntimeControlPlaneOptions = { · 22 unmodified lines | ||
| 68 | 79 | executionResolver?: ResolveExecutionEnvironment; |
| 69 | 80 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 70 | 81 | getLocalServerBaseUrl?: () => string | undefined; |
| 82 | + workspaceRoot?: string; | |
| 71 | 83 | }; |
| 72 | 84 | |
| 73 | 85 | const detailsFromCause = (cause: unknown): string => |
| @@ -96,6 +108,7 @@ const runtimeContextTags = [ · 22 unmodified lines | ||
| 96 | 108 | ControlPlaneActorResolver, |
| 97 | 109 | ControlPlaneStore, |
| 98 | 110 | LiveExecutionManagerService, |
| 111 | + RuntimeLocalWorkspaceService, | |
| 99 | 112 | RuntimeSourceAuthServiceTag, |
| 100 | 113 | RuntimeExecutionResolverService, |
| 101 | 114 | ] as const; |
| @@ -105,6 +118,7 @@ const createRuntimeLayerFromContext = ( · 3 unmodified lines | ||
| 105 | 118 | ControlPlaneActorResolver |
| 106 | 119 | | ControlPlaneStore |
| 107 | 120 | | LiveExecutionManagerService |
| 121 | + | RuntimeLocalWorkspaceService | |
| 108 | 122 | | RuntimeSourceAuthServiceTag |
| 109 | 123 | | RuntimeExecutionResolverService |
| 110 | 124 | >, |
| @@ -115,6 +129,7 @@ const createRuntimeLayerFromContext = ( · 4 unmodified lines | ||
| 115 | 129 | ControlPlaneActorResolver |
| 116 | 130 | | ControlPlaneStore |
| 117 | 131 | | LiveExecutionManagerService |
| 132 | + | RuntimeLocalWorkspaceService | |
| 118 | 133 | | RuntimeSourceAuthServiceTag |
| 119 | 134 | | RuntimeExecutionResolverService |
| 120 | 135 | >; |
| @@ -165,22 +180,59 @@ export const createSqlControlPlaneRuntime = ( · 44 unmodified lines | ||
| 165 | 180 | const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe( |
| 166 | 181 | Layer.provideMerge(SqlControlPlanePersistenceLive(options)), |
| 167 | 182 | ); |
| 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 | + )), | |
| 170 | 204 | ); |
| 171 | 205 | |
| 172 | − const context = yield* Layer.buildWithScope(runtimeLayer, scope).pipe( | |
| 206 | + const localInstallation = yield* getOrProvisionLocalInstallation({ | |
| 207 | + rows, | |
| 208 | + context: localWorkspaceContext, | |
| 209 | + }).pipe( | |
| 210 | + Effect.mapError(toLocalInstallationBootstrapError), | |
| 173 | 211 | Effect.catchAll((error) => |
| 174 | 212 | closeScope(scope).pipe( |
| 175 | 213 | Effect.zipRight(Effect.fail(error)), |
| 176 | 214 | )), |
| 177 | 215 | ); |
| 178 | 216 | |
| 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 | + ); | |
| 182 | 226 | |
| 183 | − const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe( | |
| 227 | + const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({ | |
| 228 | + rows, | |
| 229 | + context: localWorkspaceContext, | |
| 230 | + loadedConfig: loadedLocalConfig, | |
| 231 | + installation: { | |
| 232 | + workspaceId: localInstallation.workspaceId, | |
| 233 | + accountId: localInstallation.accountId, | |
| 234 | + }, | |
| 235 | + }).pipe( | |
| 184 | 236 | Effect.mapError(toLocalInstallationBootstrapError), |
| 185 | 237 | Effect.catchAll((error) => |
| 186 | 238 | closeScope(scope).pipe( |
| @@ -188,6 +240,63 @@ export const createSqlControlPlaneRuntime = ( · 1 unmodified line | ||
| 188 | 240 | )), |
| 189 | 241 | ); |
| 190 | 242 | |
| 243 | + const resolveSecretMaterial = | |
| 244 | + options.resolveSecretMaterial | |
| 245 | + ?? createDefaultSecretMaterialResolver({ | |
| 246 | + rows, | |
| 247 | + localConfig: effectiveLocalConfig, | |
| 248 | + workspaceRoot: localWorkspaceContext.workspaceRoot, | |
| 249 | + }); | |
| 250 | + | |
| 251 | + const liveExecutionManager = createLiveExecutionManager(); | |
| 252 | + const sourceAuthService = createRuntimeSourceAuthService({ | |
| 253 | + rows, | |
| 254 | + liveExecutionManager, | |
| 255 | + getLocalServerBaseUrl: options.getLocalServerBaseUrl, | |
| 256 | + localConfig: effectiveLocalConfig, | |
| 257 | + workspaceRoot: localWorkspaceContext.workspaceRoot, | |
| 258 | + localWorkspaceState: { | |
| 259 | + context: localWorkspaceContext, | |
| 260 | + installation: { | |
| 261 | + workspaceId: localInstallation.workspaceId, | |
| 262 | + accountId: localInstallation.accountId, | |
| 263 | + }, | |
| 264 | + loadedConfig: { | |
| 265 | + ...loadedLocalConfig, | |
| 266 | + config: effectiveLocalConfig, | |
| 267 | + }, | |
| 268 | + }, | |
| 269 | + }); | |
| 270 | + const executionResolver = | |
| 271 | + options.executionResolver | |
| 272 | + ?? createWorkspaceExecutionEnvironmentResolver({ | |
| 273 | + rows, | |
| 274 | + sourceAuthService, | |
| 275 | + resolveSecretMaterial, | |
| 276 | + }); | |
| 277 | + const actorResolver = | |
| 278 | + options.actorResolver ?? createHeaderActorResolver(rows); | |
| 279 | + | |
| 280 | + const runtimeContext = Context.empty().pipe( | |
| 281 | + Context.add(ControlPlaneActorResolver, actorResolver), | |
| 282 | + Context.add(ControlPlaneStore, rows), | |
| 283 | + Context.add(LiveExecutionManagerService, liveExecutionManager), | |
| 284 | + Context.add(RuntimeLocalWorkspaceService, { | |
| 285 | + context: localWorkspaceContext, | |
| 286 | + installation: { | |
| 287 | + workspaceId: localInstallation.workspaceId, | |
| 288 | + accountId: localInstallation.accountId, | |
| 289 | + }, | |
| 290 | + loadedConfig: { | |
| 291 | + ...loadedLocalConfig, | |
| 292 | + config: effectiveLocalConfig, | |
| 293 | + }, | |
| 294 | + }), | |
| 295 | + Context.add(RuntimeSourceAuthServiceTag, sourceAuthService), | |
| 296 | + Context.add(RuntimeExecutionResolverService, executionResolver), | |
| 297 | + ); | |
| 298 | + const concreteRuntimeLayer = createRuntimeLayerFromContext(runtimeContext); | |
| 299 | + | |
| 191 | 300 | return { |
| 192 | 301 | persistence, |
| 193 | 302 | localInstallation, |
| @@ -35,6 +35,7 @@ const basePolicy = ( · 34 unmodified lines | ||
| 35 | 35 | patch: Partial<Policy> = {}, |
| 36 | 36 | ): Policy => ({ |
| 37 | 37 | id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`), |
| 38 | + configKey: null, | |
| 38 | 39 | scopeType: "workspace", |
| 39 | 40 | organizationId, |
| 40 | 41 | workspaceId, |
| @@ -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; | |
| @@ -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"; | |
| @@ -1,12 +1,21 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 1 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 5 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | 6 | import * as Effect from "effect/Effect"; |
| 4 | 7 | |
| 5 | 8 | import { createSqlControlPlaneRuntime } from "./index"; |
| 6 | 9 | import { getOrProvisionLocalInstallation } from "./local-installation"; |
| 10 | +import { resolveLocalWorkspaceContext } from "./local-config"; | |
| 11 | + | |
| 12 | +const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-")); | |
| 7 | 13 | |
| 8 | 14 | const makeRuntime = Effect.acquireRelease( |
| 9 | − createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | |
| 15 | + createSqlControlPlaneRuntime({ | |
| 16 | + localDataDir: ":memory:", | |
| 17 | + workspaceRoot: TEST_WORKSPACE_ROOT, | |
| 18 | + }), | |
| 10 | 19 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 11 | 20 | ); |
| 12 | 21 | |
| @@ -33,9 +42,15 @@ describe("local-installation", () => { · 20 unmodified lines | ||
| 33 | 42 | it.scoped("is idempotent when loading the default local installation", () => |
| 34 | 43 | Effect.gen(function* () { |
| 35 | 44 | const runtime = yield* makeRuntime; |
| 45 | + const context = yield* Effect.promise(() => | |
| 46 | + resolveLocalWorkspaceContext({ workspaceRoot: TEST_WORKSPACE_ROOT }), | |
| 47 | + ); | |
| 36 | 48 | |
| 37 | 49 | const first = runtime.localInstallation; |
| 38 | − const second = yield* getOrProvisionLocalInstallation(runtime.persistence.rows); | |
| 50 | + const second = yield* getOrProvisionLocalInstallation({ | |
| 51 | + rows: runtime.persistence.rows, | |
| 52 | + context, | |
| 53 | + }); | |
| 39 | 54 | |
| 40 | 55 | expect(second.id).toBe(first.id); |
| 41 | 56 | expect(second.accountId).toBe(first.accountId); |
| @@ -14,7 +14,22 @@ import { · 13 unmodified lines | ||
| 14 | 14 | import * as Effect from "effect/Effect"; |
| 15 | 15 | import * as Option from "effect/Option"; |
| 16 | 16 | |
| 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 | +}; | |
| 18 | 33 | |
| 19 | 34 | const buildAccount = (now: number): Account => { |
| 20 | 35 | const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`); |
| @@ -22,7 +37,7 @@ const buildAccount = (now: number): Account => { · 1 unmodified line | ||
| 22 | 37 | return { |
| 23 | 38 | id, |
| 24 | 39 | provider: "local", |
| 25 | − subject: `local:${id}`, | |
| 40 | + subject: LOCAL_ACCOUNT_SUBJECT, | |
| 26 | 41 | email: null, |
| 27 | 42 | displayName: "Local User", |
| 28 | 43 | createdAt: now, |
| @@ -35,7 +50,7 @@ const buildOrganization = ( · 6 unmodified lines | ||
| 35 | 50 | now: number, |
| 36 | 51 | ): Organization => ({ |
| 37 | 52 | id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`), |
| 38 | − slug: "personal", | |
| 53 | + slug: PERSONAL_ORGANIZATION_SLUG, | |
| 39 | 54 | name: "Personal", |
| 40 | 55 | status: "active", |
| 41 | 56 | createdByAccountId: accountId, |
| @@ -63,57 +78,206 @@ const buildOwnerMembership = ( · 21 unmodified lines | ||
| 63 | 78 | const buildWorkspace = ( |
| 64 | 79 | organizationId: Organization["id"], |
| 65 | 80 | accountId: Account["id"], |
| 81 | + workspaceName: string, | |
| 66 | 82 | now: number, |
| 67 | 83 | ): Workspace => ({ |
| 68 | 84 | id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`), |
| 69 | 85 | organizationId, |
| 70 | − name: "Default", | |
| 86 | + name: workspaceName, | |
| 71 | 87 | createdByAccountId: accountId, |
| 72 | 88 | createdAt: now, |
| 73 | 89 | updatedAt: now, |
| 74 | 90 | }); |
| 75 | 91 | |
| 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 | + | |
| 76 | 194 | export const loadLocalInstallation = ( |
| 77 | 195 | rows: SqlControlPlaneRows, |
| 196 | + context: ResolvedLocalWorkspaceContext, | |
| 78 | 197 | ): Effect.Effect<LocalInstallation | null, unknown> => |
| 79 | − rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe( | |
| 198 | + rows.localInstallations.getById(InstallationIdSchema.make(context.installationId)).pipe( | |
| 80 | 199 | Effect.map((result) => (Option.isSome(result) ? result.value : null)), |
| 81 | 200 | ); |
| 82 | 201 | |
| 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> => | |
| 86 | 207 | Effect.gen(function* () { |
| 87 | − const existing = yield* rows.localInstallations.getById(LOCAL_INSTALLATION_ID); | |
| 208 | + const legacy = yield* input.rows.localInstallations.getById(LEGACY_LOCAL_INSTALLATION_ID); | |
| 209 | + if (Option.isNone(legacy)) { | |
| 210 | + return null; | |
| 211 | + } | |
| 212 | + | |
| 213 | + const migrated = createInstallationForWorkspace({ | |
| 214 | + installationId: InstallationIdSchema.make(input.context.installationId), | |
| 215 | + accountId: legacy.value.accountId, | |
| 216 | + organizationId: legacy.value.organizationId, | |
| 217 | + workspaceId: legacy.value.workspaceId, | |
| 218 | + now: input.now, | |
| 219 | + }); | |
| 220 | + yield* input.rows.localInstallations.upsert(migrated); | |
| 221 | + | |
| 222 | + yield* input.rows.workspaces.update(legacy.value.workspaceId, { | |
| 223 | + name: defaultWorkspaceDisplayName(input.context), | |
| 224 | + updatedAt: input.now, | |
| 225 | + }); | |
| 226 | + | |
| 227 | + return migrated; | |
| 228 | + }); | |
| 229 | + | |
| 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); | |
| 88 | 239 | if (Option.isSome(existing)) { |
| 89 | 240 | return existing.value; |
| 90 | 241 | } |
| 91 | 242 | |
| 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, | |
| 99 | 264 | accountId: account.id, |
| 100 | 265 | organizationId: organization.id, |
| 101 | 266 | workspaceId: workspace.id, |
| 102 | − createdAt: now, | |
| 103 | − updatedAt: now, | |
| 104 | − }; | |
| 267 | + now, | |
| 268 | + }); | |
| 105 | 269 | |
| 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); | |
| 110 | 271 | |
| 111 | 272 | return installation; |
| 112 | 273 | }); |
| 113 | 274 | |
| 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), | |
| 119 | 283 | ); |
| @@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines | ||
| 17 | 17 | import { |
| 18 | 18 | operationErrors, |
| 19 | 19 | } from "./operation-errors"; |
| 20 | +import { resolveLocalWorkspaceContext } from "./local-config"; | |
| 21 | +import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context"; | |
| 20 | 22 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; |
| 21 | 23 | import { |
| 22 | 24 | createSourceCredentialSelectionBearerContent, |
| @@ -181,8 +183,11 @@ const loadSourceCredentialInteraction = (input: { · 158 unmodified lines | ||
| 181 | 183 | export const getLocalInstallation = () => |
| 182 | 184 | Effect.gen(function* () { |
| 183 | 185 | const store = yield* ControlPlaneStore; |
| 186 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 187 | + const context = runtimeLocalWorkspace?.context | |
| 188 | + ?? (yield* Effect.promise(() => resolveLocalWorkspaceContext())); | |
| 184 | 189 | |
| 185 | − const installation = yield* loadLocalInstallation(store).pipe( | |
| 190 | + const installation = yield* loadLocalInstallation(store, context).pipe( | |
| 186 | 191 | Effect.mapError((error) => |
| 187 | 192 | error instanceof ControlPlanePersistenceError |
| 188 | 193 | ? localOps.installation.storage(error) |
| @@ -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>; | |
| @@ -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 | +}; | |
| @@ -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 | +}; | |
| @@ -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 | + }); | |
| @@ -2,7 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | ControlPlaneBadRequestError, |
| 3 | 3 | ControlPlaneNotFoundError, |
| 4 | 4 | ControlPlaneStorageError, |
| 5 | −} from "#api"; | |
| 5 | +} from "../api/errors"; | |
| 6 | 6 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | |
| @@ -2,7 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | ControlPlaneBadRequestError, |
| 3 | 3 | ControlPlaneNotFoundError, |
| 4 | 4 | ControlPlaneStorageError, |
| 5 | −} from "#api"; | |
| 5 | +} from "../api/errors"; | |
| 6 | 6 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | 7 | import type { Organization } from "#schema"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| @@ -1,9 +1,12 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | + | |
| 1 | 3 | import type { |
| 2 | 4 | CreatePolicyPayload, |
| 3 | 5 | UpdatePolicyPayload, |
| 4 | 6 | } from "../api/policies/api"; |
| 5 | 7 | import { |
| 6 | 8 | PolicyIdSchema, |
| 9 | + type LocalExecutorConfig, | |
| 7 | 10 | type OrganizationId, |
| 8 | 11 | type Policy, |
| 9 | 12 | type PolicyId, |
| @@ -12,6 +15,20 @@ import { · 2 unmodified lines | ||
| 12 | 15 | import * as Effect from "effect/Effect"; |
| 13 | 16 | import * as Option from "effect/Option"; |
| 14 | 17 | |
| 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"; | |
| 15 | 32 | import { |
| 16 | 33 | mapPersistenceError, |
| 17 | 34 | parseJsonString, |
| @@ -37,6 +54,138 @@ type PolicyScopeContext = { · 19 unmodified lines | ||
| 37 | 54 | workspaceId: Policy["workspaceId"]; |
| 38 | 55 | }; |
| 39 | 56 | |
| 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 | + | |
| 40 | 189 | const loadWorkspacePolicyContext = ( |
| 41 | 190 | store: ControlPlaneStoreShape, |
| 42 | 191 | operation: OperationErrors, |
| @@ -92,6 +241,35 @@ const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean · 49 unmodified lines | ||
| 92 | 241 | && policy.organizationId === scope.organizationId |
| 93 | 242 | && policy.workspaceId === scope.workspaceId; |
| 94 | 243 | |
| 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 | + | |
| 95 | 273 | const createScopedPolicy = (input: { |
| 96 | 274 | scope: PolicyScopeContext; |
| 97 | 275 | payload: CreatePolicyPayload; |
| @@ -101,8 +279,90 @@ const createScopedPolicy = (input: { · 3 unmodified lines | ||
| 101 | 279 | const now = Date.now(); |
| 102 | 280 | const operation = policyOps.create; |
| 103 | 281 | |
| 282 | + if (input.scope.workspaceId !== null) { | |
| 283 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({ | |
| 284 | + store, | |
| 285 | + workspaceId: input.scope.workspaceId, | |
| 286 | + }).pipe( | |
| 287 | + Effect.mapError((cause) => | |
| 288 | + operation.unknownStorage( | |
| 289 | + cause, | |
| 290 | + "Failed loading local workspace policies", | |
| 291 | + ), | |
| 292 | + ), | |
| 293 | + ); | |
| 294 | + if (localWorkspace !== null) { | |
| 295 | + yield* ensureLocalWorkspacePolicyCompatible(operation, input.payload); | |
| 296 | + | |
| 297 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 298 | + const policies = { | |
| 299 | + ...(projectConfig.policies ?? {}), | |
| 300 | + }; | |
| 301 | + const configKey = derivePolicyConfigKey({ | |
| 302 | + configKey: null, | |
| 303 | + resourcePattern: input.payload.resourcePattern ?? "*", | |
| 304 | + effect: input.payload.effect ?? "allow", | |
| 305 | + approvalMode: input.payload.approvalMode ?? "auto", | |
| 306 | + }, new Set(Object.keys(policies))); | |
| 307 | + const id = localWorkspace.workspaceState.policies[configKey]?.id | |
| 308 | + ?? localPolicyIdForConfigKey({ | |
| 309 | + workspaceRoot: localWorkspace.runtimeLocalWorkspace.context.workspaceRoot, | |
| 310 | + configKey, | |
| 311 | + }); | |
| 312 | + policies[configKey] = { | |
| 313 | + match: input.payload.resourcePattern ?? "*", | |
| 314 | + action: input.payload.effect ?? "allow", | |
| 315 | + approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto", | |
| 316 | + ...(input.payload.enabled === false ? { enabled: false } : {}), | |
| 317 | + ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}), | |
| 318 | + }; | |
| 319 | + const existingState = localWorkspace.workspaceState.policies[configKey]; | |
| 320 | + const workspaceState = { | |
| 321 | + ...localWorkspace.workspaceState, | |
| 322 | + policies: { | |
| 323 | + ...localWorkspace.workspaceState.policies, | |
| 324 | + [configKey]: { | |
| 325 | + id, | |
| 326 | + createdAt: existingState?.createdAt ?? now, | |
| 327 | + updatedAt: now, | |
| 328 | + }, | |
| 329 | + }, | |
| 330 | + }; | |
| 331 | + yield* writeLocalPolicyFiles({ | |
| 332 | + operation, | |
| 333 | + context: localWorkspace.runtimeLocalWorkspace.context, | |
| 334 | + projectConfig: { | |
| 335 | + ...projectConfig, | |
| 336 | + policies, | |
| 337 | + }, | |
| 338 | + workspaceState, | |
| 339 | + }); | |
| 340 | + | |
| 341 | + return { | |
| 342 | + id, | |
| 343 | + configKey, | |
| 344 | + scopeType: "workspace", | |
| 345 | + organizationId: input.scope.organizationId, | |
| 346 | + workspaceId: input.scope.workspaceId, | |
| 347 | + targetAccountId: null, | |
| 348 | + clientId: null, | |
| 349 | + resourceType: "tool_path", | |
| 350 | + resourcePattern: policies[configKey]!.match, | |
| 351 | + matchType: "glob", | |
| 352 | + effect: policies[configKey]!.action, | |
| 353 | + approvalMode: policies[configKey]!.approval === "manual" ? "required" : "auto", | |
| 354 | + argumentConditionsJson: null, | |
| 355 | + priority: policies[configKey]!.priority ?? 0, | |
| 356 | + enabled: policies[configKey]!.enabled ?? true, | |
| 357 | + createdAt: workspaceState.policies[configKey]!.createdAt, | |
| 358 | + updatedAt: now, | |
| 359 | + } satisfies Policy; | |
| 360 | + } | |
| 361 | + } | |
| 362 | + | |
| 104 | 363 | const policy: Policy = { |
| 105 | 364 | id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`), |
| 365 | + configKey: null, | |
| 106 | 366 | scopeType: input.scope.scopeType, |
| 107 | 367 | organizationId: input.scope.organizationId, |
| 108 | 368 | workspaceId: input.scope.workspaceId, |
| @@ -133,6 +393,10 @@ const createScopedPolicy = (input: { · 24 unmodified lines | ||
| 133 | 393 | store.policies.insert(policy), |
| 134 | 394 | ); |
| 135 | 395 | |
| 396 | + if (policy.workspaceId === null) { | |
| 397 | + return policy; | |
| 398 | + } | |
| 399 | + | |
| 136 | 400 | return policy; |
| 137 | 401 | })); |
| 138 | 402 | |
| @@ -142,6 +406,33 @@ const getScopedPolicy = (input: { · 3 unmodified lines | ||
| 142 | 406 | }) => |
| 143 | 407 | Effect.flatMap(ControlPlaneStore, (store) => |
| 144 | 408 | Effect.gen(function* () { |
| 409 | + if (input.scope.workspaceId !== null) { | |
| 410 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({ | |
| 411 | + store, | |
| 412 | + workspaceId: input.scope.workspaceId, | |
| 413 | + }).pipe( | |
| 414 | + Effect.mapError((cause) => | |
| 415 | + policyOps.get.unknownStorage( | |
| 416 | + cause, | |
| 417 | + "Failed loading local workspace policies", | |
| 418 | + ), | |
| 419 | + ), | |
| 420 | + ); | |
| 421 | + if (localWorkspace !== null) { | |
| 422 | + const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; | |
| 423 | + if (policy === null) { | |
| 424 | + return yield* Effect.fail( | |
| 425 | + policyOps.get.notFound( | |
| 426 | + "Policy not found", | |
| 427 | + `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`, | |
| 428 | + ), | |
| 429 | + ); | |
| 430 | + } | |
| 431 | + | |
| 432 | + return policy; | |
| 433 | + } | |
| 434 | + } | |
| 435 | + | |
| 145 | 436 | const existing = yield* policyOps.get.mapStorage( |
| 146 | 437 | store.policies.getById(input.policyId), |
| 147 | 438 | ); |
| @@ -165,6 +456,84 @@ const updateScopedPolicy = (input: { · 17 unmodified lines | ||
| 165 | 456 | }) => |
| 166 | 457 | Effect.flatMap(ControlPlaneStore, (store) => |
| 167 | 458 | Effect.gen(function* () { |
| 459 | + if (input.scope.workspaceId !== null) { | |
| 460 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({ | |
| 461 | + store, | |
| 462 | + workspaceId: input.scope.workspaceId, | |
| 463 | + }).pipe( | |
| 464 | + Effect.mapError((cause) => | |
| 465 | + policyOps.update.unknownStorage( | |
| 466 | + cause, | |
| 467 | + "Failed loading local workspace policies", | |
| 468 | + ), | |
| 469 | + ), | |
| 470 | + ); | |
| 471 | + if (localWorkspace !== null) { | |
| 472 | + yield* ensureLocalWorkspacePolicyCompatible(policyOps.update, input.payload); | |
| 473 | + const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; | |
| 474 | + if (existing === null || existing.configKey === null) { | |
| 475 | + return yield* Effect.fail( | |
| 476 | + policyOps.update.notFound( | |
| 477 | + "Policy not found", | |
| 478 | + `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`, | |
| 479 | + ), | |
| 480 | + ); | |
| 481 | + } | |
| 482 | + | |
| 483 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 484 | + const policies = { | |
| 485 | + ...(projectConfig.policies ?? {}), | |
| 486 | + }; | |
| 487 | + const existingConfig = policies[existing.configKey] ?? { | |
| 488 | + match: existing.resourcePattern, | |
| 489 | + action: existing.effect, | |
| 490 | + approval: existing.approvalMode === "required" ? "manual" : "auto", | |
| 491 | + }; | |
| 492 | + policies[existing.configKey] = { | |
| 493 | + ...existingConfig, | |
| 494 | + ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}), | |
| 495 | + ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}), | |
| 496 | + ...(input.payload.approvalMode !== undefined | |
| 497 | + ? { approval: input.payload.approvalMode === "required" ? "manual" : "auto" } | |
| 498 | + : {}), | |
| 499 | + ...(input.payload.enabled !== undefined ? { enabled: input.payload.enabled } : {}), | |
| 500 | + ...(input.payload.priority !== undefined ? { priority: input.payload.priority } : {}), | |
| 501 | + }; | |
| 502 | + const existingState = localWorkspace.workspaceState.policies[existing.configKey]; | |
| 503 | + const updatedAt = Date.now(); | |
| 504 | + const workspaceState = { | |
| 505 | + ...localWorkspace.workspaceState, | |
| 506 | + policies: { | |
| 507 | + ...localWorkspace.workspaceState.policies, | |
| 508 | + [existing.configKey]: { | |
| 509 | + id: existing.id, | |
| 510 | + createdAt: existingState?.createdAt ?? existing.createdAt, | |
| 511 | + updatedAt, | |
| 512 | + }, | |
| 513 | + }, | |
| 514 | + }; | |
| 515 | + yield* writeLocalPolicyFiles({ | |
| 516 | + operation: policyOps.update, | |
| 517 | + context: localWorkspace.runtimeLocalWorkspace.context, | |
| 518 | + projectConfig: { | |
| 519 | + ...projectConfig, | |
| 520 | + policies, | |
| 521 | + }, | |
| 522 | + workspaceState, | |
| 523 | + }); | |
| 524 | + | |
| 525 | + return { | |
| 526 | + ...existing, | |
| 527 | + resourcePattern: policies[existing.configKey]!.match, | |
| 528 | + effect: policies[existing.configKey]!.action, | |
| 529 | + approvalMode: policies[existing.configKey]!.approval === "manual" ? "required" : "auto", | |
| 530 | + priority: policies[existing.configKey]!.priority ?? 0, | |
| 531 | + enabled: policies[existing.configKey]!.enabled ?? true, | |
| 532 | + updatedAt, | |
| 533 | + } satisfies Policy; | |
| 534 | + } | |
| 535 | + } | |
| 536 | + | |
| 168 | 537 | const existing = yield* policyOps.update.mapStorage( |
| 169 | 538 | store.policies.getById(input.policyId), |
| 170 | 539 | ); |
| @@ -234,6 +603,10 @@ const updateScopedPolicy = (input: { · 63 unmodified lines | ||
| 234 | 603 | ); |
| 235 | 604 | } |
| 236 | 605 | |
| 606 | + if (updated.value.workspaceId === null) { | |
| 607 | + return updated.value; | |
| 608 | + } | |
| 609 | + | |
| 237 | 610 | return updated.value; |
| 238 | 611 | })); |
| 239 | 612 | |
| @@ -243,6 +616,49 @@ const removeScopedPolicy = (input: { · 3 unmodified lines | ||
| 243 | 616 | }) => |
| 244 | 617 | Effect.flatMap(ControlPlaneStore, (store) => |
| 245 | 618 | Effect.gen(function* () { |
| 619 | + if (input.scope.workspaceId !== null) { | |
| 620 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({ | |
| 621 | + store, | |
| 622 | + workspaceId: input.scope.workspaceId, | |
| 623 | + }).pipe( | |
| 624 | + Effect.mapError((cause) => | |
| 625 | + policyOps.remove.unknownStorage( | |
| 626 | + cause, | |
| 627 | + "Failed loading local workspace policies", | |
| 628 | + ), | |
| 629 | + ), | |
| 630 | + ); | |
| 631 | + if (localWorkspace !== null) { | |
| 632 | + const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; | |
| 633 | + if (existing === null || existing.configKey === null) { | |
| 634 | + return { removed: false }; | |
| 635 | + } | |
| 636 | + | |
| 637 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 638 | + const policies = { | |
| 639 | + ...(projectConfig.policies ?? {}), | |
| 640 | + }; | |
| 641 | + delete policies[existing.configKey]; | |
| 642 | + const { | |
| 643 | + [existing.configKey]: _removedPolicy, | |
| 644 | + ...remainingPolicies | |
| 645 | + } = localWorkspace.workspaceState.policies; | |
| 646 | + yield* writeLocalPolicyFiles({ | |
| 647 | + operation: policyOps.remove, | |
| 648 | + context: localWorkspace.runtimeLocalWorkspace.context, | |
| 649 | + projectConfig: { | |
| 650 | + ...projectConfig, | |
| 651 | + policies, | |
| 652 | + }, | |
| 653 | + workspaceState: { | |
| 654 | + ...localWorkspace.workspaceState, | |
| 655 | + policies: remainingPolicies, | |
| 656 | + }, | |
| 657 | + }); | |
| 658 | + return { removed: true }; | |
| 659 | + } | |
| 660 | + } | |
| 661 | + | |
| 246 | 662 | const existing = yield* policyOps.remove.mapStorage( |
| 247 | 663 | store.policies.getById(input.policyId), |
| 248 | 664 | ); |
| @@ -270,6 +686,20 @@ export const listPolicies = (workspaceId: WorkspaceId) => · 21 unmodified lines | ||
| 270 | 686 | Effect.flatMap(ControlPlaneStore, (store) => |
| 271 | 687 | Effect.gen(function* () { |
| 272 | 688 | yield* loadWorkspacePolicyContext(store, policyOps.list, workspaceId); |
| 689 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({ | |
| 690 | + store, | |
| 691 | + workspaceId, | |
| 692 | + }).pipe( | |
| 693 | + Effect.mapError((cause) => | |
| 694 | + policyOps.list.unknownStorage( | |
| 695 | + cause, | |
| 696 | + "Failed loading local workspace policies", | |
| 697 | + ), | |
| 698 | + ), | |
| 699 | + ); | |
| 700 | + if (localWorkspace !== null) { | |
| 701 | + return localWorkspace.policies; | |
| 702 | + } | |
| 273 | 703 | return yield* policyOps.list.mapStorage( |
| 274 | 704 | store.policies.listByWorkspaceId(workspaceId), |
| 275 | 705 | ); |
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | 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"; | |
| 3 | 6 | |
| 4 | 7 | import type { SqlControlPlaneRows } from "#persistence"; |
| 5 | 8 | import { |
| 9 | + type LocalConfigSecretProvider, | |
| 10 | + type LocalExecutorConfig, | |
| 6 | 11 | type SecretMaterialPurpose, |
| 7 | 12 | SecretMaterialIdSchema, |
| 8 | 13 | type SecretRef, |
| @@ -10,6 +15,9 @@ import { · 1 unmodified line | ||
| 10 | 15 | import * as Effect from "effect/Effect"; |
| 11 | 16 | import * as Option from "effect/Option"; |
| 12 | 17 | |
| 18 | +import { resolveConfigRelativePath } from "./local-config"; | |
| 19 | +import { fromConfigSecretProviderId } from "./local-config-secrets"; | |
| 20 | + | |
| 13 | 21 | export const ENV_SECRET_PROVIDER_ID = "env"; |
| 14 | 22 | export const PARAMS_SECRET_PROVIDER_ID = "params"; |
| 15 | 23 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; |
| @@ -43,6 +51,8 @@ type SecretMaterialProviderRuntime = { · 27 unmodified lines | ||
| 43 | 51 | env: NodeJS.ProcessEnv; |
| 44 | 52 | dangerouslyAllowEnvSecrets: boolean; |
| 45 | 53 | keychainServiceName: string; |
| 54 | + localConfig: LocalExecutorConfig | null; | |
| 55 | + workspaceRoot: string | null; | |
| 46 | 56 | }; |
| 47 | 57 | |
| 48 | 58 | type SecretMaterialProvider = { |
| @@ -125,6 +135,7 @@ const runCommand = (input: { · 76 unmodified lines | ||
| 125 | 135 | command: string; |
| 126 | 136 | args: ReadonlyArray<string>; |
| 127 | 137 | stdin?: string; |
| 138 | + env?: NodeJS.ProcessEnv; | |
| 128 | 139 | operation: string; |
| 129 | 140 | }): Effect.Effect<SpawnResult, Error, never> => |
| 130 | 141 | Effect.tryPromise({ |
| @@ -132,6 +143,7 @@ const runCommand = (input: { · 1 unmodified line | ||
| 132 | 143 | new Promise<SpawnResult>((resolve, reject) => { |
| 133 | 144 | const child = spawn(input.command, [...input.args], { |
| 134 | 145 | stdio: "pipe", |
| 146 | + env: input.env, | |
| 135 | 147 | }); |
| 136 | 148 | |
| 137 | 149 | let stdout = ""; |
| @@ -474,17 +486,169 @@ const createSecretMaterialProviderRuntime = (input: { · 336 unmodified lines | ||
| 474 | 486 | rows: SqlControlPlaneRows; |
| 475 | 487 | dangerouslyAllowEnvSecrets?: boolean; |
| 476 | 488 | keychainServiceName?: string; |
| 489 | + localConfig?: LocalExecutorConfig | null; | |
| 490 | + workspaceRoot?: string | null; | |
| 477 | 491 | }): SecretMaterialProviderRuntime => ({ |
| 478 | 492 | rows: input.rows, |
| 479 | 493 | env: process.env, |
| 480 | 494 | dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets), |
| 481 | 495 | keychainServiceName: resolveKeychainServiceName(input.keychainServiceName), |
| 496 | + localConfig: input.localConfig ?? null, | |
| 497 | + workspaceRoot: input.workspaceRoot ?? null, | |
| 482 | 498 | }); |
| 483 | 499 | |
| 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 | + | |
| 484 | 646 | export const createDefaultSecretMaterialResolver = (input: { |
| 485 | 647 | rows: SqlControlPlaneRows; |
| 486 | 648 | dangerouslyAllowEnvSecrets?: boolean; |
| 487 | 649 | keychainServiceName?: string; |
| 650 | + localConfig?: LocalExecutorConfig | null; | |
| 651 | + workspaceRoot?: string | null; | |
| 488 | 652 | }): ResolveSecretMaterial => { |
| 489 | 653 | const providers = createSecretMaterialProviderRegistry(); |
| 490 | 654 | const runtime = createSecretMaterialProviderRuntime(input); |
| @@ -494,7 +658,20 @@ export const createDefaultSecretMaterialResolver = (input: { · 3 unmodified lines | ||
| 494 | 658 | const provider = yield* getSecretMaterialProvider({ |
| 495 | 659 | providers, |
| 496 | 660 | providerId: ref.providerId, |
| 497 | − }); | |
| 661 | + }).pipe( | |
| 662 | + Effect.catchAll(() => | |
| 663 | + fromConfigSecretProviderId(ref.providerId) !== null | |
| 664 | + ? Effect.succeed(null) | |
| 665 | + : Effect.fail(new Error(`Unsupported secret provider: ${ref.providerId}`)), | |
| 666 | + ), | |
| 667 | + ); | |
| 668 | + | |
| 669 | + if (provider === null) { | |
| 670 | + return yield* resolveConfiguredSecretProvider({ | |
| 671 | + ref, | |
| 672 | + runtime, | |
| 673 | + }); | |
| 674 | + } | |
| 498 | 675 | |
| 499 | 676 | return yield* provider.resolve({ |
| 500 | 677 | ref, |
| @@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines | ||
| 187 | 187 | }); |
| 188 | 188 | }); |
| 189 | 189 | |
| 190 | −const materializationFromMcpManifestEntries = (input: { | |
| 190 | +export const materializationFromMcpManifestEntries = (input: { | |
| 191 | 191 | recipeRevisionId: SourceRecipeRevisionId; |
| 192 | 192 | endpoint: string; |
| 193 | 193 | manifestEntries: readonly McpToolManifestEntry[]; |
| @@ -12,6 +12,7 @@ import { resolveSourceAuthMaterial } from "./source-auth-material"; · 11 unmodified lines | ||
| 12 | 12 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 13 | 13 | id: SourceIdSchema.make("src_tool_artifacts"), |
| 14 | 14 | workspaceId: WorkspaceIdSchema.make("ws_tool_artifacts"), |
| 15 | + configKey: null, | |
| 15 | 16 | name: "GitHub", |
| 16 | 17 | kind: "openapi", |
| 17 | 18 | endpoint: "https://api.github.com", |
| @@ -32,6 +32,7 @@ import { resolveSourceAuthMaterial } from "./source-auth-material"; · 31 unmodified lines | ||
| 32 | 32 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| 33 | 33 | id: "src_test" as Source["id"], |
| 34 | 34 | workspaceId: "ws_test" as Source["workspaceId"], |
| 35 | + configKey: null, | |
| 35 | 36 | name: "GitHub", |
| 36 | 37 | kind: "openapi", |
| 37 | 38 | endpoint: "https://api.github.com", |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | AccountId, |
| 12 | 12 | type CredentialSlot, |
| 13 | 13 | ExecutionIdSchema, |
| 14 | + type LocalExecutorConfig, | |
| 14 | 15 | McpSourceAuthSessionDataJsonSchema, |
| 15 | 16 | type McpSourceAuthSessionData, |
| 16 | 17 | OAuth2PkceSourceAuthSessionDataJsonSchema, |
| @@ -43,6 +44,10 @@ import { · 26 unmodified lines | ||
| 43 | 44 | LiveExecutionManagerService, |
| 44 | 45 | type LiveExecutionManager, |
| 45 | 46 | } from "./live-execution"; |
| 47 | +import { | |
| 48 | + RuntimeLocalWorkspaceService, | |
| 49 | + type RuntimeLocalWorkspaceState, | |
| 50 | +} from "./local-runtime-context"; | |
| 46 | 51 | import { |
| 47 | 52 | exchangeMcpOAuthAuthorizationCode, |
| 48 | 53 | startMcpOAuthAuthorization, |
| @@ -1634,13 +1639,35 @@ export const createRuntimeSourceAuthService = (input: { · 1585 unmodified lines | ||
| 1634 | 1639 | rows: SqlControlPlaneRows; |
| 1635 | 1640 | liveExecutionManager: LiveExecutionManager; |
| 1636 | 1641 | getLocalServerBaseUrl?: () => string | undefined; |
| 1642 | + localConfig?: LocalExecutorConfig | null; | |
| 1643 | + workspaceRoot?: string | null; | |
| 1644 | + localWorkspaceState?: RuntimeLocalWorkspaceState; | |
| 1637 | 1645 | }) => { |
| 1638 | 1646 | const resolveSecretMaterial = createDefaultSecretMaterialResolver({ |
| 1639 | 1647 | rows: input.rows, |
| 1648 | + localConfig: input.localConfig, | |
| 1649 | + workspaceRoot: input.workspaceRoot, | |
| 1640 | 1650 | }); |
| 1641 | 1651 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ |
| 1642 | 1652 | rows: input.rows, |
| 1643 | 1653 | }); |
| 1654 | + const mirrorLocalSourceResult = ( | |
| 1655 | + result: ExecutorSourceAddResult, | |
| 1656 | + ): Effect.Effect<ExecutorSourceAddResult, Error, never> => | |
| 1657 | + Effect.succeed(result); | |
| 1658 | + const mirrorLocalMcpSourceResult = ( | |
| 1659 | + result: McpSourceConnectResult, | |
| 1660 | + ): Effect.Effect<McpSourceConnectResult, Error, never> => | |
| 1661 | + Effect.succeed(result); | |
| 1662 | + const provideLocalWorkspace = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 1663 | + input.localWorkspaceState | |
| 1664 | + ? effect.pipe( | |
| 1665 | + Effect.provideService( | |
| 1666 | + RuntimeLocalWorkspaceService, | |
| 1667 | + input.localWorkspaceState, | |
| 1668 | + ), | |
| 1669 | + ) | |
| 1670 | + : effect; | |
| 1644 | 1671 | |
| 1645 | 1672 | return { |
| 1646 | 1673 | getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, |
| @@ -1652,68 +1679,78 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines | ||
| 1652 | 1679 | }), |
| 1653 | 1680 | |
| 1654 | 1681 | 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 | + ), | |
| 1660 | 1689 | |
| 1661 | 1690 | 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 | + ), | |
| 1697 | 1730 | |
| 1698 | 1731 | 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 | + ), | |
| 1717 | 1754 | |
| 1718 | 1755 | startSourceOAuthSession: (oauthInput) => |
| 1719 | 1756 | Effect.gen(function* () { |
| @@ -1781,7 +1818,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines | ||
| 1781 | 1818 | error, |
| 1782 | 1819 | errorDescription, |
| 1783 | 1820 | }) => |
| 1784 | − Effect.gen(function* () { | |
| 1821 | + provideLocalWorkspace(Effect.gen(function* () { | |
| 1785 | 1822 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 1786 | 1823 | if (Option.isNone(sessionOption)) { |
| 1787 | 1824 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); |
| @@ -1890,7 +1927,7 @@ export const createRuntimeSourceAuthService = (input: { · 102 unmodified lines | ||
| 1890 | 1927 | sessionId: session.id, |
| 1891 | 1928 | auth, |
| 1892 | 1929 | } satisfies CompleteSourceOAuthSessionResult; |
| 1893 | − }), | |
| 1930 | + })), | |
| 1894 | 1931 | |
| 1895 | 1932 | completeSourceCredentialSetup: ({ |
| 1896 | 1933 | workspaceId, |
| @@ -1901,7 +1938,7 @@ export const createRuntimeSourceAuthService = (input: { · 4 unmodified lines | ||
| 1901 | 1938 | error, |
| 1902 | 1939 | errorDescription, |
| 1903 | 1940 | }) => |
| 1904 | − Effect.gen(function* () { | |
| 1941 | + provideLocalWorkspace(Effect.gen(function* () { | |
| 1905 | 1942 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 1906 | 1943 | if (Option.isNone(sessionOption)) { |
| 1907 | 1944 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); |
| @@ -2164,7 +2201,7 @@ export const createRuntimeSourceAuthService = (input: { · 256 unmodified lines | ||
| 2164 | 2201 | }); |
| 2165 | 2202 | |
| 2166 | 2203 | return connectedSource; |
| 2167 | − }), | |
| 2204 | + })), | |
| 2168 | 2205 | } satisfies RuntimeSourceAuthServiceShape; |
| 2169 | 2206 | }; |
| 2170 | 2207 | |
| @@ -2176,6 +2213,9 @@ export class RuntimeSourceAuthServiceTag extends Context.Tag( · 5 unmodified lines | ||
| 2176 | 2213 | |
| 2177 | 2214 | export const RuntimeSourceAuthServiceLive = (input: { |
| 2178 | 2215 | getLocalServerBaseUrl?: () => string | undefined; |
| 2216 | + localConfig?: LocalExecutorConfig | null; | |
| 2217 | + workspaceRoot?: string | null; | |
| 2218 | + localWorkspaceState?: RuntimeLocalWorkspaceState; | |
| 2179 | 2219 | } = {}) => |
| 2180 | 2220 | Layer.effect( |
| 2181 | 2221 | RuntimeSourceAuthServiceTag, |
| @@ -2187,6 +2227,9 @@ export const RuntimeSourceAuthServiceLive = (input: { · 5 unmodified lines | ||
| 2187 | 2227 | rows, |
| 2188 | 2228 | liveExecutionManager, |
| 2189 | 2229 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 2230 | + localConfig: input.localConfig, | |
| 2231 | + workspaceRoot: input.workspaceRoot, | |
| 2232 | + localWorkspaceState: input.localWorkspaceState, | |
| 2190 | 2233 | }); |
| 2191 | 2234 | }), |
| 2192 | 2235 | ); |
| @@ -44,6 +44,7 @@ const mcpBinding = (input: { · 43 unmodified lines | ||
| 44 | 44 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 45 | 45 | id: SourceIdSchema.make("src_source_definitions"), |
| 46 | 46 | workspaceId: WorkspaceIdSchema.make("ws_source_definitions"), |
| 47 | + configKey: null, | |
| 47 | 48 | name: "GitHub", |
| 48 | 49 | kind: "openapi", |
| 49 | 50 | endpoint: "https://api.github.com", |
| @@ -232,6 +232,7 @@ export const createSourceFromPayload = (input: { · 231 unmodified lines | ||
| 232 | 232 | return yield* validateSourceByKind({ |
| 233 | 233 | id: input.sourceId, |
| 234 | 234 | workspaceId: input.workspaceId, |
| 235 | + configKey: null, | |
| 235 | 236 | name: input.payload.name.trim(), |
| 236 | 237 | kind: input.payload.kind, |
| 237 | 238 | endpoint: input.payload.endpoint.trim(), |
| @@ -352,6 +353,7 @@ export const splitSourceForStorage = (input: { · 114 unmodified lines | ||
| 352 | 353 | const sourceRecord: StoredSourceRecord = { |
| 353 | 354 | id: input.source.id, |
| 354 | 355 | workspaceId: input.source.workspaceId, |
| 356 | + configKey: input.source.configKey, | |
| 355 | 357 | recipeId: input.recipeId, |
| 356 | 358 | recipeRevisionId: input.recipeRevisionId, |
| 357 | 359 | name: input.source.name, |
| @@ -406,6 +408,7 @@ export const projectSourceFromStorage = (input: { · 48 unmodified lines | ||
| 406 | 408 | return { |
| 407 | 409 | id: input.sourceRecord.id, |
| 408 | 410 | workspaceId: input.sourceRecord.workspaceId, |
| 411 | + configKey: input.sourceRecord.configKey, | |
| 409 | 412 | name: input.sourceRecord.name, |
| 410 | 413 | kind: input.sourceRecord.kind, |
| 411 | 414 | endpoint: input.sourceRecord.endpoint, |
| @@ -62,6 +62,7 @@ const makeSource = (input: { · 61 unmodified lines | ||
| 62 | 62 | }): Source => ({ |
| 63 | 63 | id: input.sourceId, |
| 64 | 64 | workspaceId: input.workspaceId, |
| 65 | + configKey: null, | |
| 65 | 66 | name: "Cloudflare API", |
| 66 | 67 | kind: "openapi", |
| 67 | 68 | endpoint: "https://api.cloudflare.com/client/v4", |
| @@ -237,6 +238,7 @@ describe("source-inspection", () => { · 169 unmodified lines | ||
| 237 | 238 | await Effect.runPromise(persistence.rows.sources.insert({ |
| 238 | 239 | id: sourceId, |
| 239 | 240 | workspaceId, |
| 241 | + configKey: null, | |
| 240 | 242 | recipeId, |
| 241 | 243 | recipeRevisionId, |
| 242 | 244 | name: "Cloudflare API", |
| @@ -16,7 +16,7 @@ import { SourceRecipeSchemaBundleIdSchema } from "#schema"; · 15 unmodified lines | ||
| 16 | 16 | import { |
| 17 | 17 | ControlPlaneNotFoundError, |
| 18 | 18 | ControlPlaneStorageError, |
| 19 | −} from "#api"; | |
| 19 | +} from "../api/errors"; | |
| 20 | 20 | import * as Effect from "effect/Effect"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | |
| @@ -26,6 +26,7 @@ import { · 3 unmodified lines | ||
| 26 | 26 | getSourceAdapterForOperation, |
| 27 | 27 | } from "./source-adapters"; |
| 28 | 28 | import { |
| 29 | + loadSourceWithRecipe, | |
| 29 | 30 | recipeToolMetadata, |
| 30 | 31 | recipeToolPath, |
| 31 | 32 | } from "./source-recipes-runtime"; |
| @@ -110,41 +111,29 @@ const loadSourceRecipeRecord = (input: { · 78 unmodified lines | ||
| 110 | 111 | }) => |
| 111 | 112 | Effect.flatMap(ControlPlaneStore, (store) => |
| 112 | 113 | 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, | |
| 126 | 116 | workspaceId: input.workspaceId, |
| 127 | 117 | sourceId: input.sourceId, |
| 128 | 118 | }).pipe( |
| 129 | 119 | 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 | + ), | |
| 134 | 129 | ), |
| 135 | 130 | ); |
| 136 | − const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage( | |
| 137 | − store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 138 | − ); | |
| 139 | − const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage( | |
| 140 | − store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 141 | − ); | |
| 142 | 131 | |
| 143 | 132 | 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 | |
| 148 | 137 | ?? null, |
| 149 | 138 | }; |
| 150 | 139 | }), |
| @@ -403,27 +392,27 @@ export const getSourceInspectionSchemaBundle = (input: { · 252 unmodified lines | ||
| 403 | 392 | }) => |
| 404 | 393 | Effect.flatMap(ControlPlaneStore, (store) => |
| 405 | 394 | 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 | + ), | |
| 421 | 410 | ), |
| 422 | 411 | ); |
| 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) { | |
| 427 | 416 | return yield* Effect.fail( |
| 428 | 417 | sourceInspectOps.tool.notFound( |
| 429 | 418 | "Schema bundle not found", |
| @@ -433,10 +422,10 @@ export const getSourceInspectionSchemaBundle = (input: { · 3 unmodified lines | ||
| 433 | 422 | } |
| 434 | 423 | |
| 435 | 424 | 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, | |
| 440 | 429 | } satisfies SourceInspectionSchemaBundle; |
| 441 | 430 | }), |
| 442 | 431 | ).pipe( |
| @@ -2,14 +2,30 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line | ||
| 2 | 2 | import type { |
| 3 | 3 | AccountId, |
| 4 | 4 | Source, |
| 5 | + SourceStatus, | |
| 5 | 6 | } from "#schema"; |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | |
| 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"; | |
| 8 | 21 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| 9 | 22 | import { |
| 10 | 23 | getSourceAdapterForSource, |
| 11 | 24 | } from "./source-adapters"; |
| 12 | −import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp"; | |
| 25 | +import { | |
| 26 | + materializationFromMcpManifestEntries, | |
| 27 | + persistMcpRecipeRevisionFromManifestEntries, | |
| 28 | +} from "./source-adapters/mcp"; | |
| 13 | 29 | import type { |
| 14 | 30 | ResolveSecretMaterial as ResolveSourceSecretMaterial, |
| 15 | 31 | } from "./secret-material-providers"; |
| @@ -27,6 +43,106 @@ export const syncSourceMaterialization = (input: { · 11 unmodified lines | ||
| 27 | 43 | resolveSecretMaterial: ResolveSourceSecretMaterial; |
| 28 | 44 | }): Effect.Effect<void, Error, never> => |
| 29 | 45 | Effect.gen(function* () { |
| 46 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 47 | + if ( | |
| 48 | + runtimeLocalWorkspace !== null | |
| 49 | + && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId | |
| 50 | + && input.source.configKey !== null | |
| 51 | + ) { | |
| 52 | + if (!shouldIndexSource(input.source)) { | |
| 53 | + const state = yield* Effect.tryPromise({ | |
| 54 | + try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context), | |
| 55 | + catch: (cause) => | |
| 56 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 57 | + }); | |
| 58 | + const existingSourceState = state.sources[input.source.configKey]; | |
| 59 | + const nextState: LocalWorkspaceState = { | |
| 60 | + ...state, | |
| 61 | + sources: { | |
| 62 | + ...state.sources, | |
| 63 | + [input.source.configKey]: { | |
| 64 | + id: input.source.id, | |
| 65 | + status: (input.source.enabled ? input.source.status : "draft") as SourceStatus, | |
| 66 | + lastError: null, | |
| 67 | + sourceHash: input.source.sourceHash, | |
| 68 | + createdAt: existingSourceState?.createdAt ?? input.source.createdAt, | |
| 69 | + updatedAt: Date.now(), | |
| 70 | + }, | |
| 71 | + }, | |
| 72 | + }; | |
| 73 | + yield* Effect.tryPromise({ | |
| 74 | + try: () => | |
| 75 | + writeLocalWorkspaceState({ | |
| 76 | + context: runtimeLocalWorkspace.context, | |
| 77 | + state: nextState, | |
| 78 | + }), | |
| 79 | + catch: (cause) => | |
| 80 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 81 | + }); | |
| 82 | + return; | |
| 83 | + } | |
| 84 | + | |
| 85 | + const adapter = getSourceAdapterForSource(input.source); | |
| 86 | + const materialization = yield* adapter.materializeSource({ | |
| 87 | + source: input.source, | |
| 88 | + resolveSecretMaterial: input.resolveSecretMaterial, | |
| 89 | + resolveAuthMaterialForSlot: (slot) => | |
| 90 | + resolveSourceAuthMaterial({ | |
| 91 | + rows: input.rows, | |
| 92 | + source: input.source, | |
| 93 | + slot, | |
| 94 | + actorAccountId: input.actorAccountId, | |
| 95 | + resolveSecretMaterial: input.resolveSecretMaterial, | |
| 96 | + }), | |
| 97 | + }); | |
| 98 | + | |
| 99 | + yield* Effect.tryPromise({ | |
| 100 | + try: () => | |
| 101 | + writeLocalSourceArtifact({ | |
| 102 | + context: runtimeLocalWorkspace.context, | |
| 103 | + configKey: input.source.configKey!, | |
| 104 | + artifact: buildLocalSourceArtifact({ | |
| 105 | + source: input.source, | |
| 106 | + configKey: input.source.configKey!, | |
| 107 | + materialization, | |
| 108 | + }), | |
| 109 | + }), | |
| 110 | + catch: (cause) => | |
| 111 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 112 | + }); | |
| 113 | + | |
| 114 | + const state = yield* Effect.tryPromise({ | |
| 115 | + try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context), | |
| 116 | + catch: (cause) => | |
| 117 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 118 | + }); | |
| 119 | + const existingSourceState = state.sources[input.source.configKey]; | |
| 120 | + const nextState: LocalWorkspaceState = { | |
| 121 | + ...state, | |
| 122 | + sources: { | |
| 123 | + ...state.sources, | |
| 124 | + [input.source.configKey]: { | |
| 125 | + id: input.source.id, | |
| 126 | + status: "connected", | |
| 127 | + lastError: null, | |
| 128 | + sourceHash: materialization.sourceHash, | |
| 129 | + createdAt: existingSourceState?.createdAt ?? input.source.createdAt, | |
| 130 | + updatedAt: Date.now(), | |
| 131 | + }, | |
| 132 | + }, | |
| 133 | + }; | |
| 134 | + yield* Effect.tryPromise({ | |
| 135 | + try: () => | |
| 136 | + writeLocalWorkspaceState({ | |
| 137 | + context: runtimeLocalWorkspace.context, | |
| 138 | + state: nextState, | |
| 139 | + }), | |
| 140 | + catch: (cause) => | |
| 141 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 142 | + }); | |
| 143 | + return; | |
| 144 | + } | |
| 145 | + | |
| 30 | 146 | if (!shouldIndexSource(input.source)) { |
| 31 | 147 | return; |
| 32 | 148 | } |
| @@ -58,4 +174,35 @@ export const persistMcpRecipeMaterializationFromManifest = (input: { · 25 unmodified lines | ||
| 58 | 174 | typeof persistMcpRecipeRevisionFromManifestEntries |
| 59 | 175 | >[0]["manifestEntries"]; |
| 60 | 176 | }): Effect.Effect<void, Error, never> => |
| 61 | − persistMcpRecipeRevisionFromManifestEntries(input); | |
| 177 | + Effect.gen(function* () { | |
| 178 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 179 | + if ( | |
| 180 | + runtimeLocalWorkspace !== null | |
| 181 | + && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId | |
| 182 | + && input.source.configKey !== null | |
| 183 | + ) { | |
| 184 | + const materialization = materializationFromMcpManifestEntries({ | |
| 185 | + recipeRevisionId: "src_recipe_rev_materialization" as never, | |
| 186 | + endpoint: input.source.endpoint, | |
| 187 | + manifestEntries: input.manifestEntries, | |
| 188 | + }); | |
| 189 | + | |
| 190 | + yield* Effect.tryPromise({ | |
| 191 | + try: () => | |
| 192 | + writeLocalSourceArtifact({ | |
| 193 | + context: runtimeLocalWorkspace.context, | |
| 194 | + configKey: input.source.configKey!, | |
| 195 | + artifact: buildLocalSourceArtifact({ | |
| 196 | + source: input.source, | |
| 197 | + configKey: input.source.configKey!, | |
| 198 | + materialization, | |
| 199 | + }), | |
| 200 | + }), | |
| 201 | + catch: (cause) => | |
| 202 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 203 | + }); | |
| 204 | + return; | |
| 205 | + } | |
| 206 | + | |
| 207 | + return yield* persistMcpRecipeRevisionFromManifestEntries(input); | |
| 208 | + }); | |
| @@ -87,6 +87,7 @@ const makeSource = (overrides: Partial<Source> = {}): Source => { · 86 unmodified lines | ||
| 87 | 87 | return { |
| 88 | 88 | id: SourceIdSchema.make("src_runtime_recipe"), |
| 89 | 89 | workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"), |
| 90 | + configKey: null, | |
| 90 | 91 | name: "GitHub", |
| 91 | 92 | kind, |
| 92 | 93 | endpoint, |
| @@ -372,6 +373,7 @@ describe("source-recipes-runtime", () => { · 279 unmodified lines | ||
| 372 | 373 | sourceRecord: { |
| 373 | 374 | id: SourceIdSchema.make("src_runtime_recipe"), |
| 374 | 375 | workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"), |
| 376 | + configKey: null, | |
| 375 | 377 | recipeId: SourceRecipeIdSchema.make("src_recipe_runtime"), |
| 376 | 378 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 377 | 379 | name: "GITHUB API", |
| @@ -543,6 +545,7 @@ describe("source-recipes-runtime", () => { · 165 unmodified lines | ||
| 543 | 545 | await Effect.runPromise(persistence.rows.sources.insert({ |
| 544 | 546 | id: SourceIdSchema.make(`src_shared_revision_${index}`), |
| 545 | 547 | workspaceId, |
| 548 | + configKey: null, | |
| 546 | 549 | recipeId, |
| 547 | 550 | recipeRevisionId, |
| 548 | 551 | name, |
| @@ -621,6 +624,7 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines | ||
| 621 | 624 | await Effect.runPromise(persistence.rows.sources.insert({ |
| 622 | 625 | id: sourceId, |
| 623 | 626 | workspaceId, |
| 627 | + configKey: null, | |
| 624 | 628 | recipeId, |
| 625 | 629 | recipeRevisionId, |
| 626 | 630 | name: "GraphQL Demo", |
| @@ -688,6 +692,7 @@ describe("source-recipes-runtime", () => { · 61 unmodified lines | ||
| 688 | 692 | await Effect.runPromise(persistence.rows.sources.insert({ |
| 689 | 693 | id: sourceId, |
| 690 | 694 | workspaceId, |
| 695 | + configKey: null, | |
| 691 | 696 | recipeId, |
| 692 | 697 | recipeRevisionId, |
| 693 | 698 | name: "Broken GitHub", |
| @@ -15,6 +15,12 @@ import type { · 14 unmodified lines | ||
| 15 | 15 | import * as Effect from "effect/Effect"; |
| 16 | 16 | import * as Option from "effect/Option"; |
| 17 | 17 | |
| 18 | +import { | |
| 19 | + readLocalSourceArtifact, | |
| 20 | +} from "./local-source-artifacts"; | |
| 21 | +import { | |
| 22 | + getRuntimeLocalWorkspaceOption, | |
| 23 | +} from "./local-runtime-context"; | |
| 18 | 24 | import { namespaceFromSourceName } from "./source-names"; |
| 19 | 25 | import { |
| 20 | 26 | getSourceAdapterForOperation, |
| @@ -156,6 +162,73 @@ export const loadWorkspaceSourceRecipes = (input: { · 135 unmodified lines | ||
| 156 | 162 | actorAccountId?: AccountId | null; |
| 157 | 163 | }): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> => |
| 158 | 164 | Effect.gen(function* () { |
| 165 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 166 | + if ( | |
| 167 | + runtimeLocalWorkspace !== null | |
| 168 | + && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId | |
| 169 | + ) { | |
| 170 | + const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | |
| 171 | + actorAccountId: input.actorAccountId, | |
| 172 | + }); | |
| 173 | + | |
| 174 | + const localRecipes = yield* Effect.forEach(sources, (source) => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + if (source.configKey === null) { | |
| 177 | + return null; | |
| 178 | + } | |
| 179 | + | |
| 180 | + const artifact = yield* Effect.tryPromise({ | |
| 181 | + try: () => | |
| 182 | + readLocalSourceArtifact({ | |
| 183 | + context: runtimeLocalWorkspace.context, | |
| 184 | + configKey: source.configKey!, | |
| 185 | + }), | |
| 186 | + catch: (cause) => | |
| 187 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 188 | + }); | |
| 189 | + if (artifact === null) { | |
| 190 | + return null; | |
| 191 | + } | |
| 192 | + | |
| 193 | + const sourceRecord: StoredSourceRecord = { | |
| 194 | + id: source.id, | |
| 195 | + workspaceId: source.workspaceId, | |
| 196 | + configKey: source.configKey, | |
| 197 | + recipeId: artifact.recipeId, | |
| 198 | + recipeRevisionId: artifact.revision.id, | |
| 199 | + name: source.name, | |
| 200 | + kind: source.kind, | |
| 201 | + endpoint: source.endpoint, | |
| 202 | + status: source.status, | |
| 203 | + enabled: source.enabled, | |
| 204 | + namespace: source.namespace, | |
| 205 | + importAuthPolicy: source.importAuthPolicy, | |
| 206 | + bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source), | |
| 207 | + sourceHash: source.sourceHash, | |
| 208 | + lastError: source.lastError, | |
| 209 | + createdAt: source.createdAt, | |
| 210 | + updatedAt: source.updatedAt, | |
| 211 | + }; | |
| 212 | + const manifest = yield* parseManifestForRecipe({ | |
| 213 | + source, | |
| 214 | + revision: artifact.revision, | |
| 215 | + }); | |
| 216 | + | |
| 217 | + return { | |
| 218 | + source, | |
| 219 | + sourceRecord, | |
| 220 | + revision: artifact.revision, | |
| 221 | + documents: artifact.documents, | |
| 222 | + schemaBundles: artifact.schemaBundles, | |
| 223 | + operations: artifact.operations, | |
| 224 | + manifest, | |
| 225 | + } satisfies LoadedSourceRecipe; | |
| 226 | + }), | |
| 227 | + ); | |
| 228 | + | |
| 229 | + return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null); | |
| 230 | + } | |
| 231 | + | |
| 159 | 232 | const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); |
| 160 | 233 | const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { |
| 161 | 234 | actorAccountId: input.actorAccountId, |
| @@ -230,6 +303,72 @@ export const loadSourceWithRecipe = (input: { · 68 unmodified lines | ||
| 230 | 303 | actorAccountId?: AccountId | null; |
| 231 | 304 | }): Effect.Effect<LoadedSourceRecipe, Error, never> => |
| 232 | 305 | Effect.gen(function* () { |
| 306 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 307 | + if ( | |
| 308 | + runtimeLocalWorkspace !== null | |
| 309 | + && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId | |
| 310 | + ) { | |
| 311 | + const source = yield* loadSourceById(input.rows, { | |
| 312 | + workspaceId: input.workspaceId, | |
| 313 | + sourceId: input.sourceId, | |
| 314 | + actorAccountId: input.actorAccountId, | |
| 315 | + }); | |
| 316 | + if (source.configKey === null) { | |
| 317 | + return yield* Effect.fail( | |
| 318 | + new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`), | |
| 319 | + ); | |
| 320 | + } | |
| 321 | + | |
| 322 | + const artifact = yield* Effect.tryPromise({ | |
| 323 | + try: () => | |
| 324 | + readLocalSourceArtifact({ | |
| 325 | + context: runtimeLocalWorkspace.context, | |
| 326 | + configKey: source.configKey!, | |
| 327 | + }), | |
| 328 | + catch: (cause) => | |
| 329 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 330 | + }); | |
| 331 | + if (artifact === null) { | |
| 332 | + return yield* Effect.fail( | |
| 333 | + new Error(`Recipe artifact missing for source ${input.sourceId}`), | |
| 334 | + ); | |
| 335 | + } | |
| 336 | + | |
| 337 | + const sourceRecord: StoredSourceRecord = { | |
| 338 | + id: source.id, | |
| 339 | + workspaceId: source.workspaceId, | |
| 340 | + configKey: source.configKey, | |
| 341 | + recipeId: artifact.recipeId, | |
| 342 | + recipeRevisionId: artifact.revision.id, | |
| 343 | + name: source.name, | |
| 344 | + kind: source.kind, | |
| 345 | + endpoint: source.endpoint, | |
| 346 | + status: source.status, | |
| 347 | + enabled: source.enabled, | |
| 348 | + namespace: source.namespace, | |
| 349 | + importAuthPolicy: source.importAuthPolicy, | |
| 350 | + bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source), | |
| 351 | + sourceHash: source.sourceHash, | |
| 352 | + lastError: source.lastError, | |
| 353 | + createdAt: source.createdAt, | |
| 354 | + updatedAt: source.updatedAt, | |
| 355 | + }; | |
| 356 | + const manifest = yield* parseManifestForRecipe({ | |
| 357 | + source, | |
| 358 | + revision: artifact.revision, | |
| 359 | + }); | |
| 360 | + | |
| 361 | + return { | |
| 362 | + source, | |
| 363 | + sourceRecord, | |
| 364 | + revision: artifact.revision, | |
| 365 | + documents: artifact.documents, | |
| 366 | + schemaBundles: artifact.schemaBundles, | |
| 367 | + operations: artifact.operations, | |
| 368 | + manifest, | |
| 369 | + } satisfies LoadedSourceRecipe; | |
| 370 | + } | |
| 371 | + | |
| 233 | 372 | const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( |
| 234 | 373 | input.workspaceId, |
| 235 | 374 | input.sourceId, |
| @@ -339,94 +478,26 @@ export const loadWorkspaceSourceRecipeToolIndex = (input: { · 103 unmodified lines | ||
| 339 | 478 | includeSchemas: boolean; |
| 340 | 479 | }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => |
| 341 | 480 | 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, | |
| 344 | 484 | actorAccountId: input.actorAccountId, |
| 345 | 485 | }); |
| 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 | + })); | |
| 430 | 501 | }); |
| 431 | 502 | |
| 432 | 503 | export const loadWorkspaceSourceRecipeToolByPath = (input: { |
| @@ -437,110 +508,27 @@ export const loadWorkspaceSourceRecipeToolByPath = (input: { · 4 unmodified lines | ||
| 437 | 508 | includeSchemas: boolean; |
| 438 | 509 | }): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> => |
| 439 | 510 | 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, | |
| 442 | 514 | actorAccountId: input.actorAccountId, |
| 443 | 515 | }); |
| 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, | |
| 471 | 519 | }); |
| 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; | |
| 546 | 534 | }); |
| @@ -52,6 +52,7 @@ const makeOpenApiSource = (input: { · 51 unmodified lines | ||
| 52 | 52 | }): Source => ({ |
| 53 | 53 | id: input.sourceId, |
| 54 | 54 | workspaceId: input.workspaceId, |
| 55 | + configKey: null, | |
| 55 | 56 | name: input.name ?? "GitHub", |
| 56 | 57 | kind: "openapi", |
| 57 | 58 | endpoint: input.endpoint ?? "https://api.github.com", |
| @@ -1,7 +1,11 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | + | |
| 1 | 3 | import type { |
| 2 | 4 | AccountId, |
| 3 | 5 | AuthArtifact, |
| 4 | 6 | CredentialSlot, |
| 7 | + LocalConfigSecretInput, | |
| 8 | + LocalConfigSource, | |
| 5 | 9 | Source, |
| 6 | 10 | SourceRecipeId, |
| 7 | 11 | SourceRecipeRevisionId, |
| @@ -20,9 +24,32 @@ import { · 12 unmodified lines | ||
| 20 | 24 | stableSourceRecipeRevisionId, |
| 21 | 25 | splitSourceForStorage, |
| 22 | 26 | } 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"; | |
| 23 | 48 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; |
| 24 | 49 | import { authArtifactSecretMaterialRefs } from "./auth-artifacts"; |
| 25 | 50 | import { removeAuthLeaseAndSecrets } from "./auth-leases"; |
| 51 | +import { getSourceAdapter } from "./source-adapters"; | |
| 52 | +import { slugify } from "./slug"; | |
| 26 | 53 | |
| 27 | 54 | const secretRefKey = (ref: { providerId: string; handle: string }): string => |
| 28 | 55 | `${ref.providerId}:${ref.handle}`; |
| @@ -79,6 +106,283 @@ const selectExactAuthArtifact = (input: { · 50 unmodified lines | ||
| 79 | 106 | && artifact.actorAccountId === (input.actorAccountId ?? null), |
| 80 | 107 | ) ?? null; |
| 81 | 108 | |
| 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 | + | |
| 82 | 386 | export const loadSourcesInWorkspace = ( |
| 83 | 387 | rows: SqlControlPlaneRows, |
| 84 | 388 | workspaceId: WorkspaceId, |
| @@ -87,6 +391,27 @@ export const loadSourcesInWorkspace = ( · 2 unmodified lines | ||
| 87 | 391 | } = {}, |
| 88 | 392 | ) => |
| 89 | 393 | Effect.gen(function* () { |
| 394 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId); | |
| 395 | + if (localWorkspace !== null) { | |
| 396 | + const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); | |
| 397 | + return yield* Effect.forEach( | |
| 398 | + Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}), | |
| 399 | + (configKey) => | |
| 400 | + Effect.map( | |
| 401 | + buildLocalSourceRecord({ | |
| 402 | + context: localWorkspace.context, | |
| 403 | + workspaceId, | |
| 404 | + loadedConfig: localWorkspace.loadedConfig, | |
| 405 | + workspaceState: localWorkspace.workspaceState, | |
| 406 | + configKey, | |
| 407 | + actorAccountId: options.actorAccountId, | |
| 408 | + authArtifacts, | |
| 409 | + }), | |
| 410 | + ({ source }) => source, | |
| 411 | + ), | |
| 412 | + ); | |
| 413 | + } | |
| 414 | + | |
| 90 | 415 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); |
| 91 | 416 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); |
| 92 | 417 | const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => { |
| @@ -118,6 +443,35 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 25 unmodified lines | ||
| 118 | 443 | actorAccountId?: AccountId | null; |
| 119 | 444 | }) => |
| 120 | 445 | Effect.gen(function* () { |
| 446 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId); | |
| 447 | + if (localWorkspace !== null) { | |
| 448 | + const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId); | |
| 449 | + const configKey = resolveLocalSourceConfigKey({ | |
| 450 | + context: localWorkspace.context, | |
| 451 | + loadedConfig: localWorkspace.loadedConfig, | |
| 452 | + workspaceState: localWorkspace.workspaceState, | |
| 453 | + sourceId: input.sourceId, | |
| 454 | + }); | |
| 455 | + | |
| 456 | + if (configKey === null) { | |
| 457 | + return yield* Effect.fail( | |
| 458 | + new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`), | |
| 459 | + ); | |
| 460 | + } | |
| 461 | + | |
| 462 | + const localSource = yield* buildLocalSourceRecord({ | |
| 463 | + context: localWorkspace.context, | |
| 464 | + workspaceId: input.workspaceId, | |
| 465 | + loadedConfig: localWorkspace.loadedConfig, | |
| 466 | + workspaceState: localWorkspace.workspaceState, | |
| 467 | + configKey, | |
| 468 | + actorAccountId: input.actorAccountId, | |
| 469 | + authArtifacts, | |
| 470 | + }); | |
| 471 | + | |
| 472 | + return localSource.source; | |
| 473 | + } | |
| 474 | + | |
| 121 | 475 | const sourceRecord = yield* rows.sources.getByWorkspaceAndId( |
| 122 | 476 | input.workspaceId, |
| 123 | 477 | input.sourceId, |
| @@ -151,6 +505,62 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 27 unmodified lines | ||
| 151 | 505 | }); |
| 152 | 506 | }); |
| 153 | 507 | |
| 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 | + | |
| 154 | 564 | const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { |
| 155 | 565 | workspaceId: WorkspaceId; |
| 156 | 566 | sourceId: Source["id"]; |
| @@ -227,6 +637,75 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 70 unmodified lines | ||
| 227 | 637 | sourceId: Source["id"]; |
| 228 | 638 | }) => |
| 229 | 639 | Effect.gen(function* () { |
| 640 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId); | |
| 641 | + if (localWorkspace !== null) { | |
| 642 | + const configKey = resolveLocalSourceConfigKey({ | |
| 643 | + context: localWorkspace.context, | |
| 644 | + loadedConfig: localWorkspace.loadedConfig, | |
| 645 | + workspaceState: localWorkspace.workspaceState, | |
| 646 | + sourceId: input.sourceId, | |
| 647 | + }); | |
| 648 | + if (configKey === null) { | |
| 649 | + return false; | |
| 650 | + } | |
| 651 | + | |
| 652 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 653 | + const sources = { | |
| 654 | + ...(projectConfig.sources ?? {}), | |
| 655 | + }; | |
| 656 | + delete sources[configKey]; | |
| 657 | + yield* Effect.tryPromise({ | |
| 658 | + try: () => | |
| 659 | + writeProjectLocalExecutorConfig({ | |
| 660 | + context: localWorkspace.context, | |
| 661 | + config: { | |
| 662 | + ...projectConfig, | |
| 663 | + sources, | |
| 664 | + }, | |
| 665 | + }), | |
| 666 | + catch: (cause) => | |
| 667 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 668 | + }); | |
| 669 | + | |
| 670 | + const { | |
| 671 | + [configKey]: _removedSource, | |
| 672 | + ...remainingSources | |
| 673 | + } = localWorkspace.workspaceState.sources; | |
| 674 | + const workspaceState: LocalWorkspaceState = { | |
| 675 | + ...localWorkspace.workspaceState, | |
| 676 | + sources: remainingSources, | |
| 677 | + }; | |
| 678 | + yield* Effect.tryPromise({ | |
| 679 | + try: () => | |
| 680 | + writeLocalWorkspaceState({ | |
| 681 | + context: localWorkspace.context, | |
| 682 | + state: workspaceState, | |
| 683 | + }), | |
| 684 | + catch: (cause) => | |
| 685 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 686 | + }); | |
| 687 | + yield* Effect.tryPromise({ | |
| 688 | + try: () => | |
| 689 | + removeLocalSourceArtifact({ | |
| 690 | + context: localWorkspace.context, | |
| 691 | + configKey, | |
| 692 | + }), | |
| 693 | + catch: (cause) => | |
| 694 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 695 | + }); | |
| 696 | + | |
| 697 | + yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( | |
| 698 | + input.workspaceId, | |
| 699 | + input.sourceId, | |
| 700 | + ); | |
| 701 | + yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({ | |
| 702 | + workspaceId: input.workspaceId, | |
| 703 | + sourceId: input.sourceId, | |
| 704 | + }); | |
| 705 | + yield* removeAuthArtifactsForSource(rows, input); | |
| 706 | + return true; | |
| 707 | + } | |
| 708 | + | |
| 230 | 709 | const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId); |
| 231 | 710 | if (Option.isNone(sourceRecord)) { |
| 232 | 711 | return false; |
| @@ -262,6 +741,158 @@ export const persistSource = ( · 29 unmodified lines | ||
| 262 | 741 | } = {}, |
| 263 | 742 | ) => |
| 264 | 743 | Effect.gen(function* () { |
| 744 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(source.workspaceId); | |
| 745 | + if (localWorkspace !== null) { | |
| 746 | + const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ | |
| 747 | + workspaceId: source.workspaceId, | |
| 748 | + sourceId: source.id, | |
| 749 | + }); | |
| 750 | + const existingRuntimeAuthArtifact = selectExactAuthArtifact({ | |
| 751 | + authArtifacts: existingAuthArtifacts, | |
| 752 | + actorAccountId: options.actorAccountId, | |
| 753 | + slot: "runtime", | |
| 754 | + }); | |
| 755 | + const existingImportAuthArtifact = selectExactAuthArtifact({ | |
| 756 | + authArtifacts: existingAuthArtifacts, | |
| 757 | + actorAccountId: options.actorAccountId, | |
| 758 | + slot: "import", | |
| 759 | + }); | |
| 760 | + const configKey = | |
| 761 | + source.configKey | |
| 762 | + ?? resolveLocalSourceConfigKey({ | |
| 763 | + context: localWorkspace.context, | |
| 764 | + loadedConfig: localWorkspace.loadedConfig, | |
| 765 | + workspaceState: localWorkspace.workspaceState, | |
| 766 | + sourceId: source.id, | |
| 767 | + }) | |
| 768 | + ?? deriveLocalSourceConfigKey(source); | |
| 769 | + const nextSource = { | |
| 770 | + ...source, | |
| 771 | + configKey, | |
| 772 | + } satisfies Source; | |
| 773 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 774 | + const sources = { | |
| 775 | + ...(projectConfig.sources ?? {}), | |
| 776 | + }; | |
| 777 | + const existingConfigSource = sources[configKey]; | |
| 778 | + sources[configKey] = configSourceFromLocalSource({ | |
| 779 | + source: nextSource, | |
| 780 | + configKey, | |
| 781 | + existingConfigAuth: existingConfigSource?.connection.auth, | |
| 782 | + config: localWorkspace.loadedConfig.config, | |
| 783 | + }); | |
| 784 | + yield* Effect.tryPromise({ | |
| 785 | + try: () => | |
| 786 | + writeProjectLocalExecutorConfig({ | |
| 787 | + context: localWorkspace.context, | |
| 788 | + config: { | |
| 789 | + ...projectConfig, | |
| 790 | + sources, | |
| 791 | + }, | |
| 792 | + }), | |
| 793 | + catch: (cause) => | |
| 794 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 795 | + }); | |
| 796 | + | |
| 797 | + const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ | |
| 798 | + source: nextSource, | |
| 799 | + recipeId: stableSourceRecipeId(nextSource), | |
| 800 | + recipeRevisionId: stableSourceRecipeRevisionId(nextSource), | |
| 801 | + actorAccountId: options.actorAccountId, | |
| 802 | + existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null, | |
| 803 | + existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null, | |
| 804 | + }); | |
| 805 | + | |
| 806 | + if (runtimeAuthArtifact === null) { | |
| 807 | + if (existingRuntimeAuthArtifact !== null) { | |
| 808 | + yield* removeAuthLeaseAndSecrets(rows, { | |
| 809 | + authArtifactId: existingRuntimeAuthArtifact.id, | |
| 810 | + }); | |
| 811 | + } | |
| 812 | + yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | |
| 813 | + workspaceId: source.workspaceId, | |
| 814 | + sourceId: source.id, | |
| 815 | + actorAccountId: options.actorAccountId ?? null, | |
| 816 | + slot: "runtime", | |
| 817 | + }); | |
| 818 | + } else { | |
| 819 | + yield* rows.authArtifacts.upsert(runtimeAuthArtifact); | |
| 820 | + if ( | |
| 821 | + existingRuntimeAuthArtifact !== null | |
| 822 | + && existingRuntimeAuthArtifact.id !== runtimeAuthArtifact.id | |
| 823 | + ) { | |
| 824 | + yield* removeAuthLeaseAndSecrets(rows, { | |
| 825 | + authArtifactId: existingRuntimeAuthArtifact.id, | |
| 826 | + }); | |
| 827 | + } | |
| 828 | + } | |
| 829 | + | |
| 830 | + yield* cleanupAuthArtifactSecretRefs(rows, { | |
| 831 | + previous: existingRuntimeAuthArtifact ?? null, | |
| 832 | + next: runtimeAuthArtifact, | |
| 833 | + }); | |
| 834 | + | |
| 835 | + if (importAuthArtifact === null) { | |
| 836 | + if (existingImportAuthArtifact !== null) { | |
| 837 | + yield* removeAuthLeaseAndSecrets(rows, { | |
| 838 | + authArtifactId: existingImportAuthArtifact.id, | |
| 839 | + }); | |
| 840 | + } | |
| 841 | + yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | |
| 842 | + workspaceId: source.workspaceId, | |
| 843 | + sourceId: source.id, | |
| 844 | + actorAccountId: options.actorAccountId ?? null, | |
| 845 | + slot: "import", | |
| 846 | + }); | |
| 847 | + } else { | |
| 848 | + yield* rows.authArtifacts.upsert(importAuthArtifact); | |
| 849 | + if ( | |
| 850 | + existingImportAuthArtifact !== null | |
| 851 | + && existingImportAuthArtifact.id !== importAuthArtifact.id | |
| 852 | + ) { | |
| 853 | + yield* removeAuthLeaseAndSecrets(rows, { | |
| 854 | + authArtifactId: existingImportAuthArtifact.id, | |
| 855 | + }); | |
| 856 | + } | |
| 857 | + } | |
| 858 | + | |
| 859 | + yield* cleanupAuthArtifactSecretRefs(rows, { | |
| 860 | + previous: existingImportAuthArtifact ?? null, | |
| 861 | + next: importAuthArtifact, | |
| 862 | + }); | |
| 863 | + | |
| 864 | + const existingSourceState = localWorkspace.workspaceState.sources[configKey]; | |
| 865 | + const workspaceState: LocalWorkspaceState = { | |
| 866 | + ...localWorkspace.workspaceState, | |
| 867 | + sources: { | |
| 868 | + ...localWorkspace.workspaceState.sources, | |
| 869 | + [configKey]: { | |
| 870 | + id: nextSource.id, | |
| 871 | + status: nextSource.status, | |
| 872 | + lastError: nextSource.lastError, | |
| 873 | + sourceHash: nextSource.sourceHash, | |
| 874 | + createdAt: existingSourceState?.createdAt ?? nextSource.createdAt, | |
| 875 | + updatedAt: nextSource.updatedAt, | |
| 876 | + }, | |
| 877 | + }, | |
| 878 | + }; | |
| 879 | + yield* Effect.tryPromise({ | |
| 880 | + try: () => | |
| 881 | + writeLocalWorkspaceState({ | |
| 882 | + context: localWorkspace.context, | |
| 883 | + state: workspaceState, | |
| 884 | + }), | |
| 885 | + catch: (cause) => | |
| 886 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 887 | + }); | |
| 888 | + | |
| 889 | + return yield* loadSourceById(rows, { | |
| 890 | + workspaceId: source.workspaceId, | |
| 891 | + sourceId: nextSource.id, | |
| 892 | + actorAccountId: options.actorAccountId, | |
| 893 | + }); | |
| 894 | + } | |
| 895 | + | |
| 265 | 896 | const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); |
| 266 | 897 | const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 267 | 898 | workspaceId: source.workspaceId, |
| @@ -140,16 +140,18 @@ const syncArtifactsForSource = (input: { · 139 unmodified lines | ||
| 140 | 140 | export const listSources = (workspaceId: WorkspaceId) => |
| 141 | 141 | Effect.flatMap(ControlPlaneStore, (store) => |
| 142 | 142 | 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 | + ), | |
| 150 | 152 | ), |
| 151 | − ), | |
| 152 | − ), | |
| 153 | + ); | |
| 154 | + }), | |
| 153 | 155 | )); |
| 154 | 156 | |
| 155 | 157 | export const createSource = (input: { |
| @@ -175,19 +177,21 @@ export const createSource = (input: { · 19 unmodified lines | ||
| 175 | 177 | ), |
| 176 | 178 | ); |
| 177 | 179 | |
| 178 | − yield* mapPersistenceError( | |
| 180 | + const persistedSource = yield* mapPersistenceError( | |
| 179 | 181 | sourceOps.create.child("persist"), |
| 180 | 182 | persistSource(store, source, { |
| 181 | 183 | actorAccountId: actor.principal.accountId, |
| 182 | 184 | }), |
| 183 | 185 | ); |
| 184 | 186 | |
| 185 | − return yield* syncArtifactsForSource({ | |
| 187 | + const synchronizedSource = yield* syncArtifactsForSource({ | |
| 186 | 188 | store, |
| 187 | − source, | |
| 189 | + source: persistedSource, | |
| 188 | 190 | actorAccountId: actor.principal.accountId, |
| 189 | 191 | operation: sourceOps.create, |
| 190 | 192 | }); |
| 193 | + | |
| 194 | + return synchronizedSource; | |
| 191 | 195 | }), |
| 192 | 196 | )); |
| 193 | 197 | |
| @@ -197,23 +201,25 @@ export const getSource = (input: { · 3 unmodified lines | ||
| 197 | 201 | }) => |
| 198 | 202 | Effect.flatMap(ControlPlaneStore, (store) => |
| 199 | 203 | 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 | + ), | |
| 214 | 220 | ), |
| 215 | − ), | |
| 216 | − ), | |
| 221 | + ); | |
| 222 | + }), | |
| 217 | 223 | )); |
| 218 | 224 | |
| 219 | 225 | export const updateSource = (input: { |
| @@ -255,19 +261,21 @@ export const updateSource = (input: { · 35 unmodified lines | ||
| 255 | 261 | ), |
| 256 | 262 | ); |
| 257 | 263 | |
| 258 | − yield* mapPersistenceError( | |
| 264 | + const persistedSource = yield* mapPersistenceError( | |
| 259 | 265 | sourceOps.update.child("persist"), |
| 260 | 266 | persistSource(store, updatedSource, { |
| 261 | 267 | actorAccountId: actor.principal.accountId, |
| 262 | 268 | }), |
| 263 | 269 | ); |
| 264 | 270 | |
| 265 | − return yield* syncArtifactsForSource({ | |
| 271 | + const synchronizedSource = yield* syncArtifactsForSource({ | |
| 266 | 272 | store, |
| 267 | − source: updatedSource, | |
| 273 | + source: persistedSource, | |
| 268 | 274 | actorAccountId: actor.principal.accountId, |
| 269 | 275 | operation: sourceOps.update, |
| 270 | 276 | }); |
| 277 | + | |
| 278 | + return synchronizedSource; | |
| 271 | 279 | }), |
| 272 | 280 | )); |
| 273 | 281 | |
| @@ -589,6 +589,7 @@ const persistConnectedEchoTool = (input: { · 588 unmodified lines | ||
| 589 | 589 | const source: Source = { |
| 590 | 590 | id: input.sourceId, |
| 591 | 591 | workspaceId: input.workspaceId, |
| 592 | + configKey: null, | |
| 592 | 593 | name: "Counted MCP", |
| 593 | 594 | kind: "mcp", |
| 594 | 595 | endpoint: input.endpoint, |
| @@ -801,6 +802,7 @@ const persistConnectedGithubOpenApiSource = (input: { · 206 unmodified lines | ||
| 801 | 802 | const source: Source = { |
| 802 | 803 | id: input.sourceId, |
| 803 | 804 | workspaceId: input.workspaceId, |
| 805 | + configKey: null, | |
| 804 | 806 | name: "GitHub", |
| 805 | 807 | kind: "openapi", |
| 806 | 808 | endpoint: input.endpoint ?? "https://api.github.com", |
| @@ -37,8 +37,10 @@ import { · 36 unmodified lines | ||
| 37 | 37 | type RuntimeSourceAuthService, |
| 38 | 38 | } from "./source-auth-service"; |
| 39 | 39 | import { |
| 40 | + loadSourceWithRecipe, | |
| 40 | 41 | loadWorkspaceSourceRecipeToolByPath, |
| 41 | 42 | loadWorkspaceSourceRecipeToolIndex, |
| 43 | + loadWorkspaceSourceRecipes, | |
| 42 | 44 | type LoadedSourceRecipeToolIndexEntry, |
| 43 | 45 | } from "./source-recipes-runtime"; |
| 44 | 46 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| @@ -53,6 +55,14 @@ import { · 8 unmodified lines | ||
| 53 | 55 | evaluateInvocationPolicy, |
| 54 | 56 | type InvocationDescriptor, |
| 55 | 57 | } 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"; | |
| 56 | 66 | |
| 57 | 67 | const asToolPath = (value: string): ToolPath => value as ToolPath; |
| 58 | 68 | |
| @@ -183,28 +193,40 @@ const loadWorkspaceSchemaBundle = (input: { · 124 unmodified lines | ||
| 183 | 193 | const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById( |
| 184 | 194 | input.id, |
| 185 | 195 | ); |
| 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 | + } | |
| 200 | 213 | } |
| 201 | 214 | |
| 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; | |
| 208 | 230 | }); |
| 209 | 231 | |
| 210 | 232 | const scoreRecipeTool = ( |
| @@ -380,7 +402,7 @@ const authorizePersistedToolInvocation = (input: { · 169 unmodified lines | ||
| 380 | 402 | cause instanceof Error ? cause : new Error(String(cause)), |
| 381 | 403 | ), |
| 382 | 404 | ); |
| 383 | − const policies = Option.isSome(workspace) | |
| 405 | + const storedPolicies = Option.isSome(workspace) | |
| 384 | 406 | ? yield* input.rows.policies |
| 385 | 407 | .listForWorkspaceContext({ |
| 386 | 408 | organizationId: workspace.value.organizationId, |
| @@ -392,6 +414,20 @@ const authorizePersistedToolInvocation = (input: { · 5 unmodified lines | ||
| 392 | 414 | ), |
| 393 | 415 | ) |
| 394 | 416 | : []; |
| 417 | + const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies({ | |
| 418 | + store: input.rows, | |
| 419 | + workspaceId: input.workspaceId, | |
| 420 | + }).pipe( | |
| 421 | + Effect.mapError((cause) => | |
| 422 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 423 | + ), | |
| 424 | + ); | |
| 425 | + const policies = localWorkspacePolicies === null | |
| 426 | + ? storedPolicies | |
| 427 | + : [ | |
| 428 | + ...storedPolicies.filter((policy) => policy.scopeType !== "workspace"), | |
| 429 | + ...localWorkspacePolicies.policies, | |
| 430 | + ]; | |
| 395 | 431 | |
| 396 | 432 | const decision = evaluateInvocationPolicy({ |
| 397 | 433 | descriptor: input.descriptor, |
| @@ -471,14 +507,28 @@ const authorizePersistedToolInvocation = (input: { · 73 unmodified lines | ||
| 471 | 507 | } |
| 472 | 508 | }); |
| 473 | 509 | |
| 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 | + | |
| 474 | 523 | const createWorkspaceToolCatalog = (input: { |
| 475 | 524 | workspaceId: Source["workspaceId"]; |
| 476 | 525 | accountId: AccountId; |
| 477 | 526 | rows: SqlControlPlaneRows; |
| 478 | 527 | executorCatalog: ToolCatalog; |
| 528 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | |
| 479 | 529 | }): ToolCatalog => ({ |
| 480 | 530 | listNamespaces: ({ limit }) => |
| 481 | − Effect.gen(function* () { | |
| 531 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 482 | 532 | const [recipeTools, executor] = yield* Effect.all([ |
| 483 | 533 | loadWorkspaceRecipeTools({ |
| 484 | 534 | rows: input.rows, |
| @@ -513,10 +563,10 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines | ||
| 513 | 563 | return [...merged.values()] |
| 514 | 564 | .sort((left, right) => left.namespace.localeCompare(right.namespace)) |
| 515 | 565 | .slice(0, limit); |
| 516 | − }), | |
| 566 | + }), input.runtimeLocalWorkspace), | |
| 517 | 567 | |
| 518 | 568 | listTools: ({ namespace, query, limit, includeSchemas = false }) => |
| 519 | − Effect.gen(function* () { | |
| 569 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 520 | 570 | const [recipeTools, executor] = yield* Effect.all([ |
| 521 | 571 | loadWorkspaceRecipeTools({ |
| 522 | 572 | rows: input.rows, |
| @@ -549,10 +599,10 @@ const createWorkspaceToolCatalog = (input: { · 26 unmodified lines | ||
| 549 | 599 | return [...recipeTools.map((tool) => tool.descriptor), ...executor] |
| 550 | 600 | .sort((left, right) => left.path.localeCompare(right.path)) |
| 551 | 601 | .slice(0, limit); |
| 552 | − }), | |
| 602 | + }), input.runtimeLocalWorkspace), | |
| 553 | 603 | |
| 554 | 604 | getToolByPath: ({ path, includeSchemas }) => |
| 555 | − Effect.gen(function* () { | |
| 605 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 556 | 606 | const executor = yield* input.executorCatalog.getToolByPath({ |
| 557 | 607 | path, |
| 558 | 608 | includeSchemas, |
| @@ -569,17 +619,17 @@ const createWorkspaceToolCatalog = (input: { · 10 unmodified lines | ||
| 569 | 619 | includeSchemas, |
| 570 | 620 | }); |
| 571 | 621 | return recipeTool?.descriptor ?? null; |
| 572 | − }), | |
| 622 | + }), input.runtimeLocalWorkspace), | |
| 573 | 623 | |
| 574 | 624 | getSchemaBundle: ({ id }) => |
| 575 | − loadWorkspaceSchemaBundle({ | |
| 625 | + provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({ | |
| 576 | 626 | rows: input.rows, |
| 577 | 627 | workspaceId: input.workspaceId, |
| 578 | 628 | id: SourceRecipeSchemaBundleIdSchema.make(id), |
| 579 | − }), | |
| 629 | + }), input.runtimeLocalWorkspace), | |
| 580 | 630 | |
| 581 | 631 | searchTools: ({ query, namespace, limit }) => |
| 582 | − Effect.gen(function* () { | |
| 632 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 583 | 633 | const queryTokens = tokenize(query); |
| 584 | 634 | const [recipeTools, executor] = yield* Effect.all([ |
| 585 | 635 | loadWorkspaceRecipeTools({ |
| @@ -614,7 +664,7 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines | ||
| 614 | 664 | right.score - left.score || left.path.localeCompare(right.path), |
| 615 | 665 | ) |
| 616 | 666 | .slice(0, limit); |
| 617 | − }), | |
| 667 | + }), input.runtimeLocalWorkspace), | |
| 618 | 668 | }); |
| 619 | 669 | |
| 620 | 670 | const createWorkspaceToolInvoker = (input: { |
| @@ -623,6 +673,7 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines | ||
| 623 | 673 | rows: SqlControlPlaneRows; |
| 624 | 674 | resolveSecretMaterial: ResolveSecretMaterial; |
| 625 | 675 | sourceAuthService: RuntimeSourceAuthService; |
| 676 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | |
| 626 | 677 | onElicitation?: Parameters< |
| 627 | 678 | typeof makeToolInvokerFromTools |
| 628 | 679 | >[0]["onElicitation"]; |
| @@ -634,6 +685,7 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines | ||
| 634 | 685 | workspaceId: input.workspaceId, |
| 635 | 686 | accountId: input.accountId, |
| 636 | 687 | sourceAuthService: input.sourceAuthService, |
| 688 | + runtimeLocalWorkspace: input.runtimeLocalWorkspace, | |
| 637 | 689 | }); |
| 638 | 690 | const executorCatalog = createToolCatalogFromTools({ |
| 639 | 691 | tools: executorTools, |
| @@ -643,6 +695,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines | ||
| 643 | 695 | accountId: input.accountId, |
| 644 | 696 | rows: input.rows, |
| 645 | 697 | executorCatalog, |
| 698 | + runtimeLocalWorkspace: input.runtimeLocalWorkspace, | |
| 646 | 699 | }); |
| 647 | 700 | const systemTools = createSystemToolMap({ catalog }); |
| 648 | 701 | const systemToolPaths = new Set(Object.keys(systemTools)); |
| @@ -661,7 +714,7 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines | ||
| 661 | 714 | args: unknown; |
| 662 | 715 | context?: Record<string, unknown>; |
| 663 | 716 | }) => |
| 664 | − Effect.gen(function* () { | |
| 717 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 665 | 718 | const recipeTool = yield* loadWorkspaceRecipeToolByPath({ |
| 666 | 719 | rows: input.rows, |
| 667 | 720 | workspaceId: input.workspaceId, |
| @@ -700,17 +753,12 @@ const createWorkspaceToolInvoker = (input: { · 32 unmodified lines | ||
| 700 | 753 | id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId), |
| 701 | 754 | }) |
| 702 | 755 | : 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 | + }); | |
| 714 | 762 | |
| 715 | 763 | return yield* getSourceAdapterForOperation(recipeTool.operation) |
| 716 | 764 | .invokePersistedTool({ |
| @@ -721,23 +769,26 @@ const createWorkspaceToolInvoker = (input: { · 4 unmodified lines | ||
| 721 | 769 | path: invocation.path, |
| 722 | 770 | operation: recipeTool.operation, |
| 723 | 771 | schemaBundle, |
| 724 | − manifestJson, | |
| 772 | + manifestJson: recipe.revision.manifestJson, | |
| 725 | 773 | auth, |
| 726 | 774 | args: invocation.args, |
| 727 | 775 | context: invocation.context, |
| 728 | 776 | onElicitation: input.onElicitation, |
| 729 | 777 | }); |
| 730 | − }); | |
| 778 | + }), input.runtimeLocalWorkspace); | |
| 731 | 779 | |
| 732 | 780 | return { |
| 733 | 781 | catalog, |
| 734 | 782 | toolInvoker: { |
| 735 | 783 | 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 | + ), | |
| 741 | 792 | }, |
| 742 | 793 | }; |
| 743 | 794 | }; |
| @@ -754,13 +805,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 10 unmodified lines | ||
| 754 | 805 | }); |
| 755 | 806 | |
| 756 | 807 | return ({ workspaceId, accountId, onElicitation }) => |
| 757 | − Effect.sync(() => { | |
| 808 | + Effect.gen(function* () { | |
| 809 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 758 | 810 | const { catalog, toolInvoker } = createWorkspaceToolInvoker({ |
| 759 | 811 | workspaceId, |
| 760 | 812 | accountId, |
| 761 | 813 | rows: input.rows, |
| 762 | 814 | resolveSecretMaterial, |
| 763 | 815 | sourceAuthService: input.sourceAuthService, |
| 816 | + runtimeLocalWorkspace, | |
| 764 | 817 | onElicitation, |
| 765 | 818 | }); |
| 766 | 819 | |
| @@ -13,6 +13,7 @@ export * from "./models/secret-material"; · 12 unmodified lines | ||
| 13 | 13 | export * from "./models/source-auth-session"; |
| 14 | 14 | export * from "./models/policy"; |
| 15 | 15 | export * from "./models/local-installation"; |
| 16 | +export * from "./models/local-config"; | |
| 16 | 17 | export * from "./models/execution"; |
| 17 | 18 | export * from "./models/source-inspection"; |
| 18 | 19 | export * from "./models/source-discovery"; |
| @@ -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; | |
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | − createInsertSchema, | |
| 3 | 2 | createSelectSchema, |
| 4 | − createUpdateSchema, | |
| 5 | 3 | } from "drizzle-orm/effect-schema"; |
| 6 | 4 | import { Schema } from "effect"; |
| 7 | 5 | |
| @@ -56,18 +54,61 @@ const policySchemaOverrides = { · 48 unmodified lines | ||
| 56 | 54 | updatedAt: TimestampMsSchema, |
| 57 | 55 | } as const; |
| 58 | 56 | |
| 59 | −export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides); | |
| 57 | +const PolicyStorageSchema = createSelectSchema(policiesTable, policySchemaOverrides); | |
| 60 | 58 | |
| 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 | +}); | |
| 65 | 78 | |
| 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 | + }, | |
| 69 | 107 | ); |
| 70 | 108 | |
| 109 | +export const PolicyInsertSchema = PolicyTypeSchema; | |
| 110 | +export const PolicyUpdateSchema = Schema.partial(PolicyTypeSchema); | |
| 111 | + | |
| 71 | 112 | export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type; |
| 72 | 113 | export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type; |
| 73 | 114 | export type PolicyEffect = typeof PolicyEffectSchema.Type; |
| @@ -97,6 +97,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 96 unmodified lines | ||
| 97 | 97 | Schema.Struct({ |
| 98 | 98 | id: SourceIdSchema, |
| 99 | 99 | workspaceId: WorkspaceIdSchema, |
| 100 | + configKey: Schema.NullOr(Schema.String), | |
| 100 | 101 | recipeId: SourceRecipeIdSchema, |
| 101 | 102 | recipeRevisionId: SourceRecipeRevisionIdSchema, |
| 102 | 103 | name: Schema.String, |
| @@ -117,6 +118,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 14 unmodified lines | ||
| 117 | 118 | decode: (row, _input) => ({ |
| 118 | 119 | id: row.sourceId, |
| 119 | 120 | workspaceId: row.workspaceId, |
| 121 | + configKey: null, | |
| 120 | 122 | recipeId: row.recipeId, |
| 121 | 123 | recipeRevisionId: row.recipeRevisionId, |
| 122 | 124 | name: row.name, |
| @@ -156,6 +158,7 @@ export const StoredSourceRecordSchema = Schema.transform( · 33 unmodified lines | ||
| 156 | 158 | export const SourceSchema = Schema.Struct({ |
| 157 | 159 | id: SourceIdSchema, |
| 158 | 160 | workspaceId: WorkspaceIdSchema, |
| 161 | + configKey: Schema.NullOr(Schema.String), | |
| 159 | 162 | name: Schema.String, |
| 160 | 163 | kind: SourceKindSchema, |
| 161 | 164 | endpoint: Schema.String, |
| @@ -492,6 +492,7 @@ const openApiSource = (input: { · 491 unmodified lines | ||
| 492 | 492 | }): Source => ({ |
| 493 | 493 | id: input.sourceId, |
| 494 | 494 | workspaceId: input.workspaceId, |
| 495 | + configKey: null, | |
| 495 | 496 | name: input.displayName, |
| 496 | 497 | kind: "openapi", |
| 497 | 498 | endpoint: input.baseUrl, |
| @@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 94 unmodified lines | ||
| 95 | 95 | |
| 96 | 96 | const startControlPlaneServer = async (): Promise<ApiServer> => { |
| 97 | 97 | const runtime = await Effect.runPromise( |
| 98 | − createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | |
| 98 | + createSqlControlPlaneRuntime({ | |
| 99 | + localDataDir: ":memory:", | |
| 100 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")), | |
| 101 | + }), | |
| 99 | 102 | ); |
| 100 | 103 | const scope = await Effect.runPromise(Scope.make()); |
| 101 | 104 | |
| @@ -743,3 +746,6 @@ describe("executor-react source hooks", () => { · 641 unmodified lines | ||
| 743 | 746 | }), |
| 744 | 747 | ); |
| 745 | 748 | }); |
| 749 | +import { mkdtempSync } from "node:fs"; | |
| 750 | +import { tmpdir } from "node:os"; | |
| 751 | +import { join } from "node:path"; | |
| @@ -661,6 +661,7 @@ const createOptimisticSource = (input: { · 660 unmodified lines | ||
| 661 | 661 | return { |
| 662 | 662 | id: `src_optimistic_${crypto.randomUUID()}` as Source["id"], |
| 663 | 663 | workspaceId: input.workspaceId, |
| 664 | + configKey: null, | |
| 664 | 665 | name: input.payload.name, |
| 665 | 666 | kind: input.payload.kind, |
| 666 | 667 | endpoint: input.payload.endpoint, |
| @@ -237,4 +237,3 @@ export const makeSesExecutor = ( · 236 unmodified lines | ||
| 237 | 237 | ): CodeExecutor => ({ |
| 238 | 238 | execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker), |
| 239 | 239 | }); |
| 240 | − | |
| @@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | const formatLogLine = (args) => args.map(formatLogArg).join(" "); |
| 26 | 26 | |
| 27 | +const toSerializableValue = (value) => { | |
| 28 | + if (typeof value === "undefined") { | |
| 29 | + return undefined; | |
| 30 | + } | |
| 31 | + | |
| 32 | + return JSON.parse(JSON.stringify(value)); | |
| 33 | +}; | |
| 34 | + | |
| 27 | 35 | const buildExecutionSource = (code) => { |
| 28 | 36 | const trimmed = code.trim(); |
| 29 | 37 | const looksLikeArrowFunction = |
| @@ -157,7 +165,7 @@ process.on("message", async (message) => { · 127 unmodified lines | ||
| 157 | 165 | process.send?.({ |
| 158 | 166 | type: "result", |
| 159 | 167 | id: message.id, |
| 160 | − value: result, | |
| 168 | + value: toSerializableValue(result), | |
| 161 | 169 | logs, |
| 162 | 170 | }); |
| 163 | 171 | } catch (error) { |
| @@ -68,6 +68,7 @@ export type StartLocalExecutorServerOptions = { · 67 unmodified lines | ||
| 68 | 68 | readonly port?: number; |
| 69 | 69 | readonly host?: string; |
| 70 | 70 | readonly localDataDir?: string; |
| 71 | + readonly workspaceRoot?: string; | |
| 71 | 72 | readonly migrationsFolder?: string; |
| 72 | 73 | readonly pidFile?: string; |
| 73 | 74 | readonly executionResolver?: ResolveExecutionEnvironment; |
| @@ -100,6 +101,7 @@ const createRuntime = ( · 26 unmodified lines | ||
| 100 | 101 | createSqlControlPlaneRuntime({ |
| 101 | 102 | localDataDir, |
| 102 | 103 | migrationsFolder: options.migrationsFolder, |
| 104 | + workspaceRoot: options.workspaceRoot, | |
| 103 | 105 | executionResolver: options.executionResolver, |
| 104 | 106 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 105 | 107 | getLocalServerBaseUrl, |