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