Unified · Split
| @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { decideInteractionHandling } from "./interaction-handling"; |
| 4 | 4 | |
| 5 | 5 | describe("interaction-handling", () => { |
| 6 | − it("keeps URL interactions distinct when the terminal is non-interactive", () => { | |
| 6 | + it("keeps URL interactions distinct even when the terminal is non-interactive", () => { | |
| 7 | 7 | expect(decideInteractionHandling({ |
| 8 | 8 | parsed: { |
| 9 | 9 | mode: "url", |
| @@ -15,11 +15,11 @@ import { · 14 unmodified lines | ||
| 15 | 15 | ExecutionIdSchema, |
| 16 | 16 | RuntimeExecutionResolverService, |
| 17 | 17 | createControlPlaneClient, |
| 18 | − createSqlControlPlaneRuntime, | |
| 18 | + createControlPlaneRuntime, | |
| 19 | 19 | type ControlPlaneClient, |
| 20 | 20 | type ExecutionEnvelope, |
| 21 | 21 | type ExecutionInteraction, |
| 22 | − type SqlControlPlaneRuntime, | |
| 22 | + type ControlPlaneRuntime, | |
| 23 | 23 | } from "@executor/control-plane"; |
| 24 | 24 | import type { ToolCatalog } from "@executor/codemode-core"; |
| 25 | 25 | |
| @@ -247,7 +247,7 @@ const formatCatalogUnavailableMessage = (cause: Cause.Cause<unknown>): string => · 221 unmodified lines | ||
| 247 | 247 | : `Current workspace catalog unavailable: ${message}`; |
| 248 | 248 | }; |
| 249 | 249 | |
| 250 | −const closeSqlRuntime = (runtime: SqlControlPlaneRuntime) => | |
| 250 | +const closeRuntime = (runtime: ControlPlaneRuntime) => | |
| 251 | 251 | Effect.tryPromise({ |
| 252 | 252 | try: () => runtime.close(), |
| 253 | 253 | catch: toError, |
| @@ -274,7 +274,7 @@ const buildRunWorkflowText = ( · 20 unmodified lines | ||
| 274 | 274 | |
| 275 | 275 | const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => |
| 276 | 276 | Effect.acquireUseRelease( |
| 277 | − createSqlControlPlaneRuntime({ | |
| 277 | + createControlPlaneRuntime({ | |
| 278 | 278 | localDataDir: DEFAULT_LOCAL_DATA_DIR, |
| 279 | 279 | }).pipe(Effect.mapError(toError)), |
| 280 | 280 | (runtime) => |
| @@ -293,7 +293,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines | ||
| 293 | 293 | |
| 294 | 294 | return yield* buildRunWorkflowText(environment.catalog); |
| 295 | 295 | }), |
| 296 | − closeSqlRuntime, | |
| 296 | + closeRuntime, | |
| 297 | 297 | ).pipe( |
| 298 | 298 | Effect.catchAllCause((cause) => |
| 299 | 299 | Effect.succeed( |
| @@ -100,7 +100,12 @@ describe("portable distribution flow", () => { · 99 unmodified lines | ||
| 100 | 100 | const distDir = join(tempRoot, "dist"); |
| 101 | 101 | const homeDir = join(tempRoot, "home"); |
| 102 | 102 | const binDir = join(homeDir, ".local", "bin"); |
| 103 | − const installHome = join(homeDir, ".local", "share", "executor"); | |
| 103 | + const installHome = process.platform === "darwin" | |
| 104 | + ? join(homeDir, "Library", "Application Support", "Executor") | |
| 105 | + : join(homeDir, ".local", "share", "executor"); | |
| 106 | + const runHome = process.platform === "darwin" | |
| 107 | + ? join(installHome, "run") | |
| 108 | + : join(homeDir, ".local", "state", "executor", "run"); | |
| 104 | 109 | const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor"); |
| 105 | 110 | const baseUrl = `http://127.0.0.1:${await allocatePort()}`; |
| 106 | 111 | |
| @@ -176,9 +181,9 @@ describe("portable distribution flow", () => { · 69 unmodified lines | ||
| 176 | 181 | expect(status.reachable).toBe(true); |
| 177 | 182 | expect(status.pidRunning).toBe(true); |
| 178 | 183 | expect(status.installation).not.toBeNull(); |
| 179 | − expect(status.logFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true); | |
| 184 | + expect(status.logFile.startsWith(runHome)).toBe(true); | |
| 180 | 185 | expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true); |
| 181 | − expect(status.pidFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true); | |
| 186 | + expect(status.pidFile.startsWith(runHome)).toBe(true); | |
| 182 | 187 | |
| 183 | 188 | const html = await fetch(new URL("/", baseUrl)); |
| 184 | 189 | expect(html.status).toBe(200); |
| @@ -7,7 +7,9 @@ import { · 6 unmodified lines | ||
| 7 | 7 | HttpApiGroup, |
| 8 | 8 | HttpApiSchema, |
| 9 | 9 | OpenApi, |
| 10 | + FileSystem, | |
| 10 | 11 | } from "@effect/platform"; |
| 12 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 11 | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | 14 | import { assertTrue } from "@effect/vitest/utils"; |
| 13 | 15 | import * as Effect from "effect/Effect"; |
| @@ -19,10 +21,15 @@ import { · 5 unmodified lines | ||
| 19 | 21 | createControlPlaneClient, |
| 20 | 22 | controlPlaneOpenApiSpec, |
| 21 | 23 | type ControlPlaneClient, |
| 24 | + buildLocalSourceArtifact, | |
| 25 | + deriveLocalInstallation, | |
| 26 | + materializationFromMcpManifestEntries, | |
| 22 | 27 | type ResolveExecutionEnvironment, |
| 28 | + resolveLocalWorkspaceContext, | |
| 23 | 29 | SourceIdSchema, |
| 24 | − SourceRecipeIdSchema, | |
| 25 | 30 | SourceRecipeRevisionIdSchema, |
| 31 | + writeLocalSourceArtifact, | |
| 32 | + writeProjectLocalExecutorConfig, | |
| 26 | 33 | } from "@executor/control-plane"; |
| 27 | 34 | import { makeSesExecutor } from "@executor/runtime-ses"; |
| 28 | 35 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| @@ -58,7 +65,115 @@ const executionResolver: ResolveExecutionEnvironment = () => · 29 unmodified lines | ||
| 58 | 65 | }), |
| 59 | 66 | }); |
| 60 | 67 | |
| 61 | −const makeServer = createLocalExecutorServer({ | |
| 68 | +const makeTempWorkspaceRoot = () => | |
| 69 | + FileSystem.FileSystem.pipe( | |
| 70 | + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "executor-server-test-" })), | |
| 71 | + Effect.provide(NodeFileSystem.layer), | |
| 72 | + ); | |
| 73 | + | |
| 74 | +const createIsolatedLocalExecutorServer = ( | |
| 75 | + options: Parameters<typeof createLocalExecutorServer>[0] = {}, | |
| 76 | +) => | |
| 77 | + Effect.gen(function* () { | |
| 78 | + const workspaceRoot = yield* makeTempWorkspaceRoot(); | |
| 79 | + const server = yield* createLocalExecutorServer({ | |
| 80 | + ...options, | |
| 81 | + workspaceRoot, | |
| 82 | + }); | |
| 83 | + return { | |
| 84 | + ...server, | |
| 85 | + workspaceRoot, | |
| 86 | + }; | |
| 87 | + }); | |
| 88 | + | |
| 89 | +const writeConfiguredLocalMcpSource = (input: { | |
| 90 | + workspaceRoot: string; | |
| 91 | + sourceId: string; | |
| 92 | + endpoint: string; | |
| 93 | + name?: string; | |
| 94 | + namespace?: string; | |
| 95 | +}) => | |
| 96 | + Effect.gen(function* () { | |
| 97 | + const sourceId = SourceIdSchema.make(input.sourceId); | |
| 98 | + const context = yield* resolveLocalWorkspaceContext({ | |
| 99 | + workspaceRoot: input.workspaceRoot, | |
| 100 | + }); | |
| 101 | + const installation = deriveLocalInstallation(context); | |
| 102 | + | |
| 103 | + yield* writeProjectLocalExecutorConfig({ | |
| 104 | + context, | |
| 105 | + config: { | |
| 106 | + sources: { | |
| 107 | + [input.sourceId]: { | |
| 108 | + kind: "mcp", | |
| 109 | + name: input.name ?? "Demo", | |
| 110 | + namespace: input.namespace ?? input.sourceId, | |
| 111 | + connection: { | |
| 112 | + endpoint: input.endpoint, | |
| 113 | + }, | |
| 114 | + binding: { | |
| 115 | + transport: "streamable-http", | |
| 116 | + }, | |
| 117 | + }, | |
| 118 | + }, | |
| 119 | + }, | |
| 120 | + }); | |
| 121 | + | |
| 122 | + const source = { | |
| 123 | + id: sourceId, | |
| 124 | + workspaceId: installation.workspaceId, | |
| 125 | + name: input.name ?? "Demo", | |
| 126 | + kind: "mcp" as const, | |
| 127 | + endpoint: input.endpoint, | |
| 128 | + status: "connected" as const, | |
| 129 | + enabled: true, | |
| 130 | + namespace: input.namespace ?? input.sourceId, | |
| 131 | + bindingVersion: 1, | |
| 132 | + binding: { | |
| 133 | + transport: "streamable-http", | |
| 134 | + queryParams: null, | |
| 135 | + headers: null, | |
| 136 | + }, | |
| 137 | + importAuthPolicy: "reuse_runtime" as const, | |
| 138 | + importAuth: { kind: "none" as const }, | |
| 139 | + auth: { kind: "none" as const }, | |
| 140 | + sourceHash: null, | |
| 141 | + lastError: null, | |
| 142 | + createdAt: Date.now(), | |
| 143 | + updatedAt: Date.now(), | |
| 144 | + }; | |
| 145 | + | |
| 146 | + const materialization = materializationFromMcpManifestEntries({ | |
| 147 | + recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"), | |
| 148 | + endpoint: input.endpoint, | |
| 149 | + manifestEntries: [{ | |
| 150 | + toolId: "gated_echo", | |
| 151 | + toolName: "gated_echo", | |
| 152 | + description: "Asks for approval before echoing a value", | |
| 153 | + inputSchemaJson: JSON.stringify({ | |
| 154 | + type: "object", | |
| 155 | + properties: { | |
| 156 | + value: { | |
| 157 | + type: "string", | |
| 158 | + }, | |
| 159 | + }, | |
| 160 | + required: ["value"], | |
| 161 | + additionalProperties: false, | |
| 162 | + }), | |
| 163 | + }], | |
| 164 | + }); | |
| 165 | + | |
| 166 | + yield* writeLocalSourceArtifact({ | |
| 167 | + context, | |
| 168 | + sourceId, | |
| 169 | + artifact: buildLocalSourceArtifact({ | |
| 170 | + source, | |
| 171 | + materialization, | |
| 172 | + }), | |
| 173 | + }); | |
| 174 | + }); | |
| 175 | + | |
| 176 | +const makeServer = createIsolatedLocalExecutorServer({ | |
| 62 | 177 | port: 0, |
| 63 | 178 | localDataDir: ":memory:", |
| 64 | 179 | executionResolver, |
| @@ -579,28 +694,6 @@ const extractUrlInteractionUrl = (payloadJson: string): string => { · 514 unmodified lines | ||
| 579 | 694 | return url; |
| 580 | 695 | }; |
| 581 | 696 | |
| 582 | −const waitForExecutionCompletion = (input: { | |
| 583 | − client: ControlPlaneClient; | |
| 584 | − workspaceId: string; | |
| 585 | − executionId: string; | |
| 586 | −}) => | |
| 587 | − Effect.gen(function* () { | |
| 588 | − while (true) { | |
| 589 | − const next = yield* input.client.executions.get({ | |
| 590 | − path: { | |
| 591 | − workspaceId: input.workspaceId as never, | |
| 592 | − executionId: input.executionId as never, | |
| 593 | − }, | |
| 594 | − }); | |
| 595 | − | |
| 596 | − if (next.execution.status !== "waiting_for_interaction") { | |
| 597 | − return next; | |
| 598 | − } | |
| 599 | − | |
| 600 | − yield* Effect.sleep("100 millis"); | |
| 601 | − } | |
| 602 | − }); | |
| 603 | − | |
| 604 | 697 | describe("local-executor-server", () => { |
| 605 | 698 | it.scoped("serves the control-plane OpenAPI spec at /v1/openapi.json", () => |
| 606 | 699 | Effect.gen(function* () { |
| @@ -644,6 +737,7 @@ describe("local-executor-server", () => { · 37 unmodified lines | ||
| 644 | 737 | expect(execution.execution.status).toBe("completed"); |
| 645 | 738 | expect(execution.execution.resultJson).toBe(JSON.stringify({ sum: 42 })); |
| 646 | 739 | }), |
| 740 | + 15_000, | |
| 647 | 741 | ); |
| 648 | 742 | |
| 649 | 743 | it.scoped("includes completed MCP return values in text content", () => |
| @@ -681,7 +775,7 @@ describe("local-executor-server", () => { · 31 unmodified lines | ||
| 681 | 775 | |
| 682 | 776 | it.scoped("serves only execute over MCP when elicitation is supported", () => |
| 683 | 777 | Effect.gen(function* () { |
| 684 | − const server = yield* createLocalExecutorServer({ | |
| 778 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 685 | 779 | port: 0, |
| 686 | 780 | localDataDir: ":memory:", |
| 687 | 781 | executionResolver: gatedExecutionResolver, |
| @@ -738,7 +832,7 @@ describe("local-executor-server", () => { · 50 unmodified lines | ||
| 738 | 832 | |
| 739 | 833 | it.scoped("serves execute and resume over MCP when elicitation is unavailable", () => |
| 740 | 834 | Effect.gen(function* () { |
| 741 | − const server = yield* createLocalExecutorServer({ | |
| 835 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 742 | 836 | port: 0, |
| 743 | 837 | localDataDir: ":memory:", |
| 744 | 838 | executionResolver: gatedExecutionResolver, |
| @@ -808,6 +902,7 @@ describe("local-executor-server", () => { · 63 unmodified lines | ||
| 808 | 902 | expect(resumed.structuredContent?.result).toBe("approved:manual-mcp"); |
| 809 | 903 | expect(resumed.content?.find((item) => item.type === "text")?.text).toContain("approved:manual-mcp"); |
| 810 | 904 | }), |
| 905 | + 15_000, | |
| 811 | 906 | ); |
| 812 | 907 | |
| 813 | 908 | it.scoped("loads MCP sources from control-plane state and resumes elicitation", () => |
| @@ -817,7 +912,7 @@ describe("local-executor-server", () => { · 3 unmodified lines | ||
| 817 | 912 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 818 | 913 | ); |
| 819 | 914 | |
| 820 | − const server = yield* createLocalExecutorServer({ | |
| 915 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 821 | 916 | port: 0, |
| 822 | 917 | localDataDir: ":memory:", |
| 823 | 918 | }); |
| @@ -898,7 +993,7 @@ describe("local-executor-server", () => { · 74 unmodified lines | ||
| 898 | 993 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 899 | 994 | ); |
| 900 | 995 | |
| 901 | − const server = yield* createLocalExecutorServer({ | |
| 996 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 902 | 997 | port: 0, |
| 903 | 998 | localDataDir: ":memory:", |
| 904 | 999 | }); |
| @@ -976,7 +1071,7 @@ describe("local-executor-server", () => { · 71 unmodified lines | ||
| 976 | 1071 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 977 | 1072 | ); |
| 978 | 1073 | |
| 979 | − const server = yield* createLocalExecutorServer({ | |
| 1074 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 980 | 1075 | port: 0, |
| 981 | 1076 | localDataDir: ":memory:", |
| 982 | 1077 | }); |
| @@ -1065,7 +1160,7 @@ describe("local-executor-server", () => { · 82 unmodified lines | ||
| 1065 | 1160 | }), |
| 1066 | 1161 | ); |
| 1067 | 1162 | |
| 1068 | − const server = yield* createLocalExecutorServer({ | |
| 1163 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1069 | 1164 | port: 0, |
| 1070 | 1165 | localDataDir: ":memory:", |
| 1071 | 1166 | }); |
| @@ -1111,7 +1206,7 @@ describe("local-executor-server", () => { · 39 unmodified lines | ||
| 1111 | 1206 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1112 | 1207 | ); |
| 1113 | 1208 | |
| 1114 | − const server = yield* createLocalExecutorServer({ | |
| 1209 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1115 | 1210 | port: 0, |
| 1116 | 1211 | localDataDir: ":memory:", |
| 1117 | 1212 | }); |
| @@ -1148,29 +1243,28 @@ describe("local-executor-server", () => { · 30 unmodified lines | ||
| 1148 | 1243 | }), |
| 1149 | 1244 | ); |
| 1150 | 1245 | assertTrue(callbackResponse.ok); |
| 1151 | − expect(yield* Effect.promise(() => callbackResponse.text())).toContain("Source connected:"); | |
| 1152 | − | |
| 1153 | − const completed = yield* waitForExecutionCompletion({ | |
| 1154 | − client, | |
| 1155 | − workspaceId: installation.workspaceId, | |
| 1156 | − executionId: added.execution.id, | |
| 1157 | − }); | |
| 1158 | − | |
| 1159 | − expect(completed.execution.status).toBe("completed"); | |
| 1160 | − expect(completed.pendingInteraction).toBeNull(); | |
| 1161 | − expect(completed.execution.resultJson).toContain('"name":"Axiom"'); | |
| 1162 | − expect(completed.execution.resultJson).toContain('"status":"connected"'); | |
| 1246 | + const callbackText = yield* Effect.promise(() => callbackResponse.text()); | |
| 1247 | + expect(callbackText).toContain("Source connected:"); | |
| 1248 | + | |
| 1249 | + const connectedSource = yield* Effect.gen(function* () { | |
| 1250 | + while (true) { | |
| 1251 | + const sources = yield* client.sources.list({ | |
| 1252 | + path: { | |
| 1253 | + workspaceId: installation.workspaceId, | |
| 1254 | + }, | |
| 1255 | + }); | |
| 1256 | + const source = sources.find((entry) => entry.namespace === "axiom"); | |
| 1257 | + if (source?.status === "connected" && source.auth.kind === "oauth2") { | |
| 1258 | + return source; | |
| 1259 | + } | |
| 1163 | 1260 | |
| 1164 | − const sources = yield* client.sources.list({ | |
| 1165 | − path: { | |
| 1166 | − workspaceId: installation.workspaceId, | |
| 1167 | − }, | |
| 1261 | + yield* Effect.sleep("100 millis"); | |
| 1262 | + } | |
| 1168 | 1263 | }); |
| 1169 | 1264 | |
| 1170 | − expect(sources).toHaveLength(1); | |
| 1171 | − expect(sources[0]?.name).toBe("Axiom"); | |
| 1172 | − expect(sources[0]?.status).toBe("connected"); | |
| 1173 | − expect(sources[0]?.auth.kind).toBe("oauth2"); | |
| 1265 | + expect(connectedSource.name).toBe("Axiom"); | |
| 1266 | + expect(connectedSource.status).toBe("connected"); | |
| 1267 | + expect(connectedSource.auth.kind).toBe("oauth2"); | |
| 1174 | 1268 | |
| 1175 | 1269 | const toolCall = yield* client.executions.create({ |
| 1176 | 1270 | path: { |
| @@ -1188,13 +1282,13 @@ describe("local-executor-server", () => { · 11 unmodified lines | ||
| 1188 | 1282 | 15_000, |
| 1189 | 1283 | ); |
| 1190 | 1284 | |
| 1191 | − it.scoped("gates mutating OpenAPI tools by default and allows them via organization policy", () => | |
| 1285 | + it.scoped("gates mutating OpenAPI tools by default and allows them via workspace policy", () => | |
| 1192 | 1286 | Effect.gen(function* () { |
| 1193 | 1287 | const openApiServer = yield* Effect.acquireRelease( |
| 1194 | 1288 | Effect.promise(() => startMutatingOpenApiDemoServer()), |
| 1195 | 1289 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1196 | 1290 | ); |
| 1197 | − const server = yield* createLocalExecutorServer({ | |
| 1291 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1198 | 1292 | port: 0, |
| 1199 | 1293 | localDataDir: ":memory:", |
| 1200 | 1294 | }); |
| @@ -1260,19 +1354,17 @@ describe("local-executor-server", () => { · 59 unmodified lines | ||
| 1260 | 1354 | expect(approved.execution.status).toBe("completed"); |
| 1261 | 1355 | expect(openApiServer.createdBodies).toHaveLength(1); |
| 1262 | 1356 | |
| 1263 | − const policy = yield* client.policies.createOrganization({ | |
| 1357 | + const policy = yield* client.policies.create({ | |
| 1264 | 1358 | path: { |
| 1265 | − organizationId: installation.organizationId, | |
| 1359 | + workspaceId: installation.workspaceId, | |
| 1266 | 1360 | }, |
| 1267 | 1361 | payload: { |
| 1268 | − resourceType: "tool_path", | |
| 1269 | 1362 | resourcePattern: "dns.records.createRecord", |
| 1270 | − matchType: "exact", | |
| 1271 | 1363 | effect: "allow", |
| 1272 | 1364 | approvalMode: "auto", |
| 1273 | 1365 | }, |
| 1274 | 1366 | }); |
| 1275 | − expect(policy.scopeType).toBe("organization"); | |
| 1367 | + expect(policy.key).toBe("dns.records.createRecord"); | |
| 1276 | 1368 | |
| 1277 | 1369 | const automatic = yield* client.executions.create({ |
| 1278 | 1370 | path: { |
| @@ -1287,6 +1379,7 @@ describe("local-executor-server", () => { · 8 unmodified lines | ||
| 1287 | 1379 | expect(automatic.pendingInteraction).toBeNull(); |
| 1288 | 1380 | expect(openApiServer.createdBodies).toHaveLength(2); |
| 1289 | 1381 | }), |
| 1382 | + 15_000, | |
| 1290 | 1383 | ); |
| 1291 | 1384 | |
| 1292 | 1385 | it.scoped("starts source OAuth without creating a source and stores secrets on callback", () => |
| @@ -1296,7 +1389,7 @@ describe("local-executor-server", () => { · 3 unmodified lines | ||
| 1296 | 1389 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1297 | 1390 | ); |
| 1298 | 1391 | |
| 1299 | − const server = yield* createLocalExecutorServer({ | |
| 1392 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1300 | 1393 | port: 0, |
| 1301 | 1394 | localDataDir: ":memory:", |
| 1302 | 1395 | }); |
| @@ -1360,11 +1453,12 @@ describe("local-executor-server", () => { · 57 unmodified lines | ||
| 1360 | 1453 | expect(secrets.some((secret) => secret.purpose === "oauth_access_token")).toBe(true); |
| 1361 | 1454 | expect(secrets.some((secret) => secret.purpose === "oauth_refresh_token")).toBe(true); |
| 1362 | 1455 | }), |
| 1456 | + 15_000, | |
| 1363 | 1457 | ); |
| 1364 | 1458 | |
| 1365 | 1459 | it.scoped("marks execution failed when a configured MCP endpoint is invalid", () => |
| 1366 | 1460 | Effect.gen(function* () { |
| 1367 | − const server = yield* createLocalExecutorServer({ | |
| 1461 | + const server = yield* createIsolatedLocalExecutorServer({ | |
| 1368 | 1462 | port: 0, |
| 1369 | 1463 | localDataDir: ":memory:", |
| 1370 | 1464 | }); |
| @@ -1377,123 +1471,12 @@ describe("local-executor-server", () => { · 6 unmodified lines | ||
| 1377 | 1471 | baseUrl: server.baseUrl, |
| 1378 | 1472 | accountId: installation.accountId, |
| 1379 | 1473 | }); |
| 1380 | − | |
| 1381 | − const sourceId = SourceIdSchema.make("src_invalid_mcp_endpoint"); | |
| 1382 | − const recipeId = SourceRecipeIdSchema.make(`src_recipe_${sourceId}`); | |
| 1383 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); | |
| 1384 | − const now = Date.now(); | |
| 1385 | − const manifest = { | |
| 1386 | − version: 1 as const, | |
| 1387 | − tools: [{ | |
| 1388 | − toolId: "gated_echo", | |
| 1389 | − toolName: "gated_echo", | |
| 1390 | − description: "Asks for approval before echoing a value", | |
| 1391 | − inputSchemaJson: JSON.stringify({ | |
| 1392 | − type: "object", | |
| 1393 | − properties: { | |
| 1394 | − value: { | |
| 1395 | − type: "string", | |
| 1396 | − }, | |
| 1397 | − }, | |
| 1398 | − required: ["value"], | |
| 1399 | − additionalProperties: false, | |
| 1400 | − }), | |
| 1401 | − }], | |
| 1402 | − }; | |
| 1403 | − const manifestJson = JSON.stringify(manifest); | |
| 1404 | − yield* server.runtime.persistence.rows.sourceRecipes.upsert({ | |
| 1405 | − id: recipeId, | |
| 1406 | − kind: "mcp", | |
| 1407 | − adapterKey: "mcp", | |
| 1408 | − providerKey: "generic_mcp", | |
| 1409 | − name: "Demo", | |
| 1410 | − summary: null, | |
| 1411 | − visibility: "workspace", | |
| 1412 | − latestRevisionId: recipeRevisionId, | |
| 1413 | − createdAt: now, | |
| 1414 | − updatedAt: now, | |
| 1415 | − }); | |
| 1416 | − yield* server.runtime.persistence.rows.sourceRecipeRevisions.upsert({ | |
| 1417 | − id: recipeRevisionId, | |
| 1418 | − recipeId, | |
| 1419 | − revisionNumber: 1, | |
| 1420 | − sourceConfigJson: JSON.stringify({ | |
| 1421 | − kind: "mcp", | |
| 1422 | − endpoint: "http://127.0.0.1:PORT/mcp", | |
| 1423 | − transport: "streamable-http", | |
| 1424 | − queryParams: null, | |
| 1425 | − headers: null, | |
| 1426 | − }), | |
| 1427 | − manifestJson, | |
| 1428 | − manifestHash: null, | |
| 1429 | − materializationHash: null, | |
| 1430 | − createdAt: now, | |
| 1431 | − updatedAt: now, | |
| 1432 | − }); | |
| 1433 | − yield* server.runtime.persistence.rows.sources.insert({ | |
| 1434 | − id: sourceId, | |
| 1435 | − workspaceId: installation.workspaceId, | |
| 1436 | − recipeId, | |
| 1437 | − recipeRevisionId, | |
| 1438 | − name: "Demo", | |
| 1439 | − kind: "mcp", | |
| 1474 | + yield* writeConfiguredLocalMcpSource({ | |
| 1475 | + workspaceRoot: server.workspaceRoot, | |
| 1476 | + sourceId: "demo", | |
| 1440 | 1477 | endpoint: "http://127.0.0.1:PORT/mcp", |
| 1441 | − status: "connected", | |
| 1442 | − enabled: true, | |
| 1478 | + name: "Demo", | |
| 1443 | 1479 | namespace: "demo", |
| 1444 | − importAuthPolicy: "reuse_runtime", | |
| 1445 | − bindingConfigJson: JSON.stringify({ | |
| 1446 | − adapterKey: "mcp", | |
| 1447 | − version: 1, | |
| 1448 | − payload: { | |
| 1449 | − transport: "streamable-http", | |
| 1450 | − queryParams: null, | |
| 1451 | − headers: null, | |
| 1452 | − }, | |
| 1453 | − }), | |
| 1454 | − sourceHash: null, | |
| 1455 | − lastError: null, | |
| 1456 | − createdAt: now, | |
| 1457 | − updatedAt: now, | |
| 1458 | − }); | |
| 1459 | − yield* server.runtime.persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 1460 | − recipeRevisionId, | |
| 1461 | − documents: [{ | |
| 1462 | − id: `src_recipe_doc_${sourceId}`, | |
| 1463 | − recipeRevisionId, | |
| 1464 | − documentKind: "mcp_manifest", | |
| 1465 | − documentKey: "manifest", | |
| 1466 | − contentText: manifestJson, | |
| 1467 | − contentHash: `manifest_${sourceId}`, | |
| 1468 | − fetchedAt: now, | |
| 1469 | − createdAt: now, | |
| 1470 | − updatedAt: now, | |
| 1471 | − }], | |
| 1472 | − }); | |
| 1473 | − yield* server.runtime.persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 1474 | − recipeRevisionId, | |
| 1475 | − operations: [{ | |
| 1476 | − id: `src_recipe_op_${sourceId}`, | |
| 1477 | − recipeRevisionId, | |
| 1478 | − operationKey: "gated_echo", | |
| 1479 | − transportKind: "mcp", | |
| 1480 | − toolId: "gated_echo", | |
| 1481 | − title: "gated_echo", | |
| 1482 | − description: "Asks for approval before echoing a value", | |
| 1483 | − operationKind: "unknown", | |
| 1484 | − searchText: "demo.gated_echo gated echo approval", | |
| 1485 | − inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null, | |
| 1486 | − outputSchemaJson: null, | |
| 1487 | − providerKind: "mcp", | |
| 1488 | − providerDataJson: JSON.stringify({ | |
| 1489 | − kind: "mcp", | |
| 1490 | − toolId: "gated_echo", | |
| 1491 | − toolName: "gated_echo", | |
| 1492 | − description: "Asks for approval before echoing a value", | |
| 1493 | − }), | |
| 1494 | − createdAt: now, | |
| 1495 | − updatedAt: now, | |
| 1496 | − }], | |
| 1497 | 1480 | }); |
| 1498 | 1481 | |
| 1499 | 1482 | const execution = yield* client.executions.create({ |
| @@ -10,6 +10,9 @@ · 9 unmodified lines | ||
| 10 | 10 | "typescript": "^5.9.3", |
| 11 | 11 | }, |
| 12 | 12 | }, |
| 13 | + "apps/docs": { | |
| 14 | + "name": "@executor/docs", | |
| 15 | + }, | |
| 13 | 16 | "apps/executor": { |
| 14 | 17 | "name": "executor", |
| 15 | 18 | "version": "1.1.9", |
| @@ -79,9 +82,6 @@ · 63 unmodified lines | ||
| 79 | 82 | "vite": "^7.3.1", |
| 80 | 83 | }, |
| 81 | 84 | }, |
| 82 | − "docs": { | |
| 83 | − "name": "@executor/docs", | |
| 84 | − }, | |
| 85 | 85 | "packages/ai-sdk-adapter": { |
| 86 | 86 | "name": "@executor/ai-sdk-adapter", |
| 87 | 87 | "dependencies": { |
| @@ -171,6 +171,7 @@ · 83 unmodified lines | ||
| 171 | 171 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 172 | 172 | "effect": "catalog:", |
| 173 | 173 | "graphql": "^16.12.0", |
| 174 | + "jsonc-parser": "3.3.1", | |
| 174 | 175 | "postgres": "^3.4.7", |
| 175 | 176 | "prettier": "^3.8.1", |
| 176 | 177 | }, |
| @@ -496,7 +497,7 @@ · 319 unmodified lines | ||
| 496 | 497 | |
| 497 | 498 | "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"], |
| 498 | 499 | |
| 499 | − "@executor/docs": ["@executor/docs@workspace:docs"], | |
| 500 | + "@executor/docs": ["@executor/docs@workspace:apps/docs"], | |
| 500 | 501 | |
| 501 | 502 | "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"], |
| 502 | 503 | |
| @@ -34,6 +34,7 @@ · 33 unmodified lines | ||
| 34 | 34 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 35 | 35 | "effect": "catalog:", |
| 36 | 36 | "graphql": "^16.12.0", |
| 37 | + "jsonc-parser": "3.3.1", | |
| 37 | 38 | "postgres": "^3.4.7", |
| 38 | 39 | "prettier": "^3.8.1" |
| 39 | 40 | }, |
| @@ -2,26 +2,20 @@ import { HttpApi, OpenApi } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { ExecutionsApi } from "./executions/api"; |
| 4 | 4 | import { LocalApi } from "./local/api"; |
| 5 | −import { MembershipsApi } from "./memberships/api"; | |
| 6 | 5 | import { OAuthApi } from "./oauth/api"; |
| 7 | −import { OrganizationsApi } from "./organizations/api"; | |
| 8 | 6 | import { PoliciesApi } from "./policies/api"; |
| 9 | 7 | import { SourcesApi } from "./sources/api"; |
| 10 | −import { WorkspacesApi } from "./workspaces/api"; | |
| 11 | 8 | |
| 12 | 9 | export class ControlPlaneApi extends HttpApi.make("controlPlane") |
| 13 | 10 | .add(LocalApi) |
| 14 | 11 | .add(OAuthApi) |
| 15 | − .add(OrganizationsApi) | |
| 16 | − .add(MembershipsApi) | |
| 17 | − .add(WorkspacesApi) | |
| 18 | 12 | .add(SourcesApi) |
| 19 | 13 | .add(PoliciesApi) |
| 20 | 14 | .add(ExecutionsApi) |
| 21 | 15 | .annotateContext( |
| 22 | 16 | OpenApi.annotations({ |
| 23 | 17 | title: "Executor Control Plane API", |
| 24 | − description: "CRUD control plane for organizations, workspaces, sources, and policies", | |
| 18 | + description: "Local-first control plane for workspace sources, policies, auth, and execution", | |
| 25 | 19 | }), |
| 26 | 20 | ) {} |
| 27 | 21 | |
| @@ -1,31 +0,0 @@ | ||
| 1 | −import { type WorkspaceId } from "#schema"; | |
| 2 | −import * as PlatformHeaders from "@effect/platform/Headers"; | |
| 3 | −import * as Context from "effect/Context"; | |
| 4 | −import * as Effect from "effect/Effect"; | |
| 5 | − | |
| 6 | −import { | |
| 7 | − type ActorShape, | |
| 8 | − type ActorUnauthenticatedError, | |
| 9 | −} from "#domain"; | |
| 10 | − | |
| 11 | −export type ResolveActorInput = { | |
| 12 | − headers: PlatformHeaders.Headers; | |
| 13 | −}; | |
| 14 | − | |
| 15 | −export type ResolveWorkspaceActorInput = { | |
| 16 | − workspaceId: WorkspaceId; | |
| 17 | − headers: PlatformHeaders.Headers; | |
| 18 | −}; | |
| 19 | − | |
| 20 | −export type ControlPlaneActorResolverShape = { | |
| 21 | − resolveActor: ( | |
| 22 | − input: ResolveActorInput, | |
| 23 | − ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>; | |
| 24 | − resolveWorkspaceActor: ( | |
| 25 | − input: ResolveWorkspaceActorInput, | |
| 26 | − ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>; | |
| 27 | −}; | |
| 28 | − | |
| 29 | −export class ControlPlaneActorResolver extends Context.Tag( | |
| 30 | − "#api/ControlPlaneActorResolver", | |
| 31 | −)<ControlPlaneActorResolver, ControlPlaneActorResolverShape>() {} | |
| @@ -1,59 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { | |
| 3 | − AccountIdSchema, | |
| 4 | − OrganizationIdSchema, | |
| 5 | − OrganizationMemberIdSchema, | |
| 6 | − WorkspaceIdSchema, | |
| 7 | −} from "#schema"; | |
| 8 | − | |
| 9 | −import { deriveWorkspaceMembershipsForPrincipal } from "./workspace-membership"; | |
| 10 | − | |
| 11 | −describe("workspace membership derivation", () => { | |
| 12 | − it("prefers highest active role in matching organization", () => { | |
| 13 | − const principalAccountId = AccountIdSchema.make("acc_1"); | |
| 14 | − const workspaceId = WorkspaceIdSchema.make("ws_1"); | |
| 15 | − const organizationId = OrganizationIdSchema.make("org_1"); | |
| 16 | − | |
| 17 | − const memberships = deriveWorkspaceMembershipsForPrincipal({ | |
| 18 | − principalAccountId, | |
| 19 | − workspaceId, | |
| 20 | − workspace: { | |
| 21 | − id: workspaceId, | |
| 22 | − organizationId, | |
| 23 | − name: "Main", | |
| 24 | − createdByAccountId: null, | |
| 25 | − createdAt: 1, | |
| 26 | − updatedAt: 1, | |
| 27 | − }, | |
| 28 | − organizationMemberships: [ | |
| 29 | − { | |
| 30 | − id: OrganizationMemberIdSchema.make("mem_1"), | |
| 31 | − organizationId, | |
| 32 | − accountId: principalAccountId, | |
| 33 | − role: "viewer", | |
| 34 | − status: "active", | |
| 35 | − billable: true, | |
| 36 | − invitedByAccountId: null, | |
| 37 | − joinedAt: 1, | |
| 38 | − createdAt: 1, | |
| 39 | − updatedAt: 1, | |
| 40 | − }, | |
| 41 | − { | |
| 42 | − id: OrganizationMemberIdSchema.make("mem_2"), | |
| 43 | − organizationId, | |
| 44 | − accountId: principalAccountId, | |
| 45 | − role: "admin", | |
| 46 | − status: "active", | |
| 47 | − billable: true, | |
| 48 | − invitedByAccountId: null, | |
| 49 | − joinedAt: 2, | |
| 50 | − createdAt: 2, | |
| 51 | − updatedAt: 2, | |
| 52 | − }, | |
| 53 | − ], | |
| 54 | − }); | |
| 55 | − | |
| 56 | − expect(memberships).toHaveLength(1); | |
| 57 | − expect(memberships[0]?.role).toBe("admin"); | |
| 58 | − }); | |
| 59 | −}); | |
| @@ -1,118 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type AccountId, | |
| 3 | − type OrganizationMembership, | |
| 4 | − type OrganizationMemberStatus, | |
| 5 | − type Role, | |
| 6 | − type Workspace, | |
| 7 | − type WorkspaceMembership, | |
| 8 | −} from "#schema"; | |
| 9 | − | |
| 10 | −type WorkspaceMembershipCandidate = Pick< | |
| 11 | − WorkspaceMembership, | |
| 12 | − "role" | "status" | "grantedAt" | "updatedAt" | |
| 13 | −>; | |
| 14 | − | |
| 15 | −const roleRank: Readonly<Record<Role, number>> = { | |
| 16 | − viewer: 0, | |
| 17 | − editor: 1, | |
| 18 | − admin: 2, | |
| 19 | − owner: 3, | |
| 20 | −}; | |
| 21 | − | |
| 22 | −const toWorkspaceMembershipStatus = ( | |
| 23 | − status: OrganizationMemberStatus, | |
| 24 | −): WorkspaceMembership["status"] => { | |
| 25 | − switch (status) { | |
| 26 | − case "active": | |
| 27 | − return "active"; | |
| 28 | − case "suspended": | |
| 29 | − return "suspended"; | |
| 30 | − default: | |
| 31 | − return "revoked"; | |
| 32 | − } | |
| 33 | −}; | |
| 34 | − | |
| 35 | −const compareMembershipPriority = ( | |
| 36 | − left: WorkspaceMembershipCandidate, | |
| 37 | − right: WorkspaceMembershipCandidate, | |
| 38 | −): number => { | |
| 39 | − const rankDelta = roleRank[left.role] - roleRank[right.role]; | |
| 40 | − if (rankDelta !== 0) { | |
| 41 | − return rankDelta; | |
| 42 | − } | |
| 43 | − | |
| 44 | − return left.updatedAt - right.updatedAt; | |
| 45 | −}; | |
| 46 | − | |
| 47 | −export const deriveWorkspaceMembershipsForPrincipal = (input: { | |
| 48 | − principalAccountId: AccountId; | |
| 49 | − workspaceId: Workspace["id"]; | |
| 50 | − workspace: Workspace | null; | |
| 51 | − organizationMemberships: ReadonlyArray<OrganizationMembership>; | |
| 52 | −}): ReadonlyArray<WorkspaceMembership> => { | |
| 53 | − if (input.workspace === null) { | |
| 54 | − return []; | |
| 55 | − } | |
| 56 | − | |
| 57 | − const workspace = input.workspace; | |
| 58 | − const candidates: Array<WorkspaceMembershipCandidate> = []; | |
| 59 | − | |
| 60 | − const hasActiveOrganizationMembership = input.organizationMemberships.some( | |
| 61 | − (membership) => | |
| 62 | − membership.accountId === input.principalAccountId | |
| 63 | − && membership.organizationId === workspace.organizationId | |
| 64 | − && membership.status === "active", | |
| 65 | − ); | |
| 66 | − | |
| 67 | − if ( | |
| 68 | − workspace.createdByAccountId === input.principalAccountId | |
| 69 | − && hasActiveOrganizationMembership | |
| 70 | − ) { | |
| 71 | − candidates.push({ | |
| 72 | − role: "owner", | |
| 73 | − status: "active", | |
| 74 | − grantedAt: workspace.createdAt, | |
| 75 | − updatedAt: workspace.updatedAt, | |
| 76 | − }); | |
| 77 | − } | |
| 78 | − | |
| 79 | − for (const membership of input.organizationMemberships) { | |
| 80 | − if (membership.accountId !== input.principalAccountId) { | |
| 81 | − continue; | |
| 82 | − } | |
| 83 | − | |
| 84 | − if (membership.organizationId !== workspace.organizationId) { | |
| 85 | − continue; | |
| 86 | − } | |
| 87 | − | |
| 88 | − candidates.push({ | |
| 89 | − role: membership.role, | |
| 90 | − status: toWorkspaceMembershipStatus(membership.status), | |
| 91 | − grantedAt: membership.joinedAt ?? membership.createdAt, | |
| 92 | − updatedAt: membership.updatedAt, | |
| 93 | − }); | |
| 94 | − } | |
| 95 | − | |
| 96 | − const activeCandidates = candidates.filter( | |
| 97 | − (candidate) => candidate.status === "active", | |
| 98 | − ); | |
| 99 | − | |
| 100 | − if (activeCandidates.length === 0) { | |
| 101 | − return []; | |
| 102 | − } | |
| 103 | − | |
| 104 | − const selected = activeCandidates.reduce((best, candidate) => | |
| 105 | − compareMembershipPriority(candidate, best) > 0 ? candidate : best, | |
| 106 | − ); | |
| 107 | − | |
| 108 | − return [ | |
| 109 | − { | |
| 110 | − accountId: input.principalAccountId, | |
| 111 | − workspaceId: input.workspaceId, | |
| 112 | − role: selected.role, | |
| 113 | − status: selected.status, | |
| 114 | − grantedAt: selected.grantedAt, | |
| 115 | − updatedAt: selected.updatedAt, | |
| 116 | − }, | |
| 117 | − ]; | |
| 118 | −}; | |
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | −import type { WorkspaceId } from "#schema"; | |
| 3 | − | |
| 4 | −import { requirePermission, withPolicy } from "#domain"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 5 | 3 | import { |
| 6 | 4 | createExecution, |
| 7 | 5 | getExecution, |
| @@ -9,13 +7,7 @@ import { · 1 unmodified line | ||
| 9 | 7 | } from "../../runtime/execution-service"; |
| 10 | 8 | |
| 11 | 9 | import { ControlPlaneApi } from "../api"; |
| 12 | −import { withWorkspaceRequestActor } from "../http-auth"; | |
| 13 | − | |
| 14 | −const requireExecuteWorkspace = (workspaceId: WorkspaceId) => | |
| 15 | − requirePermission({ | |
| 16 | − permission: "workspace:read", | |
| 17 | − workspaceId, | |
| 18 | − }); | |
| 10 | +import { resolveRequestedLocalWorkspace } from "../local-context"; | |
| 19 | 11 | |
| 20 | 12 | export const ControlPlaneExecutionsLive = HttpApiBuilder.group( |
| 21 | 13 | ControlPlaneApi, |
| @@ -23,19 +15,19 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 1 unmodified line | ||
| 23 | 15 | (handlers) => |
| 24 | 16 | handlers |
| 25 | 17 | .handle("create", ({ path, payload }) => |
| 26 | − withWorkspaceRequestActor("executions.create", path.workspaceId, (actor) => | |
| 27 | − withPolicy(requireExecuteWorkspace(path.workspaceId))( | |
| 18 | + resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe( | |
| 19 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 28 | 20 | createExecution({ |
| 29 | 21 | workspaceId: path.workspaceId, |
| 30 | 22 | payload, |
| 31 | − createdByAccountId: actor.principal.accountId, | |
| 32 | − }), | |
| 23 | + createdByAccountId: runtimeLocalWorkspace.installation.accountId, | |
| 24 | + }) | |
| 33 | 25 | ), |
| 34 | 26 | ), |
| 35 | 27 | ) |
| 36 | 28 | .handle("get", ({ path }) => |
| 37 | − withWorkspaceRequestActor("executions.get", path.workspaceId, () => | |
| 38 | − withPolicy(requireExecuteWorkspace(path.workspaceId))( | |
| 29 | + resolveRequestedLocalWorkspace("executions.get", path.workspaceId).pipe( | |
| 30 | + Effect.zipRight( | |
| 39 | 31 | getExecution({ |
| 40 | 32 | workspaceId: path.workspaceId, |
| 41 | 33 | executionId: path.executionId, |
| @@ -44,14 +36,14 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 2 unmodified lines | ||
| 44 | 36 | ), |
| 45 | 37 | ) |
| 46 | 38 | .handle("resume", ({ path, payload }) => |
| 47 | − withWorkspaceRequestActor("executions.resume", path.workspaceId, (actor) => | |
| 48 | − withPolicy(requireExecuteWorkspace(path.workspaceId))( | |
| 39 | + resolveRequestedLocalWorkspace("executions.resume", path.workspaceId).pipe( | |
| 40 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 49 | 41 | resumeExecution({ |
| 50 | 42 | workspaceId: path.workspaceId, |
| 51 | 43 | executionId: path.executionId, |
| 52 | 44 | payload, |
| 53 | − resumedByAccountId: actor.principal.accountId, | |
| 54 | − }), | |
| 45 | + resumedByAccountId: runtimeLocalWorkspace.installation.accountId, | |
| 46 | + }) | |
| 55 | 47 | ), |
| 56 | 48 | ), |
| 57 | 49 | ), |
| @@ -1,98 +0,0 @@ | ||
| 1 | −import { HttpServerRequest } from "@effect/platform"; | |
| 2 | −import type { WorkspaceId } from "#schema"; | |
| 3 | −import { | |
| 4 | − Actor, | |
| 5 | − ActorForbiddenError, | |
| 6 | − type ActorShape, | |
| 7 | − ActorUnauthenticatedError, | |
| 8 | −} from "#domain"; | |
| 9 | −import * as Effect from "effect/Effect"; | |
| 10 | − | |
| 11 | −import { ControlPlaneActorResolver } from "./auth/actor-resolver"; | |
| 12 | −import { | |
| 13 | − ControlPlaneForbiddenError, | |
| 14 | − ControlPlaneUnauthorizedError, | |
| 15 | −} from "./errors"; | |
| 16 | − | |
| 17 | −const requestHeaders = Effect.map( | |
| 18 | − HttpServerRequest.HttpServerRequest, | |
| 19 | − (request) => request.headers, | |
| 20 | −); | |
| 21 | − | |
| 22 | −const readString = (value: unknown, fallback: string): string => | |
| 23 | − typeof value === "string" && value.length > 0 ? value : fallback; | |
| 24 | − | |
| 25 | −const toForbiddenError = ( | |
| 26 | − operation: string, | |
| 27 | − cause: unknown, | |
| 28 | −): ControlPlaneForbiddenError => | |
| 29 | − new ControlPlaneForbiddenError({ | |
| 30 | − operation, | |
| 31 | − message: "Access denied", | |
| 32 | − details: `${readString((cause as { permission?: unknown })?.permission, "unknown")} on ${readString((cause as { scope?: unknown })?.scope, "unknown")}`, | |
| 33 | − }); | |
| 34 | − | |
| 35 | −const toUnauthorizedError = ( | |
| 36 | − operation: string, | |
| 37 | − cause: unknown, | |
| 38 | −): ControlPlaneUnauthorizedError => | |
| 39 | − new ControlPlaneUnauthorizedError({ | |
| 40 | − operation, | |
| 41 | − message: readString((cause as { message?: unknown })?.message, "Authentication required"), | |
| 42 | − details: "Authentication required", | |
| 43 | − }); | |
| 44 | − | |
| 45 | −export const resolveRequestActor = Effect.flatMap( | |
| 46 | − ControlPlaneActorResolver, | |
| 47 | − (actorResolver) => | |
| 48 | − Effect.flatMap(requestHeaders, (headers) => actorResolver.resolveActor({ headers })), | |
| 49 | −); | |
| 50 | − | |
| 51 | −export const resolveWorkspaceRequestActor = (workspaceId: WorkspaceId) => | |
| 52 | − Effect.flatMap(ControlPlaneActorResolver, (actorResolver) => | |
| 53 | − Effect.flatMap(requestHeaders, (headers) => | |
| 54 | − actorResolver.resolveWorkspaceActor({ workspaceId, headers }) | |
| 55 | − )); | |
| 56 | − | |
| 57 | −const provideActor = <A, E, R>( | |
| 58 | − actor: ActorShape, | |
| 59 | − effect: Effect.Effect<A, E, R>, | |
| 60 | −) => effect.pipe(Effect.provideService(Actor, actor)); | |
| 61 | − | |
| 62 | −export const mapActorHttpErrors = <A, E, R>( | |
| 63 | − operation: string, | |
| 64 | − effect: Effect.Effect<A, E | ActorUnauthenticatedError | ActorForbiddenError, R>, | |
| 65 | −): Effect.Effect<A, E | ControlPlaneUnauthorizedError | ControlPlaneForbiddenError, R> => | |
| 66 | − effect.pipe( | |
| 67 | − Effect.catchTag("ActorUnauthenticatedError", (cause) => | |
| 68 | − Effect.fail(toUnauthorizedError(operation, cause)), | |
| 69 | − ), | |
| 70 | − Effect.catchTag("ActorForbiddenError", (cause) => | |
| 71 | − Effect.fail(toForbiddenError(operation, cause)), | |
| 72 | − ), | |
| 73 | − ); | |
| 74 | − | |
| 75 | −export const withRequestActor = <A, E, R>( | |
| 76 | − operation: string, | |
| 77 | − run: (actor: ActorShape) => Effect.Effect<A, E | ActorForbiddenError, R | Actor>, | |
| 78 | −) => | |
| 79 | − mapActorHttpErrors( | |
| 80 | − operation, | |
| 81 | − Effect.gen(function* () { | |
| 82 | − const actor = yield* resolveRequestActor; | |
| 83 | − return yield* provideActor(actor, run(actor)); | |
| 84 | − }), | |
| 85 | − ); | |
| 86 | − | |
| 87 | −export const withWorkspaceRequestActor = <A, E, R>( | |
| 88 | − operation: string, | |
| 89 | − workspaceId: WorkspaceId, | |
| 90 | − run: (actor: ActorShape) => Effect.Effect<A, E | ActorForbiddenError, R | Actor>, | |
| 91 | −) => | |
| 92 | − mapActorHttpErrors( | |
| 93 | − operation, | |
| 94 | − Effect.gen(function* () { | |
| 95 | − const actor = yield* resolveWorkspaceRequestActor(workspaceId); | |
| 96 | − return yield* provideActor(actor, run(actor)); | |
| 97 | − }), | |
| 98 | − ); | |
| @@ -2,25 +2,15 @@ import { HttpApiBuilder } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | |
| 4 | 4 | import { ControlPlaneApi } from "./api"; |
| 5 | −import { | |
| 6 | − ControlPlaneActorResolver, | |
| 7 | − type ControlPlaneActorResolverShape, | |
| 8 | −} from "./auth/actor-resolver"; | |
| 9 | 5 | import { ControlPlaneExecutionsLive } from "./executions/http"; |
| 10 | 6 | import { ControlPlaneLocalLive } from "./local/http"; |
| 11 | −import { ControlPlaneMembershipsLive } from "./memberships/http"; | |
| 12 | 7 | import { ControlPlaneOAuthLive } from "./oauth/http"; |
| 13 | −import { ControlPlaneOrganizationsLive } from "./organizations/http"; | |
| 14 | 8 | import { ControlPlanePoliciesLive } from "./policies/http"; |
| 15 | 9 | import { ControlPlaneSourcesLive } from "./sources/http"; |
| 16 | −import { ControlPlaneWorkspacesLive } from "./workspaces/http"; | |
| 17 | 10 | |
| 18 | 11 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( |
| 19 | 12 | Layer.provide(ControlPlaneLocalLive), |
| 20 | 13 | Layer.provide(ControlPlaneOAuthLive), |
| 21 | − Layer.provide(ControlPlaneOrganizationsLive), | |
| 22 | − Layer.provide(ControlPlaneMembershipsLive), | |
| 23 | − Layer.provide(ControlPlaneWorkspacesLive), | |
| 24 | 14 | Layer.provide(ControlPlaneSourcesLive), |
| 25 | 15 | Layer.provide(ControlPlanePoliciesLive), |
| 26 | 16 | Layer.provide(ControlPlaneExecutionsLive), |
| @@ -40,7 +30,3 @@ export const createControlPlaneApiLayer = <ERuntime>( · 13 unmodified lines | ||
| 40 | 30 | ControlPlaneApiLive.pipe( |
| 41 | 31 | Layer.provide(runtimeLayer), |
| 42 | 32 | ); |
| 43 | − | |
| 44 | −export const ControlPlaneActorResolverLive = ( | |
| 45 | − resolver: ControlPlaneActorResolverShape, | |
| 46 | −) => Layer.succeed(ControlPlaneActorResolver, resolver); | |
| @@ -17,20 +17,9 @@ export { · 16 unmodified lines | ||
| 17 | 17 | ControlPlaneApiLive, |
| 18 | 18 | type ControlPlaneApiRuntimeContext, |
| 19 | 19 | type BuiltControlPlaneApiLayer, |
| 20 | − ControlPlaneActorResolverLive, | |
| 21 | 20 | createControlPlaneApiLayer, |
| 22 | 21 | } from "./http"; |
| 23 | 22 | |
| 24 | −export { | |
| 25 | − ControlPlaneActorResolver, | |
| 26 | − type ControlPlaneActorResolverShape, | |
| 27 | − type ResolveActorInput, | |
| 28 | − type ResolveWorkspaceActorInput, | |
| 29 | −} from "./auth/actor-resolver"; | |
| 30 | − | |
| 31 | −export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership"; | |
| 32 | − | |
| 33 | − | |
| 34 | 23 | export { |
| 35 | 24 | CreateExecutionPayloadSchema, |
| 36 | 25 | ResumeExecutionPayloadSchema, |
| @@ -60,27 +49,6 @@ export { · 23 unmodified lines | ||
| 60 | 49 | type CompleteSourceOAuthResult, |
| 61 | 50 | } from "./oauth/api"; |
| 62 | 51 | |
| 63 | −export { | |
| 64 | − CreateOrganizationPayloadSchema, | |
| 65 | − UpdateOrganizationPayloadSchema, | |
| 66 | − type CreateOrganizationPayload, | |
| 67 | − type UpdateOrganizationPayload, | |
| 68 | −} from "./organizations/api"; | |
| 69 | − | |
| 70 | −export { | |
| 71 | − CreateMembershipPayloadSchema, | |
| 72 | − UpdateMembershipPayloadSchema, | |
| 73 | − type CreateMembershipPayload, | |
| 74 | − type UpdateMembershipPayload, | |
| 75 | −} from "./memberships/api"; | |
| 76 | − | |
| 77 | −export { | |
| 78 | − CreateWorkspacePayloadSchema, | |
| 79 | − UpdateWorkspacePayloadSchema, | |
| 80 | − type CreateWorkspacePayload, | |
| 81 | − type UpdateWorkspacePayload, | |
| 82 | −} from "./workspaces/api"; | |
| 83 | − | |
| 84 | 52 | export { |
| 85 | 53 | ConnectSourcePayloadSchema, |
| 86 | 54 | ConnectSourceResultSchema, |
| @@ -0,0 +1,25 @@ | ||
| 1 | +import type { WorkspaceId } from "#schema"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + RuntimeLocalWorkspaceMismatchError, | |
| 6 | +} from "../runtime/local-errors"; | |
| 7 | +import { requireRuntimeLocalWorkspace } from "../runtime/local-runtime-context"; | |
| 8 | +import { ControlPlaneForbiddenError } from "./errors"; | |
| 9 | + | |
| 10 | +export const resolveRequestedLocalWorkspace = ( | |
| 11 | + operation: string, | |
| 12 | + workspaceId: WorkspaceId, | |
| 13 | +) => | |
| 14 | + requireRuntimeLocalWorkspace(workspaceId).pipe( | |
| 15 | + Effect.mapError((cause) => | |
| 16 | + new ControlPlaneForbiddenError({ | |
| 17 | + operation, | |
| 18 | + message: "Requested workspace is not the active local workspace", | |
| 19 | + details: | |
| 20 | + cause instanceof RuntimeLocalWorkspaceMismatchError | |
| 21 | + ? `requestedWorkspaceId=${cause.requestedWorkspaceId} activeWorkspaceId=${cause.activeWorkspaceId}` | |
| 22 | + : "Runtime local workspace is unavailable", | |
| 23 | + }) | |
| 24 | + ), | |
| 25 | + ); | |
| @@ -1,84 +0,0 @@ | ||
| 1 | −import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | −import { | |
| 3 | − AccountIdSchema, | |
| 4 | − OrganizationIdSchema, | |
| 5 | − OrganizationMembershipInsertSchema, | |
| 6 | − OrganizationMembershipSchema, | |
| 7 | − OrganizationMembershipUpdateSchema, | |
| 8 | −} from "#schema"; | |
| 9 | −import * as Schema from "effect/Schema"; | |
| 10 | − | |
| 11 | −import { | |
| 12 | − ControlPlaneBadRequestError, | |
| 13 | − ControlPlaneForbiddenError, | |
| 14 | − ControlPlaneNotFoundError, | |
| 15 | − ControlPlaneStorageError, | |
| 16 | − ControlPlaneUnauthorizedError, | |
| 17 | −} from "../errors"; | |
| 18 | − | |
| 19 | −const createMembershipPayloadRequiredSchema = OrganizationMembershipInsertSchema.pipe( | |
| 20 | − Schema.pick("accountId", "role"), | |
| 21 | −); | |
| 22 | − | |
| 23 | −const createMembershipPayloadOptionalSchema = OrganizationMembershipInsertSchema.pipe( | |
| 24 | − Schema.pick("status", "billable", "invitedByAccountId"), | |
| 25 | − Schema.partialWith({ exact: true }), | |
| 26 | −); | |
| 27 | − | |
| 28 | −export const CreateMembershipPayloadSchema = Schema.extend( | |
| 29 | − createMembershipPayloadRequiredSchema, | |
| 30 | − createMembershipPayloadOptionalSchema, | |
| 31 | −); | |
| 32 | − | |
| 33 | −export type CreateMembershipPayload = typeof CreateMembershipPayloadSchema.Type; | |
| 34 | − | |
| 35 | −export const UpdateMembershipPayloadSchema = OrganizationMembershipUpdateSchema.pipe( | |
| 36 | − Schema.pick("role", "status", "billable"), | |
| 37 | − Schema.partialWith({ exact: true }), | |
| 38 | −); | |
| 39 | − | |
| 40 | −export type UpdateMembershipPayload = typeof UpdateMembershipPayloadSchema.Type; | |
| 41 | − | |
| 42 | −const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | |
| 43 | −const accountIdParam = HttpApiSchema.param("accountId", AccountIdSchema); | |
| 44 | − | |
| 45 | −export class MembershipsApi extends HttpApiGroup.make("memberships") | |
| 46 | − .add( | |
| 47 | − HttpApiEndpoint.get("list")`/organizations/${organizationIdParam}/memberships` | |
| 48 | − .addSuccess(Schema.Array(OrganizationMembershipSchema)) | |
| 49 | − .addError(ControlPlaneBadRequestError) | |
| 50 | − .addError(ControlPlaneUnauthorizedError) | |
| 51 | − .addError(ControlPlaneForbiddenError) | |
| 52 | − .addError(ControlPlaneNotFoundError) | |
| 53 | − .addError(ControlPlaneStorageError), | |
| 54 | − ) | |
| 55 | − .add( | |
| 56 | − HttpApiEndpoint.post("create")`/organizations/${organizationIdParam}/memberships` | |
| 57 | − .setPayload(CreateMembershipPayloadSchema) | |
| 58 | − .addSuccess(OrganizationMembershipSchema) | |
| 59 | − .addError(ControlPlaneBadRequestError) | |
| 60 | − .addError(ControlPlaneUnauthorizedError) | |
| 61 | − .addError(ControlPlaneForbiddenError) | |
| 62 | − .addError(ControlPlaneNotFoundError) | |
| 63 | − .addError(ControlPlaneStorageError), | |
| 64 | − ) | |
| 65 | − .add( | |
| 66 | − HttpApiEndpoint.patch("update")`/organizations/${organizationIdParam}/memberships/${accountIdParam}` | |
| 67 | − .setPayload(UpdateMembershipPayloadSchema) | |
| 68 | − .addSuccess(OrganizationMembershipSchema) | |
| 69 | − .addError(ControlPlaneBadRequestError) | |
| 70 | − .addError(ControlPlaneUnauthorizedError) | |
| 71 | − .addError(ControlPlaneForbiddenError) | |
| 72 | − .addError(ControlPlaneNotFoundError) | |
| 73 | − .addError(ControlPlaneStorageError), | |
| 74 | − ) | |
| 75 | − .add( | |
| 76 | − HttpApiEndpoint.del("remove")`/organizations/${organizationIdParam}/memberships/${accountIdParam}` | |
| 77 | − .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 78 | − .addError(ControlPlaneBadRequestError) | |
| 79 | − .addError(ControlPlaneUnauthorizedError) | |
| 80 | − .addError(ControlPlaneForbiddenError) | |
| 81 | − .addError(ControlPlaneNotFoundError) | |
| 82 | − .addError(ControlPlaneStorageError), | |
| 83 | − ) | |
| 84 | − .prefix("/v1") {} | |
| @@ -1,67 +0,0 @@ | ||
| 1 | −import { HttpApiBuilder } from "@effect/platform"; | |
| 2 | −import type { OrganizationId } from "#schema"; | |
| 3 | − | |
| 4 | −import { requirePermission, withPolicy } from "#domain"; | |
| 5 | −import { | |
| 6 | − createMembership, | |
| 7 | − listMemberships, | |
| 8 | − removeMembership, | |
| 9 | − updateMembership, | |
| 10 | −} from "../../runtime/organizations-operations"; | |
| 11 | − | |
| 12 | −import { ControlPlaneApi } from "../api"; | |
| 13 | −import { withRequestActor } from "../http-auth"; | |
| 14 | − | |
| 15 | −const requireReadMemberships = (organizationId: OrganizationId) => | |
| 16 | − requirePermission({ | |
| 17 | − permission: "memberships:read", | |
| 18 | − organizationId, | |
| 19 | − }); | |
| 20 | − | |
| 21 | −const requireWriteMemberships = (organizationId: OrganizationId) => | |
| 22 | − requirePermission({ | |
| 23 | − permission: "memberships:write", | |
| 24 | − organizationId, | |
| 25 | − }); | |
| 26 | − | |
| 27 | −export const ControlPlaneMembershipsLive = HttpApiBuilder.group( | |
| 28 | − ControlPlaneApi, | |
| 29 | − "memberships", | |
| 30 | − (handlers) => | |
| 31 | − handlers | |
| 32 | − .handle("list", ({ path }) => | |
| 33 | − withRequestActor("memberships.list", () => | |
| 34 | − withPolicy(requireReadMemberships(path.organizationId))( | |
| 35 | − listMemberships(path.organizationId), | |
| 36 | − ), | |
| 37 | − ), | |
| 38 | − ) | |
| 39 | − .handle("create", ({ path, payload }) => | |
| 40 | − withRequestActor("memberships.create", () => | |
| 41 | − withPolicy(requireWriteMemberships(path.organizationId))( | |
| 42 | − createMembership({ organizationId: path.organizationId, payload }), | |
| 43 | − ), | |
| 44 | − ), | |
| 45 | − ) | |
| 46 | − .handle("update", ({ path, payload }) => | |
| 47 | − withRequestActor("memberships.update", () => | |
| 48 | − withPolicy(requireWriteMemberships(path.organizationId))( | |
| 49 | − updateMembership({ | |
| 50 | − organizationId: path.organizationId, | |
| 51 | − accountId: path.accountId, | |
| 52 | − payload, | |
| 53 | − }), | |
| 54 | − ), | |
| 55 | − ), | |
| 56 | − ) | |
| 57 | − .handle("remove", ({ path }) => | |
| 58 | − withRequestActor("memberships.remove", () => | |
| 59 | − withPolicy(requireWriteMemberships(path.organizationId))( | |
| 60 | − removeMembership({ | |
| 61 | − organizationId: path.organizationId, | |
| 62 | − accountId: path.accountId, | |
| 63 | − }), | |
| 64 | − ), | |
| 65 | − ), | |
| 66 | − ), | |
| 67 | −); | |
| @@ -8,27 +8,20 @@ import * as Cause from "effect/Cause"; · 7 unmodified lines | ||
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | |
| 11 | −import { requirePermission, withPolicy } from "#domain"; | |
| 12 | 11 | import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service"; |
| 13 | 12 | import { ControlPlaneApi } from "../api"; |
| 14 | 13 | import { |
| 15 | 14 | ControlPlaneBadRequestError, |
| 16 | 15 | ControlPlaneStorageError, |
| 17 | 16 | } from "../errors"; |
| 17 | +import { resolveRequestedLocalWorkspace } from "../local-context"; | |
| 18 | 18 | import { |
| 19 | 19 | SourceOAuthPopupResultSchema, |
| 20 | 20 | type SourceOAuthPopupResult, |
| 21 | 21 | } from "./api"; |
| 22 | −import { withWorkspaceRequestActor } from "../http-auth"; | |
| 23 | 22 | |
| 24 | 23 | const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; |
| 25 | 24 | |
| 26 | −const requireWriteSources = (workspaceId: WorkspaceId) => | |
| 27 | − requirePermission({ | |
| 28 | − permission: "sources:write", | |
| 29 | − workspaceId, | |
| 30 | − }); | |
| 31 | − | |
| 32 | 25 | const escapeHtml = (value: string): string => |
| 33 | 26 | value |
| 34 | 27 | .replaceAll("&", "&") |
| @@ -241,14 +234,14 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 206 unmodified lines | ||
| 241 | 234 | (handlers) => |
| 242 | 235 | handlers |
| 243 | 236 | .handle("startSourceAuth", ({ path, payload }) => |
| 244 | − withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) => | |
| 245 | − withPolicy(requireWriteSources(path.workspaceId))( | |
| 237 | + resolveRequestedLocalWorkspace("oauth.start_source_auth", path.workspaceId).pipe( | |
| 238 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 246 | 239 | Effect.gen(function* () { |
| 247 | 240 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 248 | 241 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 249 | 242 | return yield* sourceAuthService.startSourceOAuthSession({ |
| 250 | 243 | workspaceId: path.workspaceId, |
| 251 | − actorAccountId: actor.principal.accountId, | |
| 244 | + actorAccountId: runtimeLocalWorkspace.installation.accountId, | |
| 252 | 245 | baseUrl: resolveRequestOrigin(request), |
| 253 | 246 | displayName: payload.name, |
| 254 | 247 | provider: { |
| @@ -270,7 +263,7 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 15 unmodified lines | ||
| 270 | 263 | }), |
| 271 | 264 | ), |
| 272 | 265 | ), |
| 273 | − ), | |
| 266 | + ) | |
| 274 | 267 | ), |
| 275 | 268 | ), |
| 276 | 269 | ) |
| @@ -1,82 +0,0 @@ | ||
| 1 | −import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | −import { | |
| 3 | − OrganizationIdSchema, | |
| 4 | − OrganizationStatusSchema, | |
| 5 | − OrganizationSchema, | |
| 6 | −} from "#schema"; | |
| 7 | −import * as Schema from "effect/Schema"; | |
| 8 | − | |
| 9 | −import { | |
| 10 | − ControlPlaneBadRequestError, | |
| 11 | − ControlPlaneForbiddenError, | |
| 12 | − ControlPlaneNotFoundError, | |
| 13 | − ControlPlaneStorageError, | |
| 14 | − ControlPlaneUnauthorizedError, | |
| 15 | −} from "../errors"; | |
| 16 | −import { | |
| 17 | − OptionalTrimmedNonEmptyStringSchema, | |
| 18 | − TrimmedNonEmptyStringSchema, | |
| 19 | −} from "../string-schemas"; | |
| 20 | − | |
| 21 | −export const CreateOrganizationPayloadSchema = Schema.Struct({ | |
| 22 | − name: TrimmedNonEmptyStringSchema, | |
| 23 | − slug: OptionalTrimmedNonEmptyStringSchema, | |
| 24 | −}); | |
| 25 | − | |
| 26 | −export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type; | |
| 27 | − | |
| 28 | −export const UpdateOrganizationPayloadSchema = Schema.Struct({ | |
| 29 | − name: OptionalTrimmedNonEmptyStringSchema, | |
| 30 | − status: Schema.optional(OrganizationStatusSchema), | |
| 31 | −}); | |
| 32 | − | |
| 33 | −export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type; | |
| 34 | − | |
| 35 | −const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | |
| 36 | − | |
| 37 | −export class OrganizationsApi extends HttpApiGroup.make("organizations") | |
| 38 | − .add( | |
| 39 | − HttpApiEndpoint.get("list")`/organizations` | |
| 40 | − .addSuccess(Schema.Array(OrganizationSchema)) | |
| 41 | − .addError(ControlPlaneBadRequestError) | |
| 42 | − .addError(ControlPlaneUnauthorizedError) | |
| 43 | − .addError(ControlPlaneForbiddenError) | |
| 44 | − .addError(ControlPlaneStorageError), | |
| 45 | − ) | |
| 46 | − .add( | |
| 47 | − HttpApiEndpoint.post("create")`/organizations` | |
| 48 | − .setPayload(CreateOrganizationPayloadSchema) | |
| 49 | − .addSuccess(OrganizationSchema) | |
| 50 | − .addError(ControlPlaneBadRequestError) | |
| 51 | − .addError(ControlPlaneUnauthorizedError) | |
| 52 | − .addError(ControlPlaneForbiddenError) | |
| 53 | − .addError(ControlPlaneStorageError), | |
| 54 | − ) | |
| 55 | − .add( | |
| 56 | − HttpApiEndpoint.get("get")`/organizations/${organizationIdParam}` | |
| 57 | − .addSuccess(OrganizationSchema) | |
| 58 | − .addError(ControlPlaneBadRequestError) | |
| 59 | − .addError(ControlPlaneUnauthorizedError) | |
| 60 | − .addError(ControlPlaneForbiddenError) | |
| 61 | − .addError(ControlPlaneNotFoundError) | |
| 62 | − .addError(ControlPlaneStorageError), | |
| 63 | − ) | |
| 64 | − .add( | |
| 65 | − HttpApiEndpoint.patch("update")`/organizations/${organizationIdParam}` | |
| 66 | − .setPayload(UpdateOrganizationPayloadSchema) | |
| 67 | − .addSuccess(OrganizationSchema) | |
| 68 | − .addError(ControlPlaneBadRequestError) | |
| 69 | − .addError(ControlPlaneUnauthorizedError) | |
| 70 | − .addError(ControlPlaneForbiddenError) | |
| 71 | − .addError(ControlPlaneNotFoundError) | |
| 72 | − .addError(ControlPlaneStorageError), | |
| 73 | − ) | |
| 74 | − .add( | |
| 75 | − HttpApiEndpoint.del("remove")`/organizations/${organizationIdParam}` | |
| 76 | − .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 77 | − .addError(ControlPlaneBadRequestError) | |
| 78 | − .addError(ControlPlaneUnauthorizedError) | |
| 79 | − .addError(ControlPlaneForbiddenError) | |
| 80 | − .addError(ControlPlaneStorageError), | |
| 81 | − ) | |
| 82 | − .prefix("/v1") {} | |
| @@ -1,75 +0,0 @@ | ||
| 1 | −import { HttpApiBuilder } from "@effect/platform"; | |
| 2 | −import type { OrganizationId } from "#schema"; | |
| 3 | − | |
| 4 | −import { requirePermission, withPolicy } from "#domain"; | |
| 5 | −import { | |
| 6 | − createOrganization, | |
| 7 | − getOrganization, | |
| 8 | − listOrganizations, | |
| 9 | − removeOrganization, | |
| 10 | − updateOrganization, | |
| 11 | −} from "../../runtime/organizations-operations"; | |
| 12 | − | |
| 13 | −import { ControlPlaneApi } from "../api"; | |
| 14 | −import { withRequestActor } from "../http-auth"; | |
| 15 | − | |
| 16 | −const requireReadOrganizations = requirePermission({ | |
| 17 | − permission: "organizations:read", | |
| 18 | −}); | |
| 19 | − | |
| 20 | −const requireManageOrganizations = (organizationId: OrganizationId) => | |
| 21 | − requirePermission({ | |
| 22 | − permission: "organizations:manage", | |
| 23 | − organizationId, | |
| 24 | − }); | |
| 25 | − | |
| 26 | −export const ControlPlaneOrganizationsLive = HttpApiBuilder.group( | |
| 27 | − ControlPlaneApi, | |
| 28 | − "organizations", | |
| 29 | − (handlers) => | |
| 30 | − handlers | |
| 31 | − .handle("list", () => | |
| 32 | − withRequestActor("organizations.list", (actor) => | |
| 33 | − withPolicy(requireReadOrganizations)( | |
| 34 | − listOrganizations({ | |
| 35 | − accountId: actor.principal.accountId, | |
| 36 | − }), | |
| 37 | − ), | |
| 38 | − ), | |
| 39 | − ) | |
| 40 | − .handle("create", ({ payload }) => | |
| 41 | − withRequestActor("organizations.create", (actor) => | |
| 42 | − createOrganization({ | |
| 43 | − payload, | |
| 44 | − createdByAccountId: actor.principal.accountId, | |
| 45 | − }), | |
| 46 | − ), | |
| 47 | − ) | |
| 48 | − .handle("get", ({ path }) => | |
| 49 | − withRequestActor("organizations.get", (actor) => | |
| 50 | − withPolicy(requireReadOrganizations)( | |
| 51 | − getOrganization({ | |
| 52 | − organizationId: path.organizationId, | |
| 53 | − accountId: actor.principal.accountId, | |
| 54 | − }), | |
| 55 | − ), | |
| 56 | − ), | |
| 57 | − ) | |
| 58 | − .handle("update", ({ path, payload }) => | |
| 59 | − withRequestActor("organizations.update", () => | |
| 60 | − withPolicy(requireManageOrganizations(path.organizationId))( | |
| 61 | − updateOrganization({ | |
| 62 | − organizationId: path.organizationId, | |
| 63 | − payload, | |
| 64 | − }), | |
| 65 | − ), | |
| 66 | − ), | |
| 67 | − ) | |
| 68 | − .handle("remove", ({ path }) => | |
| 69 | − withRequestActor("organizations.remove", () => | |
| 70 | − withPolicy(requireManageOrganizations(path.organizationId))( | |
| 71 | − removeOrganization({ organizationId: path.organizationId }), | |
| 72 | − ), | |
| 73 | − ), | |
| 74 | − ), | |
| 75 | −); | |
| @@ -5,38 +5,15 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | CreateExecutionPayloadSchema, |
| 7 | 7 | } from "./executions/api"; |
| 8 | −import { | |
| 9 | − CreateOrganizationPayloadSchema, | |
| 10 | − UpdateOrganizationPayloadSchema, | |
| 11 | −} from "./organizations/api"; | |
| 12 | 8 | import { CreatePolicyPayloadSchema } from "./policies/api"; |
| 13 | 9 | import { |
| 14 | 10 | ConnectSourcePayloadSchema, |
| 15 | 11 | CreateSourcePayloadSchema, |
| 16 | 12 | UpdateSourcePayloadSchema, |
| 17 | 13 | } from "./sources/api"; |
| 18 | −import { CreateWorkspacePayloadSchema } from "./workspaces/api"; | |
| 19 | 14 | |
| 20 | 15 | describe("control-plane payload schemas", () => { |
| 21 | 16 | it("normalizes trimmed strings at decode time", () => { |
| 22 | − expect( | |
| 23 | − Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({ | |
| 24 | − name: " Acme ", | |
| 25 | − slug: " acme ", | |
| 26 | − }), | |
| 27 | − ).toEqual({ | |
| 28 | − name: "Acme", | |
| 29 | − slug: "acme", | |
| 30 | − }); | |
| 31 | − | |
| 32 | − expect( | |
| 33 | − Schema.decodeUnknownSync(CreateWorkspacePayloadSchema)({ | |
| 34 | − name: " Primary ", | |
| 35 | − }), | |
| 36 | − ).toEqual({ | |
| 37 | − name: "Primary", | |
| 38 | − }); | |
| 39 | − | |
| 40 | 17 | expect( |
| 41 | 18 | Schema.decodeUnknownSync(CreateSourcePayloadSchema)({ |
| 42 | 19 | name: " Github ", |
| @@ -101,18 +78,6 @@ describe("control-plane payload schemas", () => { · 58 unmodified lines | ||
| 101 | 78 | }); |
| 102 | 79 | |
| 103 | 80 | it("rejects blank strings for normalized string fields", () => { |
| 104 | − throws(() => | |
| 105 | − Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({ | |
| 106 | − name: " ", | |
| 107 | − }) | |
| 108 | − ); | |
| 109 | − | |
| 110 | − throws(() => | |
| 111 | − Schema.decodeUnknownSync(UpdateOrganizationPayloadSchema)({ | |
| 112 | − name: " ", | |
| 113 | − }) | |
| 114 | − ); | |
| 115 | − | |
| 116 | 81 | throws(() => |
| 117 | 82 | Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({ |
| 118 | 83 | endpoint: " ", |
| @@ -125,5 +90,11 @@ describe("control-plane payload schemas", () => { · 6 unmodified lines | ||
| 125 | 90 | endpoint: " ", |
| 126 | 91 | }) |
| 127 | 92 | ); |
| 93 | + | |
| 94 | + throws(() => | |
| 95 | + Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({ | |
| 96 | + resourcePattern: " ", | |
| 97 | + }) | |
| 98 | + ); | |
| 128 | 99 | }); |
| 129 | 100 | }); |
| @@ -1,13 +1,9 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { |
| 3 | − AccountIdSchema, | |
| 4 | − OrganizationIdSchema, | |
| 3 | + LocalWorkspacePolicyApprovalModeSchema, | |
| 4 | + LocalWorkspacePolicyEffectSchema, | |
| 5 | + LocalWorkspacePolicySchema, | |
| 5 | 6 | PolicyIdSchema, |
| 6 | − PolicyApprovalModeSchema, | |
| 7 | − PolicyEffectSchema, | |
| 8 | − PolicyMatchTypeSchema, | |
| 9 | − PolicyResourceTypeSchema, | |
| 10 | − PolicySchema, | |
| 11 | 7 | WorkspaceIdSchema, |
| 12 | 8 | } from "#schema"; |
| 13 | 9 | import * as Schema from "effect/Schema"; |
| @@ -21,91 +17,29 @@ import { · 7 unmodified lines | ||
| 21 | 17 | } from "../errors"; |
| 22 | 18 | import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas"; |
| 23 | 19 | |
| 24 | −export const CreatePolicyPayloadSchema = Schema.Struct({ | |
| 25 | − resourceType: Schema.optional(PolicyResourceTypeSchema), | |
| 20 | +const LocalWorkspacePolicyPayloadSchema = Schema.Struct({ | |
| 26 | 21 | resourcePattern: OptionalTrimmedNonEmptyStringSchema, |
| 27 | − matchType: Schema.optional(PolicyMatchTypeSchema), | |
| 28 | − effect: Schema.optional(PolicyEffectSchema), | |
| 29 | − approvalMode: Schema.optional(PolicyApprovalModeSchema), | |
| 30 | − argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 22 | + effect: Schema.optional(LocalWorkspacePolicyEffectSchema), | |
| 23 | + approvalMode: Schema.optional(LocalWorkspacePolicyApprovalModeSchema), | |
| 31 | 24 | priority: Schema.optional(Schema.Number), |
| 32 | 25 | enabled: Schema.optional(Schema.Boolean), |
| 33 | − targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)), | |
| 34 | − clientId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 35 | 26 | }); |
| 36 | 27 | |
| 28 | +export const CreatePolicyPayloadSchema = LocalWorkspacePolicyPayloadSchema; | |
| 29 | + | |
| 37 | 30 | export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type; |
| 38 | 31 | |
| 39 | −export const UpdatePolicyPayloadSchema = Schema.Struct({ | |
| 40 | − resourceType: Schema.optional(PolicyResourceTypeSchema), | |
| 41 | − resourcePattern: OptionalTrimmedNonEmptyStringSchema, | |
| 42 | − matchType: Schema.optional(PolicyMatchTypeSchema), | |
| 43 | − effect: Schema.optional(PolicyEffectSchema), | |
| 44 | − approvalMode: Schema.optional(PolicyApprovalModeSchema), | |
| 45 | − argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 46 | − priority: Schema.optional(Schema.Number), | |
| 47 | − enabled: Schema.optional(Schema.Boolean), | |
| 48 | − targetAccountId: Schema.optional(Schema.NullOr(AccountIdSchema)), | |
| 49 | − clientId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 50 | −}); | |
| 32 | +export const UpdatePolicyPayloadSchema = LocalWorkspacePolicyPayloadSchema; | |
| 51 | 33 | |
| 52 | 34 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; |
| 53 | 35 | |
| 54 | −const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | |
| 55 | 36 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 56 | 37 | const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema); |
| 57 | 38 | |
| 58 | 39 | export class PoliciesApi extends HttpApiGroup.make("policies") |
| 59 | − .add( | |
| 60 | − HttpApiEndpoint.get("listOrganization")`/organizations/${organizationIdParam}/policies` | |
| 61 | − .addSuccess(Schema.Array(PolicySchema)) | |
| 62 | − .addError(ControlPlaneBadRequestError) | |
| 63 | − .addError(ControlPlaneUnauthorizedError) | |
| 64 | − .addError(ControlPlaneForbiddenError) | |
| 65 | − .addError(ControlPlaneNotFoundError) | |
| 66 | − .addError(ControlPlaneStorageError), | |
| 67 | − ) | |
| 68 | − .add( | |
| 69 | − HttpApiEndpoint.post("createOrganization")`/organizations/${organizationIdParam}/policies` | |
| 70 | − .setPayload(CreatePolicyPayloadSchema) | |
| 71 | − .addSuccess(PolicySchema) | |
| 72 | − .addError(ControlPlaneBadRequestError) | |
| 73 | − .addError(ControlPlaneUnauthorizedError) | |
| 74 | − .addError(ControlPlaneForbiddenError) | |
| 75 | − .addError(ControlPlaneNotFoundError) | |
| 76 | − .addError(ControlPlaneStorageError), | |
| 77 | − ) | |
| 78 | − .add( | |
| 79 | − HttpApiEndpoint.get("getOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}` | |
| 80 | − .addSuccess(PolicySchema) | |
| 81 | − .addError(ControlPlaneBadRequestError) | |
| 82 | − .addError(ControlPlaneUnauthorizedError) | |
| 83 | − .addError(ControlPlaneForbiddenError) | |
| 84 | − .addError(ControlPlaneNotFoundError) | |
| 85 | − .addError(ControlPlaneStorageError), | |
| 86 | − ) | |
| 87 | − .add( | |
| 88 | − HttpApiEndpoint.patch("updateOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}` | |
| 89 | − .setPayload(UpdatePolicyPayloadSchema) | |
| 90 | − .addSuccess(PolicySchema) | |
| 91 | − .addError(ControlPlaneBadRequestError) | |
| 92 | − .addError(ControlPlaneUnauthorizedError) | |
| 93 | − .addError(ControlPlaneForbiddenError) | |
| 94 | − .addError(ControlPlaneNotFoundError) | |
| 95 | − .addError(ControlPlaneStorageError), | |
| 96 | − ) | |
| 97 | − .add( | |
| 98 | − HttpApiEndpoint.del("removeOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}` | |
| 99 | − .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 100 | − .addError(ControlPlaneBadRequestError) | |
| 101 | − .addError(ControlPlaneUnauthorizedError) | |
| 102 | − .addError(ControlPlaneForbiddenError) | |
| 103 | − .addError(ControlPlaneNotFoundError) | |
| 104 | − .addError(ControlPlaneStorageError), | |
| 105 | − ) | |
| 106 | 40 | .add( |
| 107 | 41 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies` |
| 108 | − .addSuccess(Schema.Array(PolicySchema)) | |
| 42 | + .addSuccess(Schema.Array(LocalWorkspacePolicySchema)) | |
| 109 | 43 | .addError(ControlPlaneBadRequestError) |
| 110 | 44 | .addError(ControlPlaneUnauthorizedError) |
| 111 | 45 | .addError(ControlPlaneForbiddenError) |
| @@ -115,7 +49,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 3 unmodified lines | ||
| 115 | 49 | .add( |
| 116 | 50 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/policies` |
| 117 | 51 | .setPayload(CreatePolicyPayloadSchema) |
| 118 | − .addSuccess(PolicySchema) | |
| 52 | + .addSuccess(LocalWorkspacePolicySchema) | |
| 119 | 53 | .addError(ControlPlaneBadRequestError) |
| 120 | 54 | .addError(ControlPlaneUnauthorizedError) |
| 121 | 55 | .addError(ControlPlaneForbiddenError) |
| @@ -124,7 +58,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 2 unmodified lines | ||
| 124 | 58 | ) |
| 125 | 59 | .add( |
| 126 | 60 | HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` |
| 127 | − .addSuccess(PolicySchema) | |
| 61 | + .addSuccess(LocalWorkspacePolicySchema) | |
| 128 | 62 | .addError(ControlPlaneBadRequestError) |
| 129 | 63 | .addError(ControlPlaneUnauthorizedError) |
| 130 | 64 | .addError(ControlPlaneForbiddenError) |
| @@ -134,7 +68,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 3 unmodified lines | ||
| 134 | 68 | .add( |
| 135 | 69 | HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` |
| 136 | 70 | .setPayload(UpdatePolicyPayloadSchema) |
| 137 | − .addSuccess(PolicySchema) | |
| 71 | + .addSuccess(LocalWorkspacePolicySchema) | |
| 138 | 72 | .addError(ControlPlaneBadRequestError) |
| 139 | 73 | .addError(ControlPlaneUnauthorizedError) |
| 140 | 74 | .addError(ControlPlaneForbiddenError) |
| @@ -1,117 +1,38 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | −import type { OrganizationId, WorkspaceId } from "#schema"; | |
| 3 | − | |
| 4 | −import { requirePermission, withPolicy } from "#domain"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 5 | 3 | import { |
| 6 | − createOrganizationPolicy, | |
| 7 | 4 | createPolicy, |
| 8 | − getOrganizationPolicy, | |
| 9 | 5 | getPolicy, |
| 10 | − listOrganizationPolicies, | |
| 11 | 6 | listPolicies, |
| 12 | − removeOrganizationPolicy, | |
| 13 | 7 | removePolicy, |
| 14 | − updateOrganizationPolicy, | |
| 15 | 8 | updatePolicy, |
| 16 | 9 | } from "../../runtime/policies-operations"; |
| 17 | 10 | |
| 18 | 11 | import { ControlPlaneApi } from "../api"; |
| 19 | −import { withRequestActor, withWorkspaceRequestActor } from "../http-auth"; | |
| 20 | − | |
| 21 | −const requireReadOrganizationPolicies = (organizationId: OrganizationId) => | |
| 22 | − requirePermission({ | |
| 23 | − permission: "policies:read", | |
| 24 | − organizationId, | |
| 25 | − }); | |
| 26 | − | |
| 27 | −const requireWriteOrganizationPolicies = (organizationId: OrganizationId) => | |
| 28 | − requirePermission({ | |
| 29 | − permission: "policies:write", | |
| 30 | − organizationId, | |
| 31 | − }); | |
| 32 | − | |
| 33 | −const requireReadPolicies = (workspaceId: WorkspaceId) => | |
| 34 | − requirePermission({ | |
| 35 | − permission: "policies:read", | |
| 36 | − workspaceId, | |
| 37 | − }); | |
| 38 | − | |
| 39 | −const requireWritePolicies = (workspaceId: WorkspaceId) => | |
| 40 | − requirePermission({ | |
| 41 | − permission: "policies:write", | |
| 42 | − workspaceId, | |
| 43 | − }); | |
| 12 | +import { resolveRequestedLocalWorkspace } from "../local-context"; | |
| 44 | 13 | |
| 45 | 14 | export const ControlPlanePoliciesLive = HttpApiBuilder.group( |
| 46 | 15 | ControlPlaneApi, |
| 47 | 16 | "policies", |
| 48 | 17 | (handlers) => |
| 49 | 18 | handlers |
| 50 | − .handle("listOrganization", ({ path }) => | |
| 51 | − withRequestActor("policies.listOrganization", () => | |
| 52 | − withPolicy(requireReadOrganizationPolicies(path.organizationId))( | |
| 53 | − listOrganizationPolicies(path.organizationId), | |
| 54 | − ), | |
| 55 | − ), | |
| 56 | − ) | |
| 57 | − .handle("createOrganization", ({ path, payload }) => | |
| 58 | − withRequestActor("policies.createOrganization", () => | |
| 59 | − withPolicy(requireWriteOrganizationPolicies(path.organizationId))( | |
| 60 | − createOrganizationPolicy({ | |
| 61 | − organizationId: path.organizationId, | |
| 62 | − payload, | |
| 63 | − }), | |
| 64 | − ), | |
| 65 | − ), | |
| 66 | − ) | |
| 67 | − .handle("getOrganization", ({ path }) => | |
| 68 | − withRequestActor("policies.getOrganization", () => | |
| 69 | − withPolicy(requireReadOrganizationPolicies(path.organizationId))( | |
| 70 | − getOrganizationPolicy({ | |
| 71 | − organizationId: path.organizationId, | |
| 72 | − policyId: path.policyId, | |
| 73 | − }), | |
| 74 | − ), | |
| 75 | − ), | |
| 76 | − ) | |
| 77 | − .handle("updateOrganization", ({ path, payload }) => | |
| 78 | − withRequestActor("policies.updateOrganization", () => | |
| 79 | − withPolicy(requireWriteOrganizationPolicies(path.organizationId))( | |
| 80 | − updateOrganizationPolicy({ | |
| 81 | − organizationId: path.organizationId, | |
| 82 | − policyId: path.policyId, | |
| 83 | − payload, | |
| 84 | − }), | |
| 85 | − ), | |
| 86 | − ), | |
| 87 | − ) | |
| 88 | − .handle("removeOrganization", ({ path }) => | |
| 89 | − withRequestActor("policies.removeOrganization", () => | |
| 90 | − withPolicy(requireWriteOrganizationPolicies(path.organizationId))( | |
| 91 | − removeOrganizationPolicy({ | |
| 92 | − organizationId: path.organizationId, | |
| 93 | − policyId: path.policyId, | |
| 94 | − }), | |
| 95 | − ), | |
| 96 | − ), | |
| 97 | − ) | |
| 98 | 19 | .handle("list", ({ path }) => |
| 99 | − withWorkspaceRequestActor("policies.list", path.workspaceId, () => | |
| 100 | − withPolicy(requireReadPolicies(path.workspaceId))( | |
| 20 | + resolveRequestedLocalWorkspace("policies.list", path.workspaceId).pipe( | |
| 21 | + Effect.zipRight( | |
| 101 | 22 | listPolicies(path.workspaceId), |
| 102 | 23 | ), |
| 103 | 24 | ), |
| 104 | 25 | ) |
| 105 | 26 | .handle("create", ({ path, payload }) => |
| 106 | − withWorkspaceRequestActor("policies.create", path.workspaceId, () => | |
| 107 | − withPolicy(requireWritePolicies(path.workspaceId))( | |
| 27 | + resolveRequestedLocalWorkspace("policies.create", path.workspaceId).pipe( | |
| 28 | + Effect.zipRight( | |
| 108 | 29 | createPolicy({ workspaceId: path.workspaceId, payload }), |
| 109 | 30 | ), |
| 110 | 31 | ), |
| 111 | 32 | ) |
| 112 | 33 | .handle("get", ({ path }) => |
| 113 | − withWorkspaceRequestActor("policies.get", path.workspaceId, () => | |
| 114 | − withPolicy(requireReadPolicies(path.workspaceId))( | |
| 34 | + resolveRequestedLocalWorkspace("policies.get", path.workspaceId).pipe( | |
| 35 | + Effect.zipRight( | |
| 115 | 36 | getPolicy({ |
| 116 | 37 | workspaceId: path.workspaceId, |
| 117 | 38 | policyId: path.policyId, |
| @@ -120,8 +41,8 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 2 unmodified lines | ||
| 120 | 41 | ), |
| 121 | 42 | ) |
| 122 | 43 | .handle("update", ({ path, payload }) => |
| 123 | − withWorkspaceRequestActor("policies.update", path.workspaceId, () => | |
| 124 | − withPolicy(requireWritePolicies(path.workspaceId))( | |
| 44 | + resolveRequestedLocalWorkspace("policies.update", path.workspaceId).pipe( | |
| 45 | + Effect.zipRight( | |
| 125 | 46 | updatePolicy({ |
| 126 | 47 | workspaceId: path.workspaceId, |
| 127 | 48 | policyId: path.policyId, |
| @@ -131,8 +52,8 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 3 unmodified lines | ||
| 131 | 52 | ), |
| 132 | 53 | ) |
| 133 | 54 | .handle("remove", ({ path }) => |
| 134 | − withWorkspaceRequestActor("policies.remove", path.workspaceId, () => | |
| 135 | − withPolicy(requireWritePolicies(path.workspaceId))( | |
| 55 | + resolveRequestedLocalWorkspace("policies.remove", path.workspaceId).pipe( | |
| 56 | + Effect.zipRight( | |
| 136 | 57 | removePolicy({ |
| 137 | 58 | workspaceId: path.workspaceId, |
| 138 | 59 | policyId: path.policyId, |
| @@ -3,14 +3,9 @@ import { · 2 unmodified lines | ||
| 3 | 3 | HttpServerRequest, |
| 4 | 4 | HttpServerResponse, |
| 5 | 5 | } from "@effect/platform"; |
| 6 | −import type { | |
| 7 | − ExecutionInteraction, | |
| 8 | − Source, | |
| 9 | − WorkspaceId, | |
| 10 | −} from "#schema"; | |
| 6 | +import type { ExecutionInteraction, Source, WorkspaceId } from "#schema"; | |
| 11 | 7 | import * as Effect from "effect/Effect"; |
| 12 | 8 | |
| 13 | −import { requirePermission, withPolicy } from "#domain"; | |
| 14 | 9 | import { |
| 15 | 10 | createSource, |
| 16 | 11 | getSource, |
| @@ -39,19 +34,7 @@ import { · 22 unmodified lines | ||
| 39 | 34 | ControlPlaneStorageError, |
| 40 | 35 | } from "../errors"; |
| 41 | 36 | import { ControlPlaneApi } from "../api"; |
| 42 | −import { withRequestActor, withWorkspaceRequestActor } from "../http-auth"; | |
| 43 | − | |
| 44 | −const requireReadSources = (workspaceId: WorkspaceId) => | |
| 45 | − requirePermission({ | |
| 46 | − permission: "sources:read", | |
| 47 | − workspaceId, | |
| 48 | − }); | |
| 49 | − | |
| 50 | −const requireWriteSources = (workspaceId: WorkspaceId) => | |
| 51 | − requirePermission({ | |
| 52 | − permission: "sources:write", | |
| 53 | − workspaceId, | |
| 54 | − }); | |
| 37 | +import { resolveRequestedLocalWorkspace } from "../local-context"; | |
| 55 | 38 | |
| 56 | 39 | const readHeader = (headers: unknown, name: string): string | null => { |
| 57 | 40 | if (headers == null || typeof headers !== "object") { |
| @@ -665,20 +648,18 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 607 unmodified lines | ||
| 665 | 648 | (handlers) => |
| 666 | 649 | handlers |
| 667 | 650 | .handle("discover", ({ payload }) => |
| 668 | − withRequestActor("sources.discover", () => | |
| 669 | − discoverSource({ | |
| 670 | − url: payload.url, | |
| 671 | − probeAuth: payload.probeAuth, | |
| 672 | − }).pipe( | |
| 673 | − Effect.catchAll((cause) => | |
| 674 | − Effect.fail(toBadRequestError("sources.discover", cause)), | |
| 675 | − ), | |
| 651 | + discoverSource({ | |
| 652 | + url: payload.url, | |
| 653 | + probeAuth: payload.probeAuth, | |
| 654 | + }).pipe( | |
| 655 | + Effect.catchAll((cause) => | |
| 656 | + Effect.fail(toBadRequestError("sources.discover", cause)), | |
| 676 | 657 | ), |
| 677 | 658 | ), |
| 678 | 659 | ) |
| 679 | 660 | .handle("connect", ({ path, payload }) => |
| 680 | − withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) => | |
| 681 | − withPolicy(requireWriteSources(path.workspaceId))( | |
| 661 | + resolveRequestedLocalWorkspace("sources.connect", path.workspaceId).pipe( | |
| 662 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 682 | 663 | Effect.gen(function* () { |
| 683 | 664 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 684 | 665 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| @@ -687,7 +668,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 2 unmodified lines | ||
| 687 | 668 | if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) { |
| 688 | 669 | return yield* sourceAuthService.connectMcpSource({ |
| 689 | 670 | workspaceId: path.workspaceId, |
| 690 | − actorAccountId: actor.principal.accountId, | |
| 671 | + actorAccountId: runtimeLocalWorkspace.installation.accountId, | |
| 691 | 672 | endpoint: payload.endpoint, |
| 692 | 673 | name: payload.name, |
| 693 | 674 | namespace: payload.namespace, |
| @@ -701,7 +682,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 7 unmodified lines | ||
| 701 | 682 | return yield* sourceAuthService.addExecutorSource( |
| 702 | 683 | { |
| 703 | 684 | workspaceId: path.workspaceId, |
| 704 | − actorAccountId: actor.principal.accountId, | |
| 685 | + actorAccountId: runtimeLocalWorkspace.installation.accountId, | |
| 705 | 686 | executionId: null, |
| 706 | 687 | interactionId: null, |
| 707 | 688 | ...payload, |
| @@ -712,37 +693,45 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 4 unmodified lines | ||
| 712 | 693 | Effect.catchAll((cause) => |
| 713 | 694 | Effect.fail(toBadRequestError("sources.connect", cause)), |
| 714 | 695 | ), |
| 715 | − ), | |
| 696 | + ) | |
| 716 | 697 | ), |
| 717 | 698 | ), |
| 718 | 699 | ) |
| 719 | 700 | .handle("list", ({ path }) => |
| 720 | − withWorkspaceRequestActor("sources.list", path.workspaceId, () => | |
| 721 | − withPolicy(requireReadSources(path.workspaceId))( | |
| 722 | − listSources(path.workspaceId), | |
| 701 | + resolveRequestedLocalWorkspace("sources.list", path.workspaceId).pipe( | |
| 702 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 703 | + listSources({ | |
| 704 | + workspaceId: path.workspaceId, | |
| 705 | + accountId: runtimeLocalWorkspace.installation.accountId, | |
| 706 | + }) | |
| 723 | 707 | ), |
| 724 | 708 | ), |
| 725 | 709 | ) |
| 726 | 710 | .handle("create", ({ path, payload }) => |
| 727 | − withWorkspaceRequestActor("sources.create", path.workspaceId, () => | |
| 728 | − withPolicy(requireWriteSources(path.workspaceId))( | |
| 729 | − createSource({ workspaceId: path.workspaceId, payload }), | |
| 711 | + resolveRequestedLocalWorkspace("sources.create", path.workspaceId).pipe( | |
| 712 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 713 | + createSource({ | |
| 714 | + workspaceId: path.workspaceId, | |
| 715 | + accountId: runtimeLocalWorkspace.installation.accountId, | |
| 716 | + payload, | |
| 717 | + }) | |
| 730 | 718 | ), |
| 731 | 719 | ), |
| 732 | 720 | ) |
| 733 | 721 | .handle("get", ({ path }) => |
| 734 | − withWorkspaceRequestActor("sources.get", path.workspaceId, () => | |
| 735 | − withPolicy(requireReadSources(path.workspaceId))( | |
| 722 | + resolveRequestedLocalWorkspace("sources.get", path.workspaceId).pipe( | |
| 723 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 736 | 724 | getSource({ |
| 737 | 725 | workspaceId: path.workspaceId, |
| 738 | 726 | sourceId: path.sourceId, |
| 739 | − }), | |
| 727 | + accountId: runtimeLocalWorkspace.installation.accountId, | |
| 728 | + }) | |
| 740 | 729 | ), |
| 741 | 730 | ), |
| 742 | 731 | ) |
| 743 | 732 | .handle("inspection", ({ path }) => |
| 744 | − withWorkspaceRequestActor("sources.inspection", path.workspaceId, () => | |
| 745 | − withPolicy(requireReadSources(path.workspaceId))( | |
| 733 | + resolveRequestedLocalWorkspace("sources.inspection", path.workspaceId).pipe( | |
| 734 | + Effect.zipRight( | |
| 746 | 735 | getSourceInspection({ |
| 747 | 736 | workspaceId: path.workspaceId, |
| 748 | 737 | sourceId: path.sourceId, |
| @@ -751,8 +740,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 2 unmodified lines | ||
| 751 | 740 | ), |
| 752 | 741 | ) |
| 753 | 742 | .handle("inspectionTool", ({ path }) => |
| 754 | − withWorkspaceRequestActor("sources.inspection_tool", path.workspaceId, () => | |
| 755 | − withPolicy(requireReadSources(path.workspaceId))( | |
| 743 | + resolveRequestedLocalWorkspace("sources.inspection_tool", path.workspaceId).pipe( | |
| 744 | + Effect.zipRight( | |
| 756 | 745 | getSourceInspectionToolDetail({ |
| 757 | 746 | workspaceId: path.workspaceId, |
| 758 | 747 | sourceId: path.sourceId, |
| @@ -762,8 +751,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | ||
| 762 | 751 | ), |
| 763 | 752 | ) |
| 764 | 753 | .handle("inspectionSchemaBundle", ({ path }) => |
| 765 | − withWorkspaceRequestActor("sources.inspection_schema_bundle", path.workspaceId, () => | |
| 766 | − withPolicy(requireReadSources(path.workspaceId))( | |
| 754 | + resolveRequestedLocalWorkspace("sources.inspection_schema_bundle", path.workspaceId).pipe( | |
| 755 | + Effect.zipRight( | |
| 767 | 756 | getSourceInspectionSchemaBundle({ |
| 768 | 757 | workspaceId: path.workspaceId, |
| 769 | 758 | sourceId: path.sourceId, |
| @@ -773,8 +762,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | ||
| 773 | 762 | ), |
| 774 | 763 | ) |
| 775 | 764 | .handle("inspectionDiscover", ({ path, payload }) => |
| 776 | − withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () => | |
| 777 | − withPolicy(requireReadSources(path.workspaceId))( | |
| 765 | + resolveRequestedLocalWorkspace("sources.inspection_discover", path.workspaceId).pipe( | |
| 766 | + Effect.zipRight( | |
| 778 | 767 | discoverSourceInspectionTools({ |
| 779 | 768 | workspaceId: path.workspaceId, |
| 780 | 769 | sourceId: path.sourceId, |
| @@ -784,19 +773,20 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | ||
| 784 | 773 | ), |
| 785 | 774 | ) |
| 786 | 775 | .handle("update", ({ path, payload }) => |
| 787 | − withWorkspaceRequestActor("sources.update", path.workspaceId, () => | |
| 788 | − withPolicy(requireWriteSources(path.workspaceId))( | |
| 776 | + resolveRequestedLocalWorkspace("sources.update", path.workspaceId).pipe( | |
| 777 | + Effect.flatMap((runtimeLocalWorkspace) => | |
| 789 | 778 | updateSource({ |
| 790 | 779 | workspaceId: path.workspaceId, |
| 791 | 780 | sourceId: path.sourceId, |
| 781 | + accountId: runtimeLocalWorkspace.installation.accountId, | |
| 792 | 782 | payload, |
| 793 | − }), | |
| 783 | + }) | |
| 794 | 784 | ), |
| 795 | 785 | ), |
| 796 | 786 | ) |
| 797 | 787 | .handle("remove", ({ path }) => |
| 798 | − withWorkspaceRequestActor("sources.remove", path.workspaceId, () => | |
| 799 | − withPolicy(requireWriteSources(path.workspaceId))( | |
| 788 | + resolveRequestedLocalWorkspace("sources.remove", path.workspaceId).pipe( | |
| 789 | + Effect.zipRight( | |
| 800 | 790 | removeSource({ |
| 801 | 791 | workspaceId: path.workspaceId, |
| 802 | 792 | sourceId: path.sourceId, |
| @@ -1,83 +0,0 @@ | ||
| 1 | −import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | −import { | |
| 3 | − OrganizationIdSchema, | |
| 4 | − WorkspaceIdSchema, | |
| 5 | − WorkspaceSchema, | |
| 6 | −} from "#schema"; | |
| 7 | −import * as Schema from "effect/Schema"; | |
| 8 | − | |
| 9 | −import { | |
| 10 | − ControlPlaneBadRequestError, | |
| 11 | − ControlPlaneForbiddenError, | |
| 12 | − ControlPlaneNotFoundError, | |
| 13 | − ControlPlaneStorageError, | |
| 14 | − ControlPlaneUnauthorizedError, | |
| 15 | −} from "../errors"; | |
| 16 | −import { | |
| 17 | − OptionalTrimmedNonEmptyStringSchema, | |
| 18 | − TrimmedNonEmptyStringSchema, | |
| 19 | −} from "../string-schemas"; | |
| 20 | − | |
| 21 | −export const CreateWorkspacePayloadSchema = Schema.Struct({ | |
| 22 | − name: TrimmedNonEmptyStringSchema, | |
| 23 | −}); | |
| 24 | − | |
| 25 | −export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type; | |
| 26 | − | |
| 27 | −export const UpdateWorkspacePayloadSchema = Schema.Struct({ | |
| 28 | − name: OptionalTrimmedNonEmptyStringSchema, | |
| 29 | −}); | |
| 30 | − | |
| 31 | −export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type; | |
| 32 | − | |
| 33 | −const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | |
| 34 | −const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | |
| 35 | − | |
| 36 | −export class WorkspacesApi extends HttpApiGroup.make("workspaces") | |
| 37 | − .add( | |
| 38 | − HttpApiEndpoint.get("list")`/organizations/${organizationIdParam}/workspaces` | |
| 39 | − .addSuccess(Schema.Array(WorkspaceSchema)) | |
| 40 | − .addError(ControlPlaneBadRequestError) | |
| 41 | − .addError(ControlPlaneUnauthorizedError) | |
| 42 | − .addError(ControlPlaneForbiddenError) | |
| 43 | − .addError(ControlPlaneNotFoundError) | |
| 44 | − .addError(ControlPlaneStorageError), | |
| 45 | − ) | |
| 46 | − .add( | |
| 47 | − HttpApiEndpoint.post("create")`/organizations/${organizationIdParam}/workspaces` | |
| 48 | − .setPayload(CreateWorkspacePayloadSchema) | |
| 49 | − .addSuccess(WorkspaceSchema) | |
| 50 | − .addError(ControlPlaneBadRequestError) | |
| 51 | − .addError(ControlPlaneUnauthorizedError) | |
| 52 | − .addError(ControlPlaneForbiddenError) | |
| 53 | − .addError(ControlPlaneNotFoundError) | |
| 54 | − .addError(ControlPlaneStorageError), | |
| 55 | − ) | |
| 56 | − .add( | |
| 57 | − HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}` | |
| 58 | − .addSuccess(WorkspaceSchema) | |
| 59 | − .addError(ControlPlaneBadRequestError) | |
| 60 | − .addError(ControlPlaneUnauthorizedError) | |
| 61 | − .addError(ControlPlaneForbiddenError) | |
| 62 | − .addError(ControlPlaneNotFoundError) | |
| 63 | − .addError(ControlPlaneStorageError), | |
| 64 | − ) | |
| 65 | − .add( | |
| 66 | − HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}` | |
| 67 | − .setPayload(UpdateWorkspacePayloadSchema) | |
| 68 | − .addSuccess(WorkspaceSchema) | |
| 69 | − .addError(ControlPlaneBadRequestError) | |
| 70 | − .addError(ControlPlaneUnauthorizedError) | |
| 71 | − .addError(ControlPlaneForbiddenError) | |
| 72 | − .addError(ControlPlaneNotFoundError) | |
| 73 | − .addError(ControlPlaneStorageError), | |
| 74 | − ) | |
| 75 | − .add( | |
| 76 | − HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}` | |
| 77 | − .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 78 | − .addError(ControlPlaneBadRequestError) | |
| 79 | − .addError(ControlPlaneUnauthorizedError) | |
| 80 | − .addError(ControlPlaneForbiddenError) | |
| 81 | − .addError(ControlPlaneStorageError), | |
| 82 | − ) | |
| 83 | − .prefix("/v1") {} | |
| @@ -1,89 +0,0 @@ | ||
| 1 | −import { HttpApiBuilder } from "@effect/platform"; | |
| 2 | −import type { OrganizationId, WorkspaceId } from "#schema"; | |
| 3 | − | |
| 4 | −import { requirePermission, withPolicy } from "#domain"; | |
| 5 | −import { | |
| 6 | − createWorkspace, | |
| 7 | − getWorkspace, | |
| 8 | − listWorkspaces, | |
| 9 | − removeWorkspace, | |
| 10 | − updateWorkspace, | |
| 11 | −} from "../../runtime/organizations-operations"; | |
| 12 | − | |
| 13 | −import { ControlPlaneApi } from "../api"; | |
| 14 | −import { | |
| 15 | − withRequestActor, | |
| 16 | − withWorkspaceRequestActor, | |
| 17 | −} from "../http-auth"; | |
| 18 | − | |
| 19 | −const requireReadWorkspace = (workspaceId: WorkspaceId) => | |
| 20 | − requirePermission({ | |
| 21 | − permission: "workspace:read", | |
| 22 | − workspaceId, | |
| 23 | − }); | |
| 24 | − | |
| 25 | −const requireManageWorkspace = (workspaceId: WorkspaceId) => | |
| 26 | − requirePermission({ | |
| 27 | − permission: "workspace:manage", | |
| 28 | − workspaceId, | |
| 29 | − }); | |
| 30 | − | |
| 31 | −const requireOrganizationWorkspaceRead = (organizationId: OrganizationId) => | |
| 32 | − requirePermission({ | |
| 33 | − permission: "workspace:read", | |
| 34 | − organizationId, | |
| 35 | − }); | |
| 36 | − | |
| 37 | −const requireOrganizationWorkspaceManage = (organizationId: OrganizationId) => | |
| 38 | − requirePermission({ | |
| 39 | − permission: "workspace:manage", | |
| 40 | − organizationId, | |
| 41 | − }); | |
| 42 | − | |
| 43 | −export const ControlPlaneWorkspacesLive = HttpApiBuilder.group( | |
| 44 | − ControlPlaneApi, | |
| 45 | − "workspaces", | |
| 46 | − (handlers) => | |
| 47 | − handlers | |
| 48 | − .handle("list", ({ path }) => | |
| 49 | − withRequestActor("workspaces.list", () => | |
| 50 | − withPolicy( | |
| 51 | − requireOrganizationWorkspaceRead(path.organizationId), | |
| 52 | − )(listWorkspaces(path.organizationId)) | |
| 53 | − ), | |
| 54 | − ) | |
| 55 | − .handle("create", ({ path, payload }) => | |
| 56 | − withRequestActor("workspaces.create", (actor) => | |
| 57 | − withPolicy( | |
| 58 | − requireOrganizationWorkspaceManage(path.organizationId), | |
| 59 | − )( | |
| 60 | − createWorkspace({ | |
| 61 | − organizationId: path.organizationId, | |
| 62 | − payload, | |
| 63 | − createdByAccountId: actor.principal.accountId, | |
| 64 | − }), | |
| 65 | − ), | |
| 66 | − ), | |
| 67 | − ) | |
| 68 | − .handle("get", ({ path }) => | |
| 69 | − withWorkspaceRequestActor("workspaces.get", path.workspaceId, () => | |
| 70 | − withPolicy(requireReadWorkspace(path.workspaceId))( | |
| 71 | − getWorkspace(path.workspaceId), | |
| 72 | − ), | |
| 73 | − ), | |
| 74 | − ) | |
| 75 | − .handle("update", ({ path, payload }) => | |
| 76 | − withWorkspaceRequestActor("workspaces.update", path.workspaceId, () => | |
| 77 | − withPolicy(requireManageWorkspace(path.workspaceId))( | |
| 78 | − updateWorkspace({ workspaceId: path.workspaceId, payload }), | |
| 79 | − ), | |
| 80 | − ), | |
| 81 | − ) | |
| 82 | − .handle("remove", ({ path }) => | |
| 83 | − withWorkspaceRequestActor("workspaces.remove", path.workspaceId, () => | |
| 84 | − withPolicy(requireManageWorkspace(path.workspaceId))( | |
| 85 | − removeWorkspace({ workspaceId: path.workspaceId }), | |
| 86 | − ), | |
| 87 | − ), | |
| 88 | − ), | |
| 89 | −); | |
| @@ -1,7 +0,0 @@ | ||
| 1 | −export const ControlPlaneAuthHeaders = { | |
| 2 | − accountId: "x-executor-account-id", | |
| 3 | − principalProvider: "x-executor-principal-provider", | |
| 4 | − principalSubject: "x-executor-principal-subject", | |
| 5 | − principalEmail: "x-executor-principal-email", | |
| 6 | − principalDisplayName: "x-executor-principal-name", | |
| 7 | −} as const; | |
| @@ -1,35 +1,18 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | FetchHttpClient, |
| 3 | 3 | HttpApiClient, |
| 4 | − HttpClient, | |
| 5 | − HttpClientRequest, | |
| 6 | 4 | } from "@effect/platform"; |
| 7 | 5 | import * as Effect from "effect/Effect"; |
| 8 | 6 | |
| 9 | 7 | import { ControlPlaneApi } from "./api/api"; |
| 10 | −import { ControlPlaneAuthHeaders } from "./auth-headers"; | |
| 11 | 8 | |
| 12 | 9 | export const createControlPlaneClient = (input: { |
| 13 | 10 | baseUrl: string; |
| 14 | 11 | accountId?: string; |
| 15 | −}) => { | |
| 16 | − const accountId = input.accountId; | |
| 17 | − | |
| 18 | − return HttpApiClient.make(ControlPlaneApi, { | |
| 12 | +}) => | |
| 13 | + HttpApiClient.make(ControlPlaneApi, { | |
| 19 | 14 | baseUrl: input.baseUrl, |
| 20 | − transformClient: accountId | |
| 21 | − ? (client) => | |
| 22 | − client.pipe( | |
| 23 | − HttpClient.mapRequest( | |
| 24 | − HttpClientRequest.setHeader( | |
| 25 | − ControlPlaneAuthHeaders.accountId, | |
| 26 | − accountId, | |
| 27 | − ), | |
| 28 | − ), | |
| 29 | − ) | |
| 30 | − : undefined, | |
| 31 | 15 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 32 | −}; | |
| 33 | 16 | |
| 34 | 17 | export type ControlPlaneClient = Effect.Effect.Success< |
| 35 | 18 | ReturnType<typeof createControlPlaneClient> |
| @@ -1,98 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { assertInstanceOf, assertTrue } from "@effect/vitest/utils"; | |
| 3 | −import { AccountIdSchema, WorkspaceIdSchema } from "#schema"; | |
| 4 | −import * as Effect from "effect/Effect"; | |
| 5 | − | |
| 6 | −import { | |
| 7 | − createActor, | |
| 8 | − ActorForbiddenError, | |
| 9 | − ActorUnauthenticatedError, | |
| 10 | −} from "./actor"; | |
| 11 | − | |
| 12 | −describe("control-plane-domain actor", () => { | |
| 13 | − it.effect("allows workspace permissions from active membership", () => | |
| 14 | − Effect.gen(function* () { | |
| 15 | − const accountId = AccountIdSchema.make("acc_1"); | |
| 16 | − const workspaceId = WorkspaceIdSchema.make("ws_1"); | |
| 17 | − | |
| 18 | − const actor = yield* createActor({ | |
| 19 | − principal: { | |
| 20 | − accountId, | |
| 21 | − provider: "local", | |
| 22 | − subject: "local:acc_1", | |
| 23 | − email: null, | |
| 24 | − displayName: null, | |
| 25 | − }, | |
| 26 | − workspaceMemberships: [ | |
| 27 | − { | |
| 28 | − accountId, | |
| 29 | − workspaceId, | |
| 30 | − role: "editor", | |
| 31 | − status: "active", | |
| 32 | − grantedAt: 1, | |
| 33 | − updatedAt: 1, | |
| 34 | − }, | |
| 35 | − ], | |
| 36 | − organizationMemberships: [], | |
| 37 | − }); | |
| 38 | − | |
| 39 | − yield* actor.requirePermission({ | |
| 40 | − permission: "sources:write", | |
| 41 | − workspaceId, | |
| 42 | − }); | |
| 43 | − }), | |
| 44 | − ); | |
| 45 | − | |
| 46 | − it.effect("denies permission when role is insufficient", () => | |
| 47 | − Effect.gen(function* () { | |
| 48 | − const accountId = AccountIdSchema.make("acc_1"); | |
| 49 | − const workspaceId = WorkspaceIdSchema.make("ws_1"); | |
| 50 | − | |
| 51 | − const actor = yield* createActor({ | |
| 52 | − principal: { | |
| 53 | − accountId, | |
| 54 | − provider: "local", | |
| 55 | − subject: "local:acc_1", | |
| 56 | − email: null, | |
| 57 | − displayName: null, | |
| 58 | − }, | |
| 59 | − workspaceMemberships: [ | |
| 60 | − { | |
| 61 | − accountId, | |
| 62 | − workspaceId, | |
| 63 | − role: "viewer", | |
| 64 | − status: "active", | |
| 65 | − grantedAt: 1, | |
| 66 | − updatedAt: 1, | |
| 67 | − }, | |
| 68 | − ], | |
| 69 | − organizationMemberships: [], | |
| 70 | − }); | |
| 71 | − | |
| 72 | − const denied = yield* Effect.either( | |
| 73 | − actor.requirePermission({ | |
| 74 | − permission: "sources:write", | |
| 75 | − workspaceId, | |
| 76 | − }), | |
| 77 | − ); | |
| 78 | − | |
| 79 | − assertTrue(denied._tag === "Left"); | |
| 80 | − assertInstanceOf(denied.left, ActorForbiddenError); | |
| 81 | − }), | |
| 82 | − ); | |
| 83 | − | |
| 84 | − it.effect("fails when principal is missing", () => | |
| 85 | − Effect.gen(function* () { | |
| 86 | − const result = yield* Effect.either( | |
| 87 | − createActor({ | |
| 88 | − principal: null, | |
| 89 | − workspaceMemberships: [], | |
| 90 | − organizationMemberships: [], | |
| 91 | − }), | |
| 92 | − ); | |
| 93 | − | |
| 94 | − assertTrue(result._tag === "Left"); | |
| 95 | − assertInstanceOf(result.left, ActorUnauthenticatedError); | |
| 96 | − }), | |
| 97 | − ); | |
| 98 | −}); | |
| @@ -1,239 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − RolePermissions, | |
| 3 | − type OrganizationId, | |
| 4 | − type OrganizationMembership, | |
| 5 | − type Permission, | |
| 6 | − type Principal, | |
| 7 | − type WorkspaceId, | |
| 8 | − type WorkspaceMembership, | |
| 9 | −} from "#schema"; | |
| 10 | −import * as Context from "effect/Context"; | |
| 11 | −import * as Data from "effect/Data"; | |
| 12 | −import * as Effect from "effect/Effect"; | |
| 13 | −import * as Layer from "effect/Layer"; | |
| 14 | − | |
| 15 | −export class ActorUnauthenticatedError extends Data.TaggedError( | |
| 16 | − "ActorUnauthenticatedError", | |
| 17 | −)<{ | |
| 18 | − message: string; | |
| 19 | −}> {} | |
| 20 | − | |
| 21 | −export class ActorForbiddenError extends Data.TaggedError("ActorForbiddenError")<{ | |
| 22 | − permission: Permission; | |
| 23 | − scope: string; | |
| 24 | −}> {} | |
| 25 | − | |
| 26 | −export type PermissionRequest = { | |
| 27 | − permission: Permission; | |
| 28 | − workspaceId?: WorkspaceId; | |
| 29 | − organizationId?: OrganizationId; | |
| 30 | −}; | |
| 31 | − | |
| 32 | −export type ActorShape = { | |
| 33 | − principal: Principal; | |
| 34 | − workspaceMemberships: ReadonlyArray<WorkspaceMembership>; | |
| 35 | − organizationMemberships: ReadonlyArray<OrganizationMembership>; | |
| 36 | − hasPermission: (request: PermissionRequest) => boolean; | |
| 37 | − requirePermission: ( | |
| 38 | − request: PermissionRequest, | |
| 39 | − ) => Effect.Effect<void, ActorForbiddenError>; | |
| 40 | − hasWorkspaceAccess: (workspaceId: WorkspaceId) => boolean; | |
| 41 | − requireWorkspaceAccess: ( | |
| 42 | − workspaceId: WorkspaceId, | |
| 43 | − ) => Effect.Effect<void, ActorForbiddenError>; | |
| 44 | −}; | |
| 45 | − | |
| 46 | −export class Actor extends Context.Tag("#domain/auth/Actor")< | |
| 47 | − Actor, | |
| 48 | − ActorShape | |
| 49 | −>() {} | |
| 50 | − | |
| 51 | −export type CreateActorInput = { | |
| 52 | − principal: Principal | null; | |
| 53 | − workspaceMemberships: ReadonlyArray<WorkspaceMembership>; | |
| 54 | − organizationMemberships: ReadonlyArray<OrganizationMembership>; | |
| 55 | −}; | |
| 56 | − | |
| 57 | −const isWorkspaceMembershipActive = ( | |
| 58 | − membership: WorkspaceMembership, | |
| 59 | −): boolean => membership.status === "active"; | |
| 60 | − | |
| 61 | −const isOrganizationMembershipActive = ( | |
| 62 | − membership: OrganizationMembership, | |
| 63 | −): boolean => membership.status === "active"; | |
| 64 | − | |
| 65 | −const permissionsByRole = new Map( | |
| 66 | − Object.entries(RolePermissions).map(([role, permissions]) => [ | |
| 67 | − role, | |
| 68 | − new Set<Permission>(permissions), | |
| 69 | − ]), | |
| 70 | −); | |
| 71 | − | |
| 72 | −const permissionsForRole = ( | |
| 73 | − role: keyof typeof RolePermissions, | |
| 74 | −): ReadonlySet<Permission> => permissionsByRole.get(role) ?? new Set<Permission>(); | |
| 75 | − | |
| 76 | −const hasPermissionInWorkspace = ( | |
| 77 | − request: PermissionRequest, | |
| 78 | − memberships: ReadonlyArray<WorkspaceMembership>, | |
| 79 | −): boolean => { | |
| 80 | − if (request.workspaceId === undefined) { | |
| 81 | − return false; | |
| 82 | − } | |
| 83 | − | |
| 84 | − return memberships.some((membership) => { | |
| 85 | − if (!isWorkspaceMembershipActive(membership)) { | |
| 86 | − return false; | |
| 87 | − } | |
| 88 | − | |
| 89 | − if (membership.workspaceId !== request.workspaceId) { | |
| 90 | − return false; | |
| 91 | − } | |
| 92 | − | |
| 93 | − return permissionsForRole(membership.role).has(request.permission); | |
| 94 | − }); | |
| 95 | −}; | |
| 96 | − | |
| 97 | −const hasPermissionInOrganization = ( | |
| 98 | − request: PermissionRequest, | |
| 99 | − memberships: ReadonlyArray<OrganizationMembership>, | |
| 100 | −): boolean => { | |
| 101 | − if (request.organizationId === undefined) { | |
| 102 | − return false; | |
| 103 | − } | |
| 104 | − | |
| 105 | − return memberships.some((membership) => { | |
| 106 | − if (!isOrganizationMembershipActive(membership)) { | |
| 107 | − return false; | |
| 108 | − } | |
| 109 | − | |
| 110 | − if (membership.organizationId !== request.organizationId) { | |
| 111 | − return false; | |
| 112 | − } | |
| 113 | − | |
| 114 | − return permissionsForRole(membership.role).has(request.permission); | |
| 115 | − }); | |
| 116 | −}; | |
| 117 | − | |
| 118 | −const hasPermissionGlobally = ( | |
| 119 | − request: PermissionRequest, | |
| 120 | − workspaceMemberships: ReadonlyArray<WorkspaceMembership>, | |
| 121 | − organizationMemberships: ReadonlyArray<OrganizationMembership>, | |
| 122 | −): boolean => { | |
| 123 | − const workspaceAllowed = workspaceMemberships.some( | |
| 124 | − (membership) => | |
| 125 | − isWorkspaceMembershipActive(membership) | |
| 126 | − && permissionsForRole(membership.role).has(request.permission), | |
| 127 | − ); | |
| 128 | − | |
| 129 | − if (workspaceAllowed) { | |
| 130 | − return true; | |
| 131 | − } | |
| 132 | − | |
| 133 | − return organizationMemberships.some( | |
| 134 | − (membership) => | |
| 135 | − isOrganizationMembershipActive(membership) | |
| 136 | − && permissionsForRole(membership.role).has(request.permission), | |
| 137 | − ); | |
| 138 | −}; | |
| 139 | − | |
| 140 | −const scopeLabel = (request: PermissionRequest): string => { | |
| 141 | − if (request.workspaceId !== undefined) { | |
| 142 | − return `workspace:${request.workspaceId}`; | |
| 143 | − } | |
| 144 | − | |
| 145 | − if (request.organizationId !== undefined) { | |
| 146 | − return `organization:${request.organizationId}`; | |
| 147 | − } | |
| 148 | − | |
| 149 | − return "global"; | |
| 150 | −}; | |
| 151 | − | |
| 152 | −const toWorkspaceAccessPermissionRequest = ( | |
| 153 | − workspaceId: WorkspaceId, | |
| 154 | −): PermissionRequest => ({ | |
| 155 | − permission: "workspace:read", | |
| 156 | − workspaceId, | |
| 157 | −}); | |
| 158 | − | |
| 159 | −const toActorShape = (input: { | |
| 160 | − principal: Principal; | |
| 161 | − workspaceMemberships: ReadonlyArray<WorkspaceMembership>; | |
| 162 | − organizationMemberships: ReadonlyArray<OrganizationMembership>; | |
| 163 | −}): ActorShape => { | |
| 164 | − const hasPermission = (request: PermissionRequest): boolean => { | |
| 165 | − if (request.workspaceId !== undefined) { | |
| 166 | − return hasPermissionInWorkspace(request, input.workspaceMemberships); | |
| 167 | − } | |
| 168 | − | |
| 169 | − if (request.organizationId !== undefined) { | |
| 170 | − return hasPermissionInOrganization(request, input.organizationMemberships); | |
| 171 | − } | |
| 172 | − | |
| 173 | − return hasPermissionGlobally( | |
| 174 | − request, | |
| 175 | − input.workspaceMemberships, | |
| 176 | − input.organizationMemberships, | |
| 177 | − ); | |
| 178 | − }; | |
| 179 | − | |
| 180 | − const requirePermission = ( | |
| 181 | − request: PermissionRequest, | |
| 182 | − ): Effect.Effect<void, ActorForbiddenError> => | |
| 183 | − hasPermission(request) | |
| 184 | − ? Effect.void | |
| 185 | − : Effect.fail( | |
| 186 | − new ActorForbiddenError({ | |
| 187 | − permission: request.permission, | |
| 188 | − scope: scopeLabel(request), | |
| 189 | − }), | |
| 190 | − ); | |
| 191 | − | |
| 192 | − const hasWorkspaceAccess = (workspaceId: WorkspaceId): boolean => | |
| 193 | − hasPermission(toWorkspaceAccessPermissionRequest(workspaceId)); | |
| 194 | − | |
| 195 | − const requireWorkspaceAccess = ( | |
| 196 | − workspaceId: WorkspaceId, | |
| 197 | − ): Effect.Effect<void, ActorForbiddenError> => | |
| 198 | − requirePermission(toWorkspaceAccessPermissionRequest(workspaceId)); | |
| 199 | − | |
| 200 | − return { | |
| 201 | − principal: input.principal, | |
| 202 | − workspaceMemberships: input.workspaceMemberships, | |
| 203 | − organizationMemberships: input.organizationMemberships, | |
| 204 | − hasPermission, | |
| 205 | − requirePermission, | |
| 206 | − hasWorkspaceAccess, | |
| 207 | − requireWorkspaceAccess, | |
| 208 | − }; | |
| 209 | −}; | |
| 210 | − | |
| 211 | −export const createAllowAllActor = (principal: Principal): ActorShape => ({ | |
| 212 | − principal, | |
| 213 | − workspaceMemberships: [], | |
| 214 | − organizationMemberships: [], | |
| 215 | − hasPermission: () => true, | |
| 216 | − requirePermission: () => Effect.void, | |
| 217 | − hasWorkspaceAccess: () => true, | |
| 218 | − requireWorkspaceAccess: () => Effect.void, | |
| 219 | −}); | |
| 220 | − | |
| 221 | −export const createActor = ( | |
| 222 | − input: CreateActorInput, | |
| 223 | −): Effect.Effect<ActorShape, ActorUnauthenticatedError> => | |
| 224 | − input.principal === null | |
| 225 | − ? Effect.fail( | |
| 226 | − new ActorUnauthenticatedError({ | |
| 227 | − message: "Authenticated principal is required", | |
| 228 | − }), | |
| 229 | − ) | |
| 230 | − : Effect.succeed( | |
| 231 | − toActorShape({ | |
| 232 | − principal: input.principal, | |
| 233 | − workspaceMemberships: input.workspaceMemberships, | |
| 234 | − organizationMemberships: input.organizationMemberships, | |
| 235 | − }), | |
| 236 | − ); | |
| 237 | − | |
| 238 | −export const ActorLive = (actor: ActorShape): Layer.Layer<Actor> => | |
| 239 | − Layer.succeed(Actor, actor); | |
| @@ -1,21 +0,0 @@ | ||
| 1 | −export { | |
| 2 | − Actor, | |
| 3 | − ActorForbiddenError, | |
| 4 | − ActorLive, | |
| 5 | − ActorUnauthenticatedError, | |
| 6 | − createActor, | |
| 7 | − createAllowAllActor, | |
| 8 | − type ActorShape, | |
| 9 | − type CreateActorInput, | |
| 10 | − type PermissionRequest, | |
| 11 | −} from "./actor"; | |
| 12 | − | |
| 13 | −export { | |
| 14 | − all, | |
| 15 | − any, | |
| 16 | − policy, | |
| 17 | − requirePermission, | |
| 18 | − requireWorkspaceAccess, | |
| 19 | − withPolicy, | |
| 20 | − type Policy, | |
| 21 | −} from "./policy"; | |
| @@ -1,34 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { AccountIdSchema, WorkspaceIdSchema } from "#schema"; | |
| 3 | −import * as Effect from "effect/Effect"; | |
| 4 | − | |
| 5 | −import { Actor, createAllowAllActor } from "./actor"; | |
| 6 | −import { requirePermission, withPolicy } from "./policy"; | |
| 7 | − | |
| 8 | −describe("control-plane-domain policy", () => { | |
| 9 | − it.effect("runs protected effect when permission is granted", () => | |
| 10 | − Effect.gen(function* () { | |
| 11 | − const accountId = AccountIdSchema.make("acc_1"); | |
| 12 | − const workspaceId = WorkspaceIdSchema.make("ws_1"); | |
| 13 | − | |
| 14 | − const principal = { | |
| 15 | − accountId, | |
| 16 | − provider: "local" as const, | |
| 17 | − subject: "local:acc_1", | |
| 18 | − email: null, | |
| 19 | − displayName: null, | |
| 20 | − }; | |
| 21 | − | |
| 22 | − const result = yield* withPolicy( | |
| 23 | − requirePermission({ | |
| 24 | − permission: "workspace:read", | |
| 25 | − workspaceId, | |
| 26 | − }), | |
| 27 | − )(Effect.succeed("ok")).pipe( | |
| 28 | − Effect.provideService(Actor, createAllowAllActor(principal)), | |
| 29 | − ); | |
| 30 | − | |
| 31 | − expect(result).toBe("ok"); | |
| 32 | − }), | |
| 33 | − ); | |
| 34 | −}); | |
| @@ -1,55 +0,0 @@ | ||
| 1 | −import type { | |
| 2 | − OrganizationId, | |
| 3 | − Permission, | |
| 4 | − WorkspaceId, | |
| 5 | −} from "#schema"; | |
| 6 | −import * as Effect from "effect/Effect"; | |
| 7 | − | |
| 8 | −import { | |
| 9 | − Actor, | |
| 10 | − ActorForbiddenError, | |
| 11 | − type ActorShape, | |
| 12 | −} from "./actor"; | |
| 13 | − | |
| 14 | −export type Policy<E = never, R = never> = Effect.Effect< | |
| 15 | − void, | |
| 16 | − ActorForbiddenError | E, | |
| 17 | − Actor | R | |
| 18 | −>; | |
| 19 | − | |
| 20 | −export const policy = <E, R>( | |
| 21 | − predicate: (actor: ActorShape) => Effect.Effect<boolean, E, R>, | |
| 22 | − onForbidden: (actor: ActorShape) => ActorForbiddenError, | |
| 23 | −): Policy<E, R> => | |
| 24 | − Effect.gen(function* () { | |
| 25 | − const actor = yield* Actor; | |
| 26 | − const allowed = yield* predicate(actor); | |
| 27 | − | |
| 28 | − if (!allowed) { | |
| 29 | − return yield* Effect.fail(onForbidden(actor)); | |
| 30 | − } | |
| 31 | − }); | |
| 32 | − | |
| 33 | −export const requirePermission = (request: { | |
| 34 | − permission: Permission; | |
| 35 | − workspaceId?: WorkspaceId; | |
| 36 | − organizationId?: OrganizationId; | |
| 37 | −}): Policy => | |
| 38 | − Effect.flatMap(Actor, (actor) => actor.requirePermission(request)); | |
| 39 | − | |
| 40 | −export const requireWorkspaceAccess = (workspaceId: WorkspaceId): Policy => | |
| 41 | − Effect.flatMap(Actor, (actor) => actor.requireWorkspaceAccess(workspaceId)); | |
| 42 | − | |
| 43 | −export const withPolicy = | |
| 44 | − <E1, R1>(auth: Policy<E1, R1>) => | |
| 45 | − <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E | E1 | ActorForbiddenError, R | R1 | Actor> => | |
| 46 | − Effect.zipRight(auth, self); | |
| 47 | − | |
| 48 | −export const all = <E, R>( | |
| 49 | − policies: ReadonlyArray<Policy<E, R>>, | |
| 50 | −): Policy<E, R> => | |
| 51 | − Effect.forEach(policies, (item) => item, { discard: true }); | |
| 52 | − | |
| 53 | −export const any = <E, R>( | |
| 54 | − policies: readonly [Policy<E, R>, ...Array<Policy<E, R>>], | |
| 55 | −): Policy<E, R> => Effect.firstSuccessOf(policies); | |
| @@ -1 +0,0 @@ | ||
| 1 | −export * from "./auth"; | |
| @@ -3,5 +3,3 @@ export * from "./persistence/index"; · 2 unmodified lines | ||
| 3 | 3 | export * from "./api/index"; |
| 4 | 4 | export * from "./runtime/index"; |
| 5 | 5 | export * from "./client"; |
| 6 | − | |
| 7 | −export * as Domain from "./domain/index"; | |
| @@ -1,947 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { assertTrue } from "@effect/vitest/utils"; | |
| 3 | −import { | |
| 4 | − AccountIdSchema, | |
| 5 | − CredentialIdSchema, | |
| 6 | − McpSourceAuthSessionDataJsonSchema, | |
| 7 | − OrganizationIdSchema, | |
| 8 | − OrganizationMemberIdSchema, | |
| 9 | − PolicyIdSchema, | |
| 10 | − SecretMaterialIdSchema, | |
| 11 | − StaticBearerAuthArtifactConfigJsonSchema, | |
| 12 | − StaticOAuth2AuthArtifactConfigJsonSchema, | |
| 13 | − decodeBuiltInAuthArtifactConfig, | |
| 14 | − SourceAuthSessionIdSchema, | |
| 15 | − SourceIdSchema, | |
| 16 | − SourceRecipeIdSchema, | |
| 17 | − SourceRecipeSchemaBundleIdSchema, | |
| 18 | − SourceRecipeRevisionIdSchema, | |
| 19 | − WorkspaceIdSchema, | |
| 20 | −} from "#schema"; | |
| 21 | −import * as Effect from "effect/Effect"; | |
| 22 | −import * as Option from "effect/Option"; | |
| 23 | −import * as Schema from "effect/Schema"; | |
| 24 | −import * as Scope from "effect/Scope"; | |
| 25 | − | |
| 26 | −import { | |
| 27 | − createSqlControlPlanePersistence, | |
| 28 | − type SqlControlPlanePersistence, | |
| 29 | −} from "./index"; | |
| 30 | −import { drizzleSchema } from "./schema"; | |
| 31 | − | |
| 32 | −const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | |
| 33 | − Effect.acquireRelease( | |
| 34 | − createSqlControlPlanePersistence({ | |
| 35 | − localDataDir: ":memory:", | |
| 36 | − }), | |
| 37 | − (persistence) => | |
| 38 | − Effect.tryPromise({ | |
| 39 | − try: () => persistence.close(), | |
| 40 | − catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 41 | − }).pipe(Effect.orDie), | |
| 42 | − ); | |
| 43 | − | |
| 44 | −const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | |
| 45 | −const encodeStaticBearerArtifactConfig = Schema.encodeSync( | |
| 46 | − StaticBearerAuthArtifactConfigJsonSchema, | |
| 47 | −); | |
| 48 | −const encodeStaticOAuth2ArtifactConfig = Schema.encodeSync( | |
| 49 | − StaticOAuth2AuthArtifactConfigJsonSchema, | |
| 50 | −); | |
| 51 | − | |
| 52 | −const openApiBindingConfigJson = (specUrl: string): string => | |
| 53 | − JSON.stringify({ | |
| 54 | − adapterKey: "openapi", | |
| 55 | − version: 1, | |
| 56 | − payload: { | |
| 57 | − specUrl, | |
| 58 | − defaultHeaders: null, | |
| 59 | − }, | |
| 60 | − }); | |
| 61 | − | |
| 62 | −const baseRevisionRecord = (input: { | |
| 63 | − id: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; | |
| 64 | − recipeId: ReturnType<typeof SourceRecipeIdSchema.make>; | |
| 65 | − revisionNumber: number; | |
| 66 | − sourceConfigJson: string; | |
| 67 | − manifestJson?: string | null; | |
| 68 | − manifestHash?: string | null; | |
| 69 | − materializationHash?: string | null; | |
| 70 | − createdAt: number; | |
| 71 | − updatedAt: number; | |
| 72 | −}) => ({ | |
| 73 | − id: input.id, | |
| 74 | − recipeId: input.recipeId, | |
| 75 | − revisionNumber: input.revisionNumber, | |
| 76 | − sourceConfigJson: input.sourceConfigJson, | |
| 77 | − manifestJson: input.manifestJson ?? null, | |
| 78 | − manifestHash: input.manifestHash ?? null, | |
| 79 | − materializationHash: input.materializationHash ?? null, | |
| 80 | − createdAt: input.createdAt, | |
| 81 | − updatedAt: input.updatedAt, | |
| 82 | −}); | |
| 83 | − | |
| 84 | −const seedWorkspaceSourceState = (input: { | |
| 85 | − persistence: SqlControlPlanePersistence; | |
| 86 | − accountId: ReturnType<typeof AccountIdSchema.make>; | |
| 87 | − organizationId: ReturnType<typeof OrganizationIdSchema.make>; | |
| 88 | − workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | |
| 89 | − sourceId: ReturnType<typeof SourceIdSchema.make>; | |
| 90 | −}): Effect.Effect<void, unknown, never> => | |
| 91 | − Effect.gen(function* () { | |
| 92 | − const now = Date.now(); | |
| 93 | − const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); | |
| 94 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`); | |
| 95 | − | |
| 96 | − yield* input.persistence.rows.organizations.insert({ | |
| 97 | − id: input.organizationId, | |
| 98 | − slug: `org-${input.organizationId}`, | |
| 99 | − name: `Org ${input.organizationId}`, | |
| 100 | − status: "active", | |
| 101 | − createdByAccountId: input.accountId, | |
| 102 | − createdAt: now, | |
| 103 | − updatedAt: now, | |
| 104 | − }); | |
| 105 | − yield* input.persistence.rows.workspaces.insert({ | |
| 106 | − id: input.workspaceId, | |
| 107 | − organizationId: input.organizationId, | |
| 108 | − name: `Workspace ${input.workspaceId}`, | |
| 109 | − createdByAccountId: input.accountId, | |
| 110 | − createdAt: now, | |
| 111 | − updatedAt: now, | |
| 112 | − }); | |
| 113 | − yield* input.persistence.rows.sourceRecipes.upsert({ | |
| 114 | − id: recipeId, | |
| 115 | − kind: "http_api", | |
| 116 | − adapterKey: "openapi", | |
| 117 | − providerKey: "openapi:https://api.github.com", | |
| 118 | − name: "Github", | |
| 119 | − summary: null, | |
| 120 | − visibility: "workspace", | |
| 121 | − latestRevisionId: recipeRevisionId, | |
| 122 | − createdAt: now, | |
| 123 | − updatedAt: now, | |
| 124 | − }); | |
| 125 | − yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | |
| 126 | − id: recipeRevisionId, | |
| 127 | − recipeId, | |
| 128 | − revisionNumber: 1, | |
| 129 | − sourceConfigJson: JSON.stringify({ | |
| 130 | − kind: "openapi", | |
| 131 | − endpoint: "https://api.github.com", | |
| 132 | − specUrl: "https://api.github.com/openapi.json", | |
| 133 | − }), | |
| 134 | − manifestJson: null, | |
| 135 | − manifestHash: null, | |
| 136 | − materializationHash: null, | |
| 137 | − createdAt: now, | |
| 138 | − updatedAt: now, | |
| 139 | − })); | |
| 140 | − yield* input.persistence.rows.sources.insert({ | |
| 141 | − id: input.sourceId, | |
| 142 | − workspaceId: input.workspaceId, | |
| 143 | − recipeId, | |
| 144 | − recipeRevisionId, | |
| 145 | − name: "Github", | |
| 146 | − kind: "openapi", | |
| 147 | − endpoint: "https://api.github.com", | |
| 148 | − status: "connected", | |
| 149 | − enabled: true, | |
| 150 | − namespace: "github", | |
| 151 | − importAuthPolicy: "reuse_runtime", | |
| 152 | − bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 153 | − sourceHash: null, | |
| 154 | − lastError: null, | |
| 155 | − createdAt: now, | |
| 156 | − updatedAt: now, | |
| 157 | − }); | |
| 158 | − }); | |
| 159 | − | |
| 160 | −const seedWorkspaceAuthArtifactState = (input: { | |
| 161 | − persistence: SqlControlPlanePersistence; | |
| 162 | − accountId: ReturnType<typeof AccountIdSchema.make>; | |
| 163 | − organizationId: ReturnType<typeof OrganizationIdSchema.make>; | |
| 164 | − workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | |
| 165 | − sourceId: ReturnType<typeof SourceIdSchema.make>; | |
| 166 | −}): Effect.Effect<{ | |
| 167 | − tokenId: ReturnType<typeof SecretMaterialIdSchema.make>; | |
| 168 | − refreshId: ReturnType<typeof SecretMaterialIdSchema.make>; | |
| 169 | −}, unknown, never> => | |
| 170 | − Effect.gen(function* () { | |
| 171 | − const now = Date.now(); | |
| 172 | − const authArtifactId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | |
| 173 | − const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); | |
| 174 | − const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); | |
| 175 | − | |
| 176 | − yield* seedWorkspaceSourceState(input); | |
| 177 | − yield* input.persistence.rows.secretMaterials.upsert({ | |
| 178 | − id: tokenId, | |
| 179 | − name: null, | |
| 180 | − purpose: "oauth_access_token", | |
| 181 | − value: "token", | |
| 182 | − createdAt: now, | |
| 183 | − updatedAt: now, | |
| 184 | − }); | |
| 185 | − yield* input.persistence.rows.secretMaterials.upsert({ | |
| 186 | − id: refreshId, | |
| 187 | − name: null, | |
| 188 | − purpose: "oauth_refresh_token", | |
| 189 | − value: "refresh", | |
| 190 | − createdAt: now, | |
| 191 | − updatedAt: now, | |
| 192 | − }); | |
| 193 | − yield* input.persistence.rows.authArtifacts.upsert({ | |
| 194 | − id: authArtifactId, | |
| 195 | − workspaceId: input.workspaceId, | |
| 196 | − sourceId: input.sourceId, | |
| 197 | − actorAccountId: input.accountId, | |
| 198 | − slot: "runtime", | |
| 199 | − artifactKind: "static_oauth2", | |
| 200 | − configJson: encodeStaticOAuth2ArtifactConfig({ | |
| 201 | − headerName: "Authorization", | |
| 202 | − prefix: "Bearer ", | |
| 203 | − accessToken: { | |
| 204 | − providerId: "postgres", | |
| 205 | − handle: tokenId, | |
| 206 | − }, | |
| 207 | − refreshToken: { | |
| 208 | − providerId: "postgres", | |
| 209 | − handle: refreshId, | |
| 210 | − }, | |
| 211 | − }), | |
| 212 | − grantSetJson: null, | |
| 213 | − createdAt: now, | |
| 214 | − updatedAt: now, | |
| 215 | − }); | |
| 216 | − yield* input.persistence.rows.sourceAuthSessions.upsert({ | |
| 217 | − id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`), | |
| 218 | − workspaceId: input.workspaceId, | |
| 219 | − sourceId: input.sourceId, | |
| 220 | − actorAccountId: input.accountId, | |
| 221 | − executionId: null, | |
| 222 | − interactionId: null, | |
| 223 | − providerKind: "mcp_oauth", | |
| 224 | − credentialSlot: "runtime", | |
| 225 | − status: "pending", | |
| 226 | − state: `state_${input.workspaceId}`, | |
| 227 | − sessionDataJson: encodeSessionDataJson({ | |
| 228 | − kind: "mcp_oauth", | |
| 229 | − endpoint: "https://api.github.com", | |
| 230 | − redirectUri: "http://127.0.0.1/callback", | |
| 231 | − scope: null, | |
| 232 | − resourceMetadataUrl: null, | |
| 233 | − authorizationServerUrl: null, | |
| 234 | − resourceMetadata: null, | |
| 235 | − authorizationServerMetadata: null, | |
| 236 | − clientInformation: null, | |
| 237 | − codeVerifier: "verifier", | |
| 238 | − authorizationUrl: "https://example.com/auth", | |
| 239 | − }), | |
| 240 | − errorText: null, | |
| 241 | − completedAt: null, | |
| 242 | − createdAt: now, | |
| 243 | − updatedAt: now, | |
| 244 | − }); | |
| 245 | − | |
| 246 | − return { tokenId, refreshId }; | |
| 247 | − }); | |
| 248 | − | |
| 249 | −describe("control-plane-persistence-drizzle", () => { | |
| 250 | − it.scoped("creates and reads organization/workspace/source/policy rows", () => | |
| 251 | − Effect.gen(function* () { | |
| 252 | − const persistence = yield* makePersistence; | |
| 253 | − const now = Date.now(); | |
| 254 | − const organizationId = OrganizationIdSchema.make("org_1"); | |
| 255 | − const accountId = AccountIdSchema.make("acc_1"); | |
| 256 | − const workspaceId = WorkspaceIdSchema.make("ws_1"); | |
| 257 | − | |
| 258 | − yield* persistence.rows.organizations.insert({ | |
| 259 | − id: organizationId, | |
| 260 | − slug: "acme", | |
| 261 | − name: "Acme", | |
| 262 | − status: "active", | |
| 263 | − createdByAccountId: accountId, | |
| 264 | − createdAt: now, | |
| 265 | − updatedAt: now, | |
| 266 | − }); | |
| 267 | − | |
| 268 | − yield* persistence.rows.workspaces.insert({ | |
| 269 | − id: workspaceId, | |
| 270 | − organizationId, | |
| 271 | − name: "Main", | |
| 272 | − createdByAccountId: accountId, | |
| 273 | − createdAt: now, | |
| 274 | − updatedAt: now, | |
| 275 | − }); | |
| 276 | − | |
| 277 | − yield* persistence.rows.sources.insert({ | |
| 278 | − id: SourceIdSchema.make("src_1"), | |
| 279 | − workspaceId, | |
| 280 | − recipeId: SourceRecipeIdSchema.make("src_recipe_1"), | |
| 281 | − recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"), | |
| 282 | − name: "Github", | |
| 283 | − kind: "openapi", | |
| 284 | − endpoint: "https://api.github.com", | |
| 285 | − status: "draft", | |
| 286 | − enabled: true, | |
| 287 | − namespace: "github", | |
| 288 | − importAuthPolicy: "reuse_runtime", | |
| 289 | − bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 290 | − sourceHash: null, | |
| 291 | − lastError: null, | |
| 292 | − createdAt: now, | |
| 293 | − updatedAt: now, | |
| 294 | − }); | |
| 295 | − | |
| 296 | − yield* persistence.rows.policies.insert({ | |
| 297 | − id: PolicyIdSchema.make("pol_1"), | |
| 298 | − scopeType: "workspace", | |
| 299 | − organizationId, | |
| 300 | − workspaceId, | |
| 301 | − targetAccountId: null, | |
| 302 | − clientId: null, | |
| 303 | − resourceType: "tool_path", | |
| 304 | − resourcePattern: "source.github.*", | |
| 305 | − matchType: "glob", | |
| 306 | − effect: "allow", | |
| 307 | − approvalMode: "auto", | |
| 308 | − argumentConditionsJson: null, | |
| 309 | − priority: 10, | |
| 310 | − enabled: true, | |
| 311 | − createdAt: now, | |
| 312 | − updatedAt: now, | |
| 313 | − }); | |
| 314 | − | |
| 315 | − const workspace = yield* persistence.rows.workspaces.getById(workspaceId); | |
| 316 | − assertTrue(Option.isSome(workspace)); | |
| 317 | − | |
| 318 | − const sources = yield* persistence.rows.sources.listByWorkspaceId(workspaceId); | |
| 319 | − expect(sources).toHaveLength(1); | |
| 320 | − expect(sources[0]?.name).toBe("Github"); | |
| 321 | − | |
| 322 | − const policies = yield* persistence.rows.policies.listByWorkspaceId(workspaceId); | |
| 323 | − expect(policies).toHaveLength(1); | |
| 324 | − expect(policies[0]?.resourcePattern).toBe("source.github.*"); | |
| 325 | − }), | |
| 326 | − ); | |
| 327 | − | |
| 328 | − it.scoped("upserts organization memberships by org/account", () => | |
| 329 | − Effect.gen(function* () { | |
| 330 | − const persistence = yield* makePersistence; | |
| 331 | − const now = Date.now(); | |
| 332 | − const organizationId = OrganizationIdSchema.make("org_1"); | |
| 333 | − const accountId = AccountIdSchema.make("acc_1"); | |
| 334 | − | |
| 335 | − yield* persistence.rows.organizationMemberships.upsert({ | |
| 336 | − id: OrganizationMemberIdSchema.make("mem_1"), | |
| 337 | − organizationId, | |
| 338 | − accountId, | |
| 339 | − role: "viewer", | |
| 340 | − status: "active", | |
| 341 | − billable: true, | |
| 342 | − invitedByAccountId: null, | |
| 343 | − joinedAt: now, | |
| 344 | − createdAt: now, | |
| 345 | − updatedAt: now, | |
| 346 | − }); | |
| 347 | − | |
| 348 | − yield* persistence.rows.organizationMemberships.upsert({ | |
| 349 | − id: OrganizationMemberIdSchema.make("mem_2"), | |
| 350 | − organizationId, | |
| 351 | − accountId, | |
| 352 | − role: "admin", | |
| 353 | − status: "active", | |
| 354 | − billable: true, | |
| 355 | − invitedByAccountId: null, | |
| 356 | − joinedAt: now, | |
| 357 | − createdAt: now, | |
| 358 | − updatedAt: now + 1, | |
| 359 | − }); | |
| 360 | − | |
| 361 | − const membership = yield* persistence.rows.organizationMemberships.getByOrganizationAndAccount( | |
| 362 | − organizationId, | |
| 363 | − accountId, | |
| 364 | − ); | |
| 365 | − | |
| 366 | − assertTrue(Option.isSome(membership)); | |
| 367 | − if (Option.isSome(membership)) { | |
| 368 | − expect(membership.value.role).toBe("admin"); | |
| 369 | − } | |
| 370 | − }), | |
| 371 | − ); | |
| 372 | − | |
| 373 | − it.scoped("batches large recipe operation replacement inserts", () => | |
| 374 | − Effect.gen(function* () { | |
| 375 | − const persistence = yield* makePersistence; | |
| 376 | − const now = Date.now(); | |
| 377 | − const recipeId = SourceRecipeIdSchema.make("src_recipe_bulk_ops"); | |
| 378 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bulk_ops"); | |
| 379 | − const operationCount = 3_000; | |
| 380 | − | |
| 381 | − yield* persistence.rows.sourceRecipes.upsert({ | |
| 382 | − id: recipeId, | |
| 383 | − kind: "http_api", | |
| 384 | − adapterKey: "openapi", | |
| 385 | − providerKey: "openapi:https://api.cloudflare.com/client/v4", | |
| 386 | − name: "Cloudflare API", | |
| 387 | − summary: null, | |
| 388 | − visibility: "private", | |
| 389 | − latestRevisionId: recipeRevisionId, | |
| 390 | − createdAt: now, | |
| 391 | − updatedAt: now, | |
| 392 | − }); | |
| 393 | − | |
| 394 | − yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | |
| 395 | − id: recipeRevisionId, | |
| 396 | − recipeId, | |
| 397 | − revisionNumber: 1, | |
| 398 | − sourceConfigJson: JSON.stringify({ | |
| 399 | − endpoint: "https://api.cloudflare.com/client/v4", | |
| 400 | − specUrl: | |
| 401 | − "https://raw.githubusercontent.com/cloudflare/api-schemas/refs/heads/main/openapi.json", | |
| 402 | − }), | |
| 403 | − manifestHash: null, | |
| 404 | − manifestJson: null, | |
| 405 | − materializationHash: null, | |
| 406 | − createdAt: now, | |
| 407 | − updatedAt: now, | |
| 408 | − })); | |
| 409 | − | |
| 410 | − yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 411 | − recipeRevisionId, | |
| 412 | − operations: Array.from({ length: operationCount }, (_, index) => ({ | |
| 413 | − id: `src_recipe_op_${index}`, | |
| 414 | − recipeRevisionId, | |
| 415 | − operationKey: `zones.get${index}`, | |
| 416 | − transportKind: "http" as const, | |
| 417 | − toolId: `zones.get${index}`, | |
| 418 | − title: `Get zone ${index}`, | |
| 419 | − description: `Operation ${index}`, | |
| 420 | − operationKind: "read" as const, | |
| 421 | − searchText: `zones get ${index}`, | |
| 422 | − inputSchemaJson: null, | |
| 423 | − outputSchemaJson: null, | |
| 424 | − providerKind: "openapi" as const, | |
| 425 | − providerDataJson: JSON.stringify({ | |
| 426 | − kind: "openapi", | |
| 427 | − toolId: `zones.get${index}`, | |
| 428 | − rawToolId: `getZone${index}`, | |
| 429 | − operationId: `getZone${index}`, | |
| 430 | − group: "zones", | |
| 431 | − leaf: `get${index}`, | |
| 432 | − tags: ["zones"], | |
| 433 | − method: "get", | |
| 434 | − path: `/zones/${index}`, | |
| 435 | − operationHash: `hash_${index}`, | |
| 436 | − invocation: { | |
| 437 | − method: "get", | |
| 438 | − pathTemplate: `/zones/${index}`, | |
| 439 | − parameters: [], | |
| 440 | − requestBody: null, | |
| 441 | − }, | |
| 442 | − }), | |
| 443 | − createdAt: now, | |
| 444 | − updatedAt: now, | |
| 445 | − })), | |
| 446 | − }); | |
| 447 | − | |
| 448 | − const operations = yield* persistence.rows.sourceRecipeOperations.listByRevisionId( | |
| 449 | − recipeRevisionId, | |
| 450 | − ); | |
| 451 | − expect(operations).toHaveLength(operationCount); | |
| 452 | − expect(operations.some((operation) => operation.toolId === "zones.get0")).toBe(true); | |
| 453 | − expect( | |
| 454 | − operations.some( | |
| 455 | − (operation) => operation.toolId === `zones.get${operationCount - 1}`, | |
| 456 | − ), | |
| 457 | − ).toBe(true); | |
| 458 | − }), | |
| 459 | − ); | |
| 460 | − | |
| 461 | − it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () => | |
| 462 | − Effect.gen(function* () { | |
| 463 | − const persistence = yield* makePersistence; | |
| 464 | − const accountId = AccountIdSchema.make("acc_credentials"); | |
| 465 | − const organizationId = OrganizationIdSchema.make("org_credentials"); | |
| 466 | − const workspaceId = WorkspaceIdSchema.make("ws_credentials"); | |
| 467 | − const sourceId = SourceIdSchema.make("src_credentials"); | |
| 468 | − const actorCredentialId = CredentialIdSchema.make("cred_actor_credentials"); | |
| 469 | − const nullCredentialA = CredentialIdSchema.make("cred_null_credentials_a"); | |
| 470 | − const nullCredentialB = CredentialIdSchema.make("cred_null_credentials_b"); | |
| 471 | − const now = Date.now(); | |
| 472 | − | |
| 473 | − yield* seedWorkspaceSourceState({ | |
| 474 | − persistence, | |
| 475 | − accountId, | |
| 476 | − organizationId, | |
| 477 | − workspaceId, | |
| 478 | − sourceId, | |
| 479 | − }); | |
| 480 | − | |
| 481 | − yield* Effect.tryPromise(async () => { | |
| 482 | − await persistence.db.insert(drizzleSchema.authArtifactsTable).values([ | |
| 483 | − { | |
| 484 | − id: nullCredentialA, | |
| 485 | − workspaceId, | |
| 486 | − sourceId, | |
| 487 | − actorAccountId: null, | |
| 488 | − slot: "runtime", | |
| 489 | − artifactKind: "static_bearer", | |
| 490 | − configJson: encodeStaticBearerArtifactConfig({ | |
| 491 | − headerName: "Authorization", | |
| 492 | − prefix: "Bearer ", | |
| 493 | − token: { | |
| 494 | − providerId: "postgres", | |
| 495 | − handle: "sec_null_a", | |
| 496 | − }, | |
| 497 | − }), | |
| 498 | − grantSetJson: null, | |
| 499 | − createdAt: now, | |
| 500 | − updatedAt: now, | |
| 501 | − }, | |
| 502 | − { | |
| 503 | − id: nullCredentialB, | |
| 504 | − workspaceId, | |
| 505 | − sourceId, | |
| 506 | − actorAccountId: null, | |
| 507 | − slot: "runtime", | |
| 508 | − artifactKind: "static_bearer", | |
| 509 | − configJson: encodeStaticBearerArtifactConfig({ | |
| 510 | − headerName: "Authorization", | |
| 511 | − prefix: "Bearer ", | |
| 512 | − token: { | |
| 513 | − providerId: "postgres", | |
| 514 | − handle: "sec_null_b", | |
| 515 | − }, | |
| 516 | − }), | |
| 517 | − grantSetJson: null, | |
| 518 | − createdAt: now + 1, | |
| 519 | − updatedAt: now + 1, | |
| 520 | − }, | |
| 521 | − ]); | |
| 522 | − }).pipe(Effect.orDie); | |
| 523 | − | |
| 524 | − yield* persistence.rows.authArtifacts.upsert({ | |
| 525 | − id: CredentialIdSchema.make("cred_null_credentials_replacement"), | |
| 526 | − workspaceId, | |
| 527 | − sourceId, | |
| 528 | − actorAccountId: null, | |
| 529 | − slot: "runtime", | |
| 530 | − artifactKind: "static_oauth2", | |
| 531 | − configJson: encodeStaticOAuth2ArtifactConfig({ | |
| 532 | − headerName: "X-Auth", | |
| 533 | − prefix: "Token ", | |
| 534 | − accessToken: { | |
| 535 | − providerId: "postgres", | |
| 536 | − handle: "sec_null_replacement", | |
| 537 | − }, | |
| 538 | − refreshToken: { | |
| 539 | − providerId: "postgres", | |
| 540 | − handle: "sec_null_refresh", | |
| 541 | − }, | |
| 542 | − }), | |
| 543 | − grantSetJson: null, | |
| 544 | − createdAt: now + 2, | |
| 545 | − updatedAt: now + 2, | |
| 546 | − }); | |
| 547 | − | |
| 548 | − yield* persistence.rows.authArtifacts.upsert({ | |
| 549 | − id: actorCredentialId, | |
| 550 | − workspaceId, | |
| 551 | − sourceId, | |
| 552 | − actorAccountId: accountId, | |
| 553 | − slot: "runtime", | |
| 554 | − artifactKind: "static_bearer", | |
| 555 | − configJson: encodeStaticBearerArtifactConfig({ | |
| 556 | − headerName: "Authorization", | |
| 557 | − prefix: "Bearer ", | |
| 558 | − token: { | |
| 559 | − providerId: "postgres", | |
| 560 | − handle: "sec_actor", | |
| 561 | − }, | |
| 562 | − }), | |
| 563 | − grantSetJson: null, | |
| 564 | − createdAt: now + 3, | |
| 565 | − updatedAt: now + 3, | |
| 566 | − }); | |
| 567 | − | |
| 568 | − const allAuthArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({ | |
| 569 | − workspaceId, | |
| 570 | − sourceId, | |
| 571 | − }); | |
| 572 | − expect(allAuthArtifacts).toHaveLength(2); | |
| 573 | − const nullActorArtifacts = allAuthArtifacts.filter((artifact) => artifact.actorAccountId === null); | |
| 574 | − expect(nullActorArtifacts).toHaveLength(1); | |
| 575 | − expect(nullActorArtifacts[0]?.id).toBe(nullCredentialA); | |
| 576 | − const nullActorConfig = decodeBuiltInAuthArtifactConfig(nullActorArtifacts[0]!); | |
| 577 | − expect(nullActorConfig?.artifactKind).toBe("static_oauth2"); | |
| 578 | − if (nullActorConfig !== null && nullActorConfig.artifactKind === "static_oauth2") { | |
| 579 | − expect(nullActorConfig.config.headerName).toBe("X-Auth"); | |
| 580 | − expect(nullActorConfig.config.accessToken.handle).toBe("sec_null_replacement"); | |
| 581 | − } | |
| 582 | − expect(allAuthArtifacts.map((artifact) => artifact.id).sort()).toEqual( | |
| 583 | − [actorCredentialId, nullCredentialA].sort(), | |
| 584 | − ); | |
| 585 | − | |
| 586 | − const forActor = yield* persistence.rows.authArtifacts.listByWorkspaceSourceAndActor({ | |
| 587 | − workspaceId, | |
| 588 | − sourceId, | |
| 589 | − actorAccountId: accountId, | |
| 590 | − }); | |
| 591 | − expect(forActor).toHaveLength(2); | |
| 592 | − expect(new Set(forActor.map((artifact) => artifact.id))).toEqual( | |
| 593 | − new Set([actorCredentialId, nullCredentialA]), | |
| 594 | − ); | |
| 595 | − | |
| 596 | − const nullActorOnly = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({ | |
| 597 | − workspaceId, | |
| 598 | − sourceId, | |
| 599 | − actorAccountId: null, | |
| 600 | − slot: "runtime", | |
| 601 | − }); | |
| 602 | − assertTrue(Option.isSome(nullActorOnly)); | |
| 603 | − if (Option.isSome(nullActorOnly)) { | |
| 604 | − expect(nullActorOnly.value.id).toBe(nullCredentialA); | |
| 605 | − } | |
| 606 | − | |
| 607 | − const missingActor = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({ | |
| 608 | − workspaceId, | |
| 609 | − sourceId, | |
| 610 | − actorAccountId: AccountIdSchema.make("acc_missing_credentials"), | |
| 611 | − slot: "runtime", | |
| 612 | − }); | |
| 613 | − expect(Option.isNone(missingActor)).toBe(true); | |
| 614 | − }), | |
| 615 | − ); | |
| 616 | − | |
| 617 | − it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () => | |
| 618 | − Effect.gen(function* () { | |
| 619 | − const persistence = yield* makePersistence; | |
| 620 | − const accountId = AccountIdSchema.make("acc_ws_cleanup"); | |
| 621 | − const organizationId = OrganizationIdSchema.make("org_ws_cleanup"); | |
| 622 | − const workspaceId = WorkspaceIdSchema.make("ws_cleanup"); | |
| 623 | − const sourceId = SourceIdSchema.make("src_ws_cleanup"); | |
| 624 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); | |
| 625 | − | |
| 626 | − const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({ | |
| 627 | − persistence, | |
| 628 | − accountId, | |
| 629 | − organizationId, | |
| 630 | − workspaceId, | |
| 631 | − sourceId, | |
| 632 | − }); | |
| 633 | − yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 634 | − recipeRevisionId, | |
| 635 | − documents: [ | |
| 636 | − { | |
| 637 | − id: "src_recipe_doc_ws_cleanup", | |
| 638 | − recipeRevisionId, | |
| 639 | − documentKind: "openapi", | |
| 640 | − documentKey: "https://api.github.com/openapi.json", | |
| 641 | − contentText: "{\"openapi\":\"3.1.0\"}", | |
| 642 | − contentHash: "doc_hash_ws_cleanup", | |
| 643 | − fetchedAt: null, | |
| 644 | − createdAt: Date.now(), | |
| 645 | − updatedAt: Date.now(), | |
| 646 | − }, | |
| 647 | − ], | |
| 648 | − }); | |
| 649 | − yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | |
| 650 | − recipeRevisionId, | |
| 651 | − bundles: [ | |
| 652 | − { | |
| 653 | − id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_ws_cleanup"), | |
| 654 | − recipeRevisionId, | |
| 655 | − bundleKind: "json_schema_ref_map", | |
| 656 | − refsJson: "{}", | |
| 657 | − contentHash: "bundle_hash_ws_cleanup", | |
| 658 | − createdAt: Date.now(), | |
| 659 | − updatedAt: Date.now(), | |
| 660 | − }, | |
| 661 | − ], | |
| 662 | − }); | |
| 663 | − yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 664 | − recipeRevisionId, | |
| 665 | − operations: [ | |
| 666 | − { | |
| 667 | − id: "src_recipe_op_ws_cleanup", | |
| 668 | − recipeRevisionId, | |
| 669 | − operationKey: "users.getAuthenticated", | |
| 670 | − transportKind: "http", | |
| 671 | − toolId: "users.getAuthenticated", | |
| 672 | − title: "Get authenticated user", | |
| 673 | − description: null, | |
| 674 | − operationKind: "read", | |
| 675 | − searchText: "users get authenticated", | |
| 676 | − inputSchemaJson: null, | |
| 677 | − outputSchemaJson: null, | |
| 678 | − providerKind: "openapi", | |
| 679 | − providerDataJson: JSON.stringify({ | |
| 680 | − kind: "openapi", | |
| 681 | − toolId: "users.getAuthenticated", | |
| 682 | − rawToolId: "users.getAuthenticated", | |
| 683 | − operationId: "users.getAuthenticated", | |
| 684 | − group: "users", | |
| 685 | − leaf: "getAuthenticated", | |
| 686 | − tags: ["users"], | |
| 687 | − method: "get", | |
| 688 | − path: "/user", | |
| 689 | − operationHash: "op_hash_ws_cleanup", | |
| 690 | − invocation: { | |
| 691 | − method: "get", | |
| 692 | − pathTemplate: "/user", | |
| 693 | − pathParameterOrder: [], | |
| 694 | − serverUrl: "https://api.github.com", | |
| 695 | − parameters: [], | |
| 696 | − requestBody: null, | |
| 697 | − }, | |
| 698 | − }), | |
| 699 | − createdAt: Date.now(), | |
| 700 | − updatedAt: Date.now(), | |
| 701 | − }, | |
| 702 | − ], | |
| 703 | − }); | |
| 704 | − | |
| 705 | − const removed = yield* persistence.rows.workspaces.removeById(workspaceId); | |
| 706 | − expect(removed).toBe(true); | |
| 707 | − expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | |
| 708 | − expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 709 | − expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 710 | − expect( | |
| 711 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId), | |
| 712 | − ).toHaveLength(0); | |
| 713 | − expect( | |
| 714 | − yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(recipeRevisionId), | |
| 715 | − ).toHaveLength(0); | |
| 716 | − expect( | |
| 717 | − yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId), | |
| 718 | − ).toHaveLength(0); | |
| 719 | − expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); | |
| 720 | − expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); | |
| 721 | − }), | |
| 722 | − ); | |
| 723 | − | |
| 724 | − it.scoped("deleting a workspace retains shared recipe data still referenced elsewhere", () => | |
| 725 | − Effect.gen(function* () { | |
| 726 | − const persistence = yield* makePersistence; | |
| 727 | − const now = Date.now(); | |
| 728 | − const accountId = AccountIdSchema.make("acc_shared_recipe"); | |
| 729 | − const organizationId = OrganizationIdSchema.make("org_shared_recipe"); | |
| 730 | − const removedWorkspaceId = WorkspaceIdSchema.make("ws_shared_removed"); | |
| 731 | − const remainingWorkspaceId = WorkspaceIdSchema.make("ws_shared_remaining"); | |
| 732 | − const sharedRecipeId = SourceRecipeIdSchema.make("src_recipe_shared_workspace"); | |
| 733 | − const sharedRecipeRevisionId = SourceRecipeRevisionIdSchema.make( | |
| 734 | − "src_recipe_rev_shared_workspace", | |
| 735 | − ); | |
| 736 | − | |
| 737 | − yield* persistence.rows.organizations.insert({ | |
| 738 | − id: organizationId, | |
| 739 | − slug: "org-shared-recipe", | |
| 740 | − name: "Org Shared Recipe", | |
| 741 | − status: "active", | |
| 742 | − createdByAccountId: accountId, | |
| 743 | − createdAt: now, | |
| 744 | − updatedAt: now, | |
| 745 | − }); | |
| 746 | − yield* persistence.rows.workspaces.insert({ | |
| 747 | − id: removedWorkspaceId, | |
| 748 | − organizationId, | |
| 749 | − name: "Workspace Removed", | |
| 750 | − createdByAccountId: accountId, | |
| 751 | − createdAt: now, | |
| 752 | − updatedAt: now, | |
| 753 | − }); | |
| 754 | − yield* persistence.rows.workspaces.insert({ | |
| 755 | − id: remainingWorkspaceId, | |
| 756 | − organizationId, | |
| 757 | − name: "Workspace Remaining", | |
| 758 | − createdByAccountId: accountId, | |
| 759 | − createdAt: now, | |
| 760 | − updatedAt: now, | |
| 761 | − }); | |
| 762 | − yield* persistence.rows.sourceRecipes.upsert({ | |
| 763 | − id: sharedRecipeId, | |
| 764 | − kind: "http_api", | |
| 765 | − adapterKey: "openapi", | |
| 766 | − providerKey: "openapi:https://api.github.com", | |
| 767 | − name: "Github", | |
| 768 | − summary: null, | |
| 769 | − visibility: "workspace", | |
| 770 | − latestRevisionId: sharedRecipeRevisionId, | |
| 771 | − createdAt: now, | |
| 772 | − updatedAt: now, | |
| 773 | − }); | |
| 774 | − yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | |
| 775 | − id: sharedRecipeRevisionId, | |
| 776 | − recipeId: sharedRecipeId, | |
| 777 | − revisionNumber: 1, | |
| 778 | − sourceConfigJson: JSON.stringify({ | |
| 779 | − kind: "openapi", | |
| 780 | − endpoint: "https://api.github.com", | |
| 781 | − specUrl: "https://api.github.com/openapi.json", | |
| 782 | − }), | |
| 783 | − manifestJson: null, | |
| 784 | − manifestHash: null, | |
| 785 | − materializationHash: null, | |
| 786 | − createdAt: now, | |
| 787 | − updatedAt: now, | |
| 788 | − })); | |
| 789 | − yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 790 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 791 | − documents: [ | |
| 792 | − { | |
| 793 | − id: "src_recipe_doc_shared_workspace", | |
| 794 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 795 | − documentKind: "openapi", | |
| 796 | − documentKey: "https://api.github.com/openapi.json", | |
| 797 | − contentText: "{\"openapi\":\"3.1.0\"}", | |
| 798 | − contentHash: "doc_hash_shared_workspace", | |
| 799 | − fetchedAt: null, | |
| 800 | − createdAt: now, | |
| 801 | − updatedAt: now, | |
| 802 | − }, | |
| 803 | − ], | |
| 804 | − }); | |
| 805 | − yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | |
| 806 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 807 | − bundles: [ | |
| 808 | − { | |
| 809 | − id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_shared_workspace"), | |
| 810 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 811 | − bundleKind: "json_schema_ref_map", | |
| 812 | − refsJson: "{}", | |
| 813 | − contentHash: "bundle_hash_shared_workspace", | |
| 814 | − createdAt: now, | |
| 815 | − updatedAt: now, | |
| 816 | − }, | |
| 817 | − ], | |
| 818 | − }); | |
| 819 | − yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 820 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 821 | − operations: [ | |
| 822 | − { | |
| 823 | − id: "src_recipe_op_shared_workspace", | |
| 824 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 825 | − operationKey: "users.getAuthenticated", | |
| 826 | − transportKind: "http", | |
| 827 | − toolId: "users.getAuthenticated", | |
| 828 | − title: "Get authenticated user", | |
| 829 | − description: null, | |
| 830 | − operationKind: "read", | |
| 831 | − searchText: "users get authenticated", | |
| 832 | − inputSchemaJson: null, | |
| 833 | − outputSchemaJson: null, | |
| 834 | − providerKind: "openapi", | |
| 835 | − providerDataJson: JSON.stringify({ | |
| 836 | − kind: "openapi", | |
| 837 | − toolId: "users.getAuthenticated", | |
| 838 | − rawToolId: "users.getAuthenticated", | |
| 839 | − operationId: "users.getAuthenticated", | |
| 840 | − group: "users", | |
| 841 | − leaf: "getAuthenticated", | |
| 842 | − tags: ["users"], | |
| 843 | − method: "get", | |
| 844 | − path: "/user", | |
| 845 | − operationHash: "op_hash_shared_workspace", | |
| 846 | − invocation: { | |
| 847 | − method: "get", | |
| 848 | − pathTemplate: "/user", | |
| 849 | − pathParameterOrder: [], | |
| 850 | − serverUrl: "https://api.github.com", | |
| 851 | − parameters: [], | |
| 852 | − requestBody: null, | |
| 853 | − }, | |
| 854 | − }), | |
| 855 | − createdAt: now, | |
| 856 | − updatedAt: now, | |
| 857 | − }, | |
| 858 | − ], | |
| 859 | − }); | |
| 860 | − yield* persistence.rows.sources.insert({ | |
| 861 | − id: SourceIdSchema.make("src_shared_removed"), | |
| 862 | − workspaceId: removedWorkspaceId, | |
| 863 | − recipeId: sharedRecipeId, | |
| 864 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 865 | − name: "Github Removed", | |
| 866 | − kind: "openapi", | |
| 867 | − endpoint: "https://api.github.com", | |
| 868 | − status: "connected", | |
| 869 | − enabled: true, | |
| 870 | − namespace: "github", | |
| 871 | − importAuthPolicy: "reuse_runtime", | |
| 872 | − bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 873 | − sourceHash: null, | |
| 874 | − lastError: null, | |
| 875 | − createdAt: now, | |
| 876 | − updatedAt: now, | |
| 877 | − }); | |
| 878 | − yield* persistence.rows.sources.insert({ | |
| 879 | − id: SourceIdSchema.make("src_shared_remaining"), | |
| 880 | − workspaceId: remainingWorkspaceId, | |
| 881 | − recipeId: sharedRecipeId, | |
| 882 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 883 | − name: "Github Remaining", | |
| 884 | − kind: "openapi", | |
| 885 | − endpoint: "https://api.github.com", | |
| 886 | − status: "connected", | |
| 887 | − enabled: true, | |
| 888 | − namespace: "github", | |
| 889 | − importAuthPolicy: "reuse_runtime", | |
| 890 | − bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 891 | − sourceHash: null, | |
| 892 | − lastError: null, | |
| 893 | − createdAt: now, | |
| 894 | − updatedAt: now, | |
| 895 | − }); | |
| 896 | − | |
| 897 | − const removed = yield* persistence.rows.workspaces.removeById(removedWorkspaceId); | |
| 898 | − expect(removed).toBe(true); | |
| 899 | − expect(Option.isNone(yield* persistence.rows.workspaces.getById(removedWorkspaceId))).toBe(true); | |
| 900 | − expect(Option.isSome(yield* persistence.rows.workspaces.getById(remainingWorkspaceId))).toBe(true); | |
| 901 | − expect( | |
| 902 | − yield* persistence.rows.sources.listByWorkspaceId(remainingWorkspaceId), | |
| 903 | − ).toHaveLength(1); | |
| 904 | − expect( | |
| 905 | − Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId)), | |
| 906 | − ).toBe(true); | |
| 907 | − expect( | |
| 908 | − Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)), | |
| 909 | − ).toBe(true); | |
| 910 | − expect( | |
| 911 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId), | |
| 912 | − ).toHaveLength(1); | |
| 913 | − expect( | |
| 914 | − yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(sharedRecipeRevisionId), | |
| 915 | − ).toHaveLength(1); | |
| 916 | − expect( | |
| 917 | − yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId), | |
| 918 | − ).toHaveLength(1); | |
| 919 | − }), | |
| 920 | − ); | |
| 921 | − | |
| 922 | − it.scoped("deleting an organization removes workspace credential state and postgres secrets", () => | |
| 923 | − Effect.gen(function* () { | |
| 924 | − const persistence = yield* makePersistence; | |
| 925 | − const accountId = AccountIdSchema.make("acc_org_cleanup"); | |
| 926 | − const organizationId = OrganizationIdSchema.make("org_cleanup"); | |
| 927 | − const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup"); | |
| 928 | − const sourceId = SourceIdSchema.make("src_org_cleanup"); | |
| 929 | − | |
| 930 | − const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({ | |
| 931 | − persistence, | |
| 932 | − accountId, | |
| 933 | − organizationId, | |
| 934 | − workspaceId, | |
| 935 | − sourceId, | |
| 936 | − }); | |
| 937 | − | |
| 938 | − const removed = yield* persistence.rows.organizations.removeTreeById(organizationId); | |
| 939 | − expect(removed).toBe(true); | |
| 940 | − expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | |
| 941 | − expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 942 | − expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 943 | − expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); | |
| 944 | − expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); | |
| 945 | − }), | |
| 946 | − ); | |
| 947 | −}); | |
| @@ -11,10 +11,6 @@ import { · 10 unmodified lines | ||
| 11 | 11 | createCodeMigrationsRepo, |
| 12 | 12 | createExecutionInteractionsRepo, |
| 13 | 13 | createExecutionsRepo, |
| 14 | − createLocalInstallationsRepo, | |
| 15 | − createOrganizationMembershipsRepo, | |
| 16 | − createOrganizationsRepo, | |
| 17 | − createPoliciesRepo, | |
| 18 | 14 | createSecretMaterialsRepo, |
| 19 | 15 | createSourceAuthSessionsRepo, |
| 20 | 16 | createSourceOauthClientsRepo, |
| @@ -24,7 +20,6 @@ import { · 3 unmodified lines | ||
| 24 | 20 | createSourceRecipeRevisionsRepo, |
| 25 | 21 | createSourceRecipesRepo, |
| 26 | 22 | createSourcesRepo, |
| 27 | − createWorkspacesRepo, | |
| 28 | 23 | } from "./repos"; |
| 29 | 24 | import { drizzleSchema, tableNames, type DrizzleTables } from "./schema"; |
| 30 | 25 | import { |
| @@ -55,9 +50,6 @@ export { · 24 unmodified lines | ||
| 55 | 50 | |
| 56 | 51 | const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema) => ({ |
| 57 | 52 | accounts: createAccountsRepo(client, tables), |
| 58 | − organizations: createOrganizationsRepo(client, tables), | |
| 59 | − organizationMemberships: createOrganizationMembershipsRepo(client, tables), | |
| 60 | − workspaces: createWorkspacesRepo(client, tables), | |
| 61 | 53 | sources: createSourcesRepo(client, tables), |
| 62 | 54 | sourceRecipes: createSourceRecipesRepo(client, tables), |
| 63 | 55 | sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), |
| @@ -70,8 +62,6 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 6 unmodified lines | ||
| 70 | 62 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), |
| 71 | 63 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 72 | 64 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| 73 | − policies: createPoliciesRepo(client, tables), | |
| 74 | − localInstallations: createLocalInstallationsRepo(client, tables), | |
| 75 | 65 | executions: createExecutionsRepo(client, tables), |
| 76 | 66 | executionInteractions: createExecutionInteractionsRepo(client, tables), |
| 77 | 67 | }); |
| @@ -7,7 +7,6 @@ import { describe, expect, it } from "@effect/vitest"; · 6 unmodified lines | ||
| 7 | 7 | import { |
| 8 | 8 | AccountIdSchema, |
| 9 | 9 | McpSourceAuthSessionDataJsonSchema, |
| 10 | − OrganizationIdSchema, | |
| 11 | 10 | SourceAuthSessionIdSchema, |
| 12 | 11 | decodeAuthLeasePlacementTemplates, |
| 13 | 12 | SourceIdSchema, |
| @@ -27,7 +26,6 @@ import { · 13 unmodified lines | ||
| 27 | 26 | type SqlControlPlanePersistence, |
| 28 | 27 | } from "./index"; |
| 29 | 28 | import { runCodeMigrations } from "./code-migrations"; |
| 30 | −import { loadSourceById } from "../runtime/source-store"; | |
| 31 | 29 | |
| 32 | 30 | const openApiBindingConfigJson = (specUrl: string): string => |
| 33 | 31 | JSON.stringify({ |
| @@ -124,29 +122,11 @@ const seedMigratedSourceRecipe = (input: { · 90 unmodified lines | ||
| 124 | 122 | Effect.gen(function* () { |
| 125 | 123 | const now = Date.now(); |
| 126 | 124 | const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); |
| 127 | − const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`); | |
| 128 | 125 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); |
| 129 | 126 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( |
| 130 | 127 | `src_recipe_rev_${input.sourceId}`, |
| 131 | 128 | ); |
| 132 | 129 | |
| 133 | − yield* input.persistence.rows.organizations.insert({ | |
| 134 | − id: organizationId, | |
| 135 | − slug: `org-${input.sourceId}`, | |
| 136 | − name: `Org ${input.sourceId}`, | |
| 137 | − status: "active", | |
| 138 | − createdByAccountId: accountId, | |
| 139 | − createdAt: now, | |
| 140 | − updatedAt: now, | |
| 141 | − }); | |
| 142 | − yield* input.persistence.rows.workspaces.insert({ | |
| 143 | − id: input.workspaceId, | |
| 144 | − organizationId, | |
| 145 | − name: `Workspace ${input.sourceId}`, | |
| 146 | − createdByAccountId: accountId, | |
| 147 | − createdAt: now, | |
| 148 | − updatedAt: now, | |
| 149 | − }); | |
| 150 | 130 | yield* input.persistence.rows.sourceRecipes.upsert({ |
| 151 | 131 | id: recipeId, |
| 152 | 132 | kind: "http_api", |
| @@ -318,6 +298,7 @@ describe("code-migrations", () => { · 165 unmodified lines | ||
| 318 | 298 | )).length, |
| 319 | 299 | ).toBeGreaterThan(0); |
| 320 | 300 | }), |
| 301 | + 60_000, | |
| 321 | 302 | ); |
| 322 | 303 | |
| 323 | 304 | it.scoped("repairs migrated GraphQL recipes from stored documents", () => |
| @@ -376,6 +357,7 @@ describe("code-migrations", () => { · 52 unmodified lines | ||
| 376 | 357 | )).length, |
| 377 | 358 | ).toBeGreaterThan(0); |
| 378 | 359 | }), |
| 360 | + 60_000, | |
| 379 | 361 | ); |
| 380 | 362 | |
| 381 | 363 | it.scoped("repairs legacy MCP source auth session payloads", () => |
| @@ -450,7 +432,6 @@ describe("code-migrations", () => { · 68 unmodified lines | ||
| 450 | 432 | ); |
| 451 | 433 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_source_bindings"); |
| 452 | 434 | const accountId = AccountIdSchema.make("acc_legacy_source_bindings"); |
| 453 | − const organizationId = OrganizationIdSchema.make("org_legacy_source_bindings"); | |
| 454 | 435 | const now = Date.now(); |
| 455 | 436 | let legacyPersistence: SqlControlPlanePersistence | null = null; |
| 456 | 437 | let upgradedPersistence: SqlControlPlanePersistence | null = null; |
| @@ -464,24 +445,6 @@ describe("code-migrations", () => { · 7 unmodified lines | ||
| 464 | 445 | }), |
| 465 | 446 | ); |
| 466 | 447 | |
| 467 | − await Effect.runPromise(legacyPersistence.rows.organizations.insert({ | |
| 468 | − id: organizationId, | |
| 469 | − slug: "org-legacy-source-bindings", | |
| 470 | − name: "Legacy Org", | |
| 471 | − status: "active", | |
| 472 | − createdByAccountId: accountId, | |
| 473 | − createdAt: now, | |
| 474 | − updatedAt: now, | |
| 475 | − })); | |
| 476 | − await Effect.runPromise(legacyPersistence.rows.workspaces.insert({ | |
| 477 | − id: workspaceId, | |
| 478 | − organizationId, | |
| 479 | − name: "Legacy Workspace", | |
| 480 | − createdByAccountId: accountId, | |
| 481 | − createdAt: now, | |
| 482 | − updatedAt: now, | |
| 483 | − })); | |
| 484 | − | |
| 485 | 448 | const seedRecipe = async (input: { |
| 486 | 449 | sourceId: string; |
| 487 | 450 | kind: "openapi" | "graphql" | "mcp"; |
| @@ -693,31 +656,40 @@ describe("code-migrations", () => { · 205 unmodified lines | ||
| 693 | 656 | }), |
| 694 | 657 | ); |
| 695 | 658 | |
| 696 | − const openApiSource = await Effect.runPromise(loadSourceById( | |
| 697 | − upgradedPersistence.rows, | |
| 698 | − { | |
| 659 | + const openApiSource = await Effect.runPromise( | |
| 660 | + upgradedPersistence.rows.sources.getByWorkspaceAndId( | |
| 699 | 661 | workspaceId, |
| 700 | − sourceId: SourceIdSchema.make("src_legacy_openapi"), | |
| 701 | − }, | |
| 702 | − )); | |
| 703 | − expect(openApiSource.binding).toEqual({ | |
| 704 | − specUrl: "https://api.example.com/openapi.json", | |
| 705 | − defaultHeaders: { | |
| 706 | − accept: "application/json", | |
| 662 | + SourceIdSchema.make("src_legacy_openapi"), | |
| 663 | + ), | |
| 664 | + ); | |
| 665 | + expect( | |
| 666 | + JSON.parse(Option.getOrThrow(openApiSource).bindingConfigJson), | |
| 667 | + ).toEqual({ | |
| 668 | + adapterKey: "openapi", | |
| 669 | + version: 1, | |
| 670 | + payload: { | |
| 671 | + specUrl: "https://api.example.com/openapi.json", | |
| 672 | + defaultHeaders: { | |
| 673 | + accept: "application/json", | |
| 674 | + }, | |
| 707 | 675 | }, |
| 708 | 676 | }); |
| 709 | − expect(openApiSource.bindingVersion).toBe(1); | |
| 710 | 677 | |
| 711 | − const graphqlSource = await Effect.runPromise(loadSourceById( | |
| 712 | − upgradedPersistence.rows, | |
| 713 | − { | |
| 678 | + const graphqlSource = await Effect.runPromise( | |
| 679 | + upgradedPersistence.rows.sources.getByWorkspaceAndId( | |
| 714 | 680 | workspaceId, |
| 715 | − sourceId: SourceIdSchema.make("src_legacy_graphql"), | |
| 716 | − }, | |
| 717 | − )); | |
| 718 | − expect(graphqlSource.binding).toEqual({ | |
| 719 | − defaultHeaders: { | |
| 720 | − accept: "application/json", | |
| 681 | + SourceIdSchema.make("src_legacy_graphql"), | |
| 682 | + ), | |
| 683 | + ); | |
| 684 | + expect( | |
| 685 | + JSON.parse(Option.getOrThrow(graphqlSource).bindingConfigJson), | |
| 686 | + ).toEqual({ | |
| 687 | + adapterKey: "graphql", | |
| 688 | + version: 1, | |
| 689 | + payload: { | |
| 690 | + defaultHeaders: { | |
| 691 | + accept: "application/json", | |
| 692 | + }, | |
| 721 | 693 | }, |
| 722 | 694 | }); |
| 723 | 695 | |
| @@ -726,20 +698,25 @@ describe("code-migrations", () => { · 2 unmodified lines | ||
| 726 | 698 | ); |
| 727 | 699 | expect(Option.getOrNull(graphqlRecipe)?.adapterKey).toBe("graphql"); |
| 728 | 700 | |
| 729 | − const mcpSource = await Effect.runPromise(loadSourceById( | |
| 730 | − upgradedPersistence.rows, | |
| 731 | − { | |
| 701 | + const mcpSource = await Effect.runPromise( | |
| 702 | + upgradedPersistence.rows.sources.getByWorkspaceAndId( | |
| 732 | 703 | workspaceId, |
| 733 | − sourceId: SourceIdSchema.make("src_legacy_mcp"), | |
| 734 | − }, | |
| 735 | − )); | |
| 736 | − expect(mcpSource.binding).toEqual({ | |
| 737 | − transport: "streamable-http", | |
| 738 | − queryParams: { | |
| 739 | − tenant: "acme", | |
| 740 | − }, | |
| 741 | − headers: { | |
| 742 | − "x-tenant": "acme", | |
| 704 | + SourceIdSchema.make("src_legacy_mcp"), | |
| 705 | + ), | |
| 706 | + ); | |
| 707 | + expect( | |
| 708 | + JSON.parse(Option.getOrThrow(mcpSource).bindingConfigJson), | |
| 709 | + ).toEqual({ | |
| 710 | + adapterKey: "mcp", | |
| 711 | + version: 1, | |
| 712 | + payload: { | |
| 713 | + transport: "streamable-http", | |
| 714 | + queryParams: { | |
| 715 | + tenant: "acme", | |
| 716 | + }, | |
| 717 | + headers: { | |
| 718 | + "x-tenant": "acme", | |
| 719 | + }, | |
| 743 | 720 | }, |
| 744 | 721 | }); |
| 745 | 722 | |
| @@ -762,7 +739,6 @@ describe("code-migrations", () => { · 16 unmodified lines | ||
| 762 | 739 | ); |
| 763 | 740 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_auth_lease"); |
| 764 | 741 | const accountId = AccountIdSchema.make("acc_legacy_auth_lease"); |
| 765 | − const organizationId = OrganizationIdSchema.make("org_legacy_auth_lease"); | |
| 766 | 742 | const sourceId = SourceIdSchema.make("src_legacy_auth_lease"); |
| 767 | 743 | const recipeId = SourceRecipeIdSchema.make("src_recipe_legacy_auth_lease"); |
| 768 | 744 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( |
| @@ -782,23 +758,6 @@ describe("code-migrations", () => { · 13 unmodified lines | ||
| 782 | 758 | }), |
| 783 | 759 | ); |
| 784 | 760 | |
| 785 | − await Effect.runPromise(legacyPersistence.rows.organizations.insert({ | |
| 786 | − id: organizationId, | |
| 787 | − slug: "org-legacy-auth-lease", | |
| 788 | − name: "Legacy Org", | |
| 789 | − status: "active", | |
| 790 | − createdByAccountId: accountId, | |
| 791 | − createdAt: now, | |
| 792 | − updatedAt: now, | |
| 793 | − })); | |
| 794 | − await Effect.runPromise(legacyPersistence.rows.workspaces.insert({ | |
| 795 | − id: workspaceId, | |
| 796 | − organizationId, | |
| 797 | − name: "Legacy Workspace", | |
| 798 | − createdByAccountId: accountId, | |
| 799 | − createdAt: now, | |
| 800 | − updatedAt: now, | |
| 801 | − })); | |
| 802 | 761 | await Effect.runPromise(legacyPersistence.rows.sourceRecipes.upsert({ |
| 803 | 762 | id: recipeId, |
| 804 | 763 | kind: "http_api", |
| @@ -823,24 +782,43 @@ describe("code-migrations", () => { · 18 unmodified lines | ||
| 823 | 782 | createdAt: now, |
| 824 | 783 | updatedAt: now, |
| 825 | 784 | }))); |
| 826 | − await Effect.runPromise(legacyPersistence.rows.sources.insert({ | |
| 827 | − id: sourceId, | |
| 828 | − workspaceId, | |
| 829 | − recipeId, | |
| 830 | − recipeRevisionId, | |
| 831 | − name: "Legacy OpenAPI", | |
| 832 | − kind: "openapi", | |
| 833 | − endpoint: "https://api.example.com", | |
| 834 | − status: "connected", | |
| 835 | − enabled: true, | |
| 836 | − namespace: "legacy.openapi", | |
| 837 | − importAuthPolicy: "reuse_runtime", | |
| 838 | − bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"), | |
| 839 | − sourceHash: null, | |
| 840 | − lastError: null, | |
| 841 | − createdAt: now, | |
| 842 | − updatedAt: now, | |
| 843 | − })); | |
| 785 | + await legacyPersistence.db.execute(sql` | |
| 786 | + INSERT INTO "sources" ( | |
| 787 | + "source_id", | |
| 788 | + "workspace_id", | |
| 789 | + "recipe_id", | |
| 790 | + "recipe_revision_id", | |
| 791 | + "name", | |
| 792 | + "kind", | |
| 793 | + "endpoint", | |
| 794 | + "status", | |
| 795 | + "enabled", | |
| 796 | + "namespace", | |
| 797 | + "import_auth_policy", | |
| 798 | + "binding_config_json", | |
| 799 | + "source_hash", | |
| 800 | + "last_error", | |
| 801 | + "created_at", | |
| 802 | + "updated_at" | |
| 803 | + ) VALUES ( | |
| 804 | + ${sourceId}, | |
| 805 | + ${workspaceId}, | |
| 806 | + ${recipeId}, | |
| 807 | + ${recipeRevisionId}, | |
| 808 | + ${"Legacy OpenAPI"}, | |
| 809 | + ${"openapi"}, | |
| 810 | + ${"https://api.example.com"}, | |
| 811 | + ${"connected"}, | |
| 812 | + ${true}, | |
| 813 | + ${"legacy.openapi"}, | |
| 814 | + ${"reuse_runtime"}, | |
| 815 | + ${openApiBindingConfigJson("https://api.example.com/openapi.json")}, | |
| 816 | + NULL, | |
| 817 | + NULL, | |
| 818 | + ${now}, | |
| 819 | + ${now} | |
| 820 | + ) | |
| 821 | + `); | |
| 844 | 822 | await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({ |
| 845 | 823 | id: authArtifactId as any, |
| 846 | 824 | workspaceId, |
| @@ -1,7 +1,4 @@ | ||
| 1 | 1 | export { createAccountsRepo } from "./accounts-repo"; |
| 2 | −export { createOrganizationsRepo } from "./organizations-repo"; | |
| 3 | −export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; | |
| 4 | −export { createWorkspacesRepo } from "./workspaces-repo"; | |
| 5 | 2 | export { createSourcesRepo } from "./sources-repo"; |
| 6 | 3 | export { createSourceRecipesRepo } from "./source-recipes-repo"; |
| 7 | 4 | export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; |
| @@ -14,7 +11,5 @@ export { createAuthLeasesRepo } from "./auth-leases-repo"; · 6 unmodified lines | ||
| 14 | 11 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; |
| 15 | 12 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 16 | 13 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| 17 | −export { createPoliciesRepo } from "./policies-repo"; | |
| 18 | −export { createLocalInstallationsRepo } from "./local-installations-repo"; | |
| 19 | 14 | export { createExecutionsRepo } from "./executions-repo"; |
| 20 | 15 | export { createExecutionInteractionsRepo } from "./execution-interactions-repo"; |
| @@ -1,48 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type LocalInstallation, | |
| 3 | − LocalInstallationSchema, | |
| 4 | −} from "#schema"; | |
| 5 | −import * as Option from "effect/Option"; | |
| 6 | −import { Schema } from "effect"; | |
| 7 | −import { eq } from "drizzle-orm"; | |
| 8 | − | |
| 9 | −import type { DrizzleClient } from "../client"; | |
| 10 | −import type { DrizzleTables } from "../schema"; | |
| 11 | −import { firstOption } from "./shared"; | |
| 12 | − | |
| 13 | −const decodeLocalInstallation = Schema.decodeUnknownSync(LocalInstallationSchema); | |
| 14 | − | |
| 15 | −export const createLocalInstallationsRepo = ( | |
| 16 | − client: DrizzleClient, | |
| 17 | − tables: DrizzleTables, | |
| 18 | −) => ({ | |
| 19 | − getById: (installationId: LocalInstallation["id"]) => | |
| 20 | − client.use("rows.local_installations.get_by_id", async (db) => { | |
| 21 | − const rows = await db | |
| 22 | − .select() | |
| 23 | − .from(tables.localInstallationsTable) | |
| 24 | − .where(eq(tables.localInstallationsTable.id, installationId)) | |
| 25 | − .limit(1); | |
| 26 | − | |
| 27 | − const row = firstOption(rows); | |
| 28 | − return Option.isSome(row) | |
| 29 | − ? Option.some(decodeLocalInstallation(row.value)) | |
| 30 | − : Option.none<LocalInstallation>(); | |
| 31 | − }), | |
| 32 | − | |
| 33 | − upsert: (installation: LocalInstallation) => | |
| 34 | − client.use("rows.local_installations.upsert", async (db) => { | |
| 35 | − await db | |
| 36 | − .insert(tables.localInstallationsTable) | |
| 37 | − .values(installation) | |
| 38 | − .onConflictDoUpdate({ | |
| 39 | − target: tables.localInstallationsTable.id, | |
| 40 | − set: { | |
| 41 | − accountId: installation.accountId, | |
| 42 | − organizationId: installation.organizationId, | |
| 43 | − workspaceId: installation.workspaceId, | |
| 44 | − updatedAt: installation.updatedAt, | |
| 45 | − }, | |
| 46 | − }); | |
| 47 | − }), | |
| 48 | −}); | |
| @@ -1,110 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type OrganizationMembership, | |
| 3 | − OrganizationMembershipSchema, | |
| 4 | −} from "#schema"; | |
| 5 | −import * as Option from "effect/Option"; | |
| 6 | −import { Schema } from "effect"; | |
| 7 | −import { and, asc, eq } from "drizzle-orm"; | |
| 8 | − | |
| 9 | −import type { DrizzleClient } from "../client"; | |
| 10 | −import type { DrizzleTables } from "../schema"; | |
| 11 | −import { firstOption, withoutCreatedAt } from "./shared"; | |
| 12 | − | |
| 13 | −const decodeOrganizationMembership = Schema.decodeUnknownSync( | |
| 14 | − OrganizationMembershipSchema, | |
| 15 | −); | |
| 16 | − | |
| 17 | −export const createOrganizationMembershipsRepo = ( | |
| 18 | − client: DrizzleClient, | |
| 19 | − tables: DrizzleTables, | |
| 20 | −) => ({ | |
| 21 | − listByOrganizationId: ( | |
| 22 | − organizationId: OrganizationMembership["organizationId"], | |
| 23 | − ) => | |
| 24 | − client.use("rows.organization_memberships.list_by_organization", async (db) => { | |
| 25 | − const rows = await db | |
| 26 | − .select() | |
| 27 | − .from(tables.organizationMembershipsTable) | |
| 28 | − .where(eq(tables.organizationMembershipsTable.organizationId, organizationId)) | |
| 29 | − .orderBy( | |
| 30 | − asc(tables.organizationMembershipsTable.updatedAt), | |
| 31 | − asc(tables.organizationMembershipsTable.id), | |
| 32 | − ); | |
| 33 | − | |
| 34 | − return rows.map((row) => decodeOrganizationMembership(row)); | |
| 35 | − }), | |
| 36 | − | |
| 37 | − listByAccountId: (accountId: OrganizationMembership["accountId"]) => | |
| 38 | − client.use("rows.organization_memberships.list_by_account", async (db) => { | |
| 39 | − const rows = await db | |
| 40 | − .select() | |
| 41 | − .from(tables.organizationMembershipsTable) | |
| 42 | − .where(eq(tables.organizationMembershipsTable.accountId, accountId)) | |
| 43 | − .orderBy( | |
| 44 | − asc(tables.organizationMembershipsTable.updatedAt), | |
| 45 | − asc(tables.organizationMembershipsTable.id), | |
| 46 | − ); | |
| 47 | − | |
| 48 | − return rows.map((row) => decodeOrganizationMembership(row)); | |
| 49 | − }), | |
| 50 | − | |
| 51 | − getByOrganizationAndAccount: ( | |
| 52 | − organizationId: OrganizationMembership["organizationId"], | |
| 53 | − accountId: OrganizationMembership["accountId"], | |
| 54 | − ) => | |
| 55 | − client.use( | |
| 56 | − "rows.organization_memberships.get_by_organization_and_account", | |
| 57 | − async (db) => { | |
| 58 | − const rows = await db | |
| 59 | − .select() | |
| 60 | − .from(tables.organizationMembershipsTable) | |
| 61 | − .where( | |
| 62 | − and( | |
| 63 | − eq(tables.organizationMembershipsTable.organizationId, organizationId), | |
| 64 | − eq(tables.organizationMembershipsTable.accountId, accountId), | |
| 65 | − ), | |
| 66 | − ) | |
| 67 | − .limit(1); | |
| 68 | − | |
| 69 | − const row = firstOption(rows); | |
| 70 | − return Option.isSome(row) | |
| 71 | − ? Option.some(decodeOrganizationMembership(row.value)) | |
| 72 | − : Option.none<OrganizationMembership>(); | |
| 73 | − }, | |
| 74 | − ), | |
| 75 | − | |
| 76 | − upsert: (membership: OrganizationMembership) => | |
| 77 | − client.use("rows.organization_memberships.upsert", async (db) => { | |
| 78 | − await db | |
| 79 | − .insert(tables.organizationMembershipsTable) | |
| 80 | − .values(membership) | |
| 81 | − .onConflictDoUpdate({ | |
| 82 | − target: [ | |
| 83 | − tables.organizationMembershipsTable.organizationId, | |
| 84 | − tables.organizationMembershipsTable.accountId, | |
| 85 | − ], | |
| 86 | − set: { | |
| 87 | − ...withoutCreatedAt(membership), | |
| 88 | − id: membership.id, | |
| 89 | − }, | |
| 90 | − }); | |
| 91 | − }), | |
| 92 | − | |
| 93 | − removeByOrganizationAndAccount: ( | |
| 94 | − organizationId: OrganizationMembership["organizationId"], | |
| 95 | − accountId: OrganizationMembership["accountId"], | |
| 96 | − ) => | |
| 97 | − client.use("rows.organization_memberships.remove", async (db) => { | |
| 98 | − const deleted = await db | |
| 99 | − .delete(tables.organizationMembershipsTable) | |
| 100 | − .where( | |
| 101 | − and( | |
| 102 | − eq(tables.organizationMembershipsTable.organizationId, organizationId), | |
| 103 | − eq(tables.organizationMembershipsTable.accountId, accountId), | |
| 104 | − ), | |
| 105 | − ) | |
| 106 | − .returning(); | |
| 107 | − | |
| 108 | − return deleted.length > 0; | |
| 109 | − }), | |
| 110 | −}); | |
| @@ -1,233 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type Organization, | |
| 3 | − type OrganizationMembership, | |
| 4 | − OrganizationMembershipSchema, | |
| 5 | − OrganizationSchema, | |
| 6 | −} from "#schema"; | |
| 7 | −import * as Option from "effect/Option"; | |
| 8 | −import { Schema } from "effect"; | |
| 9 | −import { asc, eq, inArray } from "drizzle-orm"; | |
| 10 | − | |
| 11 | −import type { DrizzleClient } from "../client"; | |
| 12 | −import type { DrizzleTables } from "../schema"; | |
| 13 | −import { | |
| 14 | − cleanupOrphanedSourceRecipes, | |
| 15 | − firstOption, | |
| 16 | − postgresSecretHandlesFromAuthArtifacts, | |
| 17 | − withoutCreatedAt, | |
| 18 | −} from "./shared"; | |
| 19 | − | |
| 20 | −const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema); | |
| 21 | −const decodeOrganizationMembership = Schema.decodeUnknownSync( | |
| 22 | − OrganizationMembershipSchema, | |
| 23 | −); | |
| 24 | − | |
| 25 | −export const createOrganizationsRepo = ( | |
| 26 | − client: DrizzleClient, | |
| 27 | − tables: DrizzleTables, | |
| 28 | −) => ({ | |
| 29 | − list: () => | |
| 30 | − client.use("rows.organizations.list", async (db) => { | |
| 31 | − const rows = await db | |
| 32 | − .select() | |
| 33 | − .from(tables.organizationsTable) | |
| 34 | − .orderBy( | |
| 35 | − asc(tables.organizationsTable.updatedAt), | |
| 36 | − asc(tables.organizationsTable.id), | |
| 37 | − ); | |
| 38 | − | |
| 39 | − return rows.map((row) => decodeOrganization(row)); | |
| 40 | − }), | |
| 41 | − | |
| 42 | − getById: (organizationId: Organization["id"]) => | |
| 43 | − client.use("rows.organizations.get_by_id", async (db) => { | |
| 44 | − const rows = await db | |
| 45 | − .select() | |
| 46 | − .from(tables.organizationsTable) | |
| 47 | − .where(eq(tables.organizationsTable.id, organizationId)) | |
| 48 | − .limit(1); | |
| 49 | − | |
| 50 | − const row = firstOption(rows); | |
| 51 | − return Option.isSome(row) | |
| 52 | − ? Option.some(decodeOrganization(row.value)) | |
| 53 | − : Option.none<Organization>(); | |
| 54 | − }), | |
| 55 | − | |
| 56 | − getBySlug: (slug: Organization["slug"]) => | |
| 57 | − client.use("rows.organizations.get_by_slug", async (db) => { | |
| 58 | − const rows = await db | |
| 59 | − .select() | |
| 60 | − .from(tables.organizationsTable) | |
| 61 | − .where(eq(tables.organizationsTable.slug, slug)) | |
| 62 | − .limit(1); | |
| 63 | − | |
| 64 | − const row = firstOption(rows); | |
| 65 | − return Option.isSome(row) | |
| 66 | − ? Option.some(decodeOrganization(row.value)) | |
| 67 | − : Option.none<Organization>(); | |
| 68 | − }), | |
| 69 | − | |
| 70 | − insert: (organization: Organization) => | |
| 71 | − client.use("rows.organizations.insert", async (db) => { | |
| 72 | − await db.insert(tables.organizationsTable).values(organization); | |
| 73 | − }), | |
| 74 | − | |
| 75 | − insertWithOwnerMembership: ( | |
| 76 | − organization: Organization, | |
| 77 | − ownerMembership: OrganizationMembership | null, | |
| 78 | − ) => | |
| 79 | − client.useTx("rows.organizations.insert_with_owner_membership", async (tx) => { | |
| 80 | − await tx.insert(tables.organizationsTable).values(organization); | |
| 81 | − | |
| 82 | − if (ownerMembership !== null) { | |
| 83 | − await tx | |
| 84 | − .insert(tables.organizationMembershipsTable) | |
| 85 | − .values(decodeOrganizationMembership(ownerMembership)) | |
| 86 | − .onConflictDoUpdate({ | |
| 87 | − target: [ | |
| 88 | − tables.organizationMembershipsTable.organizationId, | |
| 89 | − tables.organizationMembershipsTable.accountId, | |
| 90 | − ], | |
| 91 | − set: { | |
| 92 | − ...withoutCreatedAt(ownerMembership), | |
| 93 | − id: ownerMembership.id, | |
| 94 | − }, | |
| 95 | − }); | |
| 96 | − } | |
| 97 | − }), | |
| 98 | − | |
| 99 | − update: ( | |
| 100 | − organizationId: Organization["id"], | |
| 101 | − patch: Partial<Omit<Organization, "id" | "createdAt">>, | |
| 102 | − ) => | |
| 103 | − client.use("rows.organizations.update", async (db) => { | |
| 104 | − const rows = await db | |
| 105 | − .update(tables.organizationsTable) | |
| 106 | − .set(patch) | |
| 107 | − .where(eq(tables.organizationsTable.id, organizationId)) | |
| 108 | − .returning(); | |
| 109 | − | |
| 110 | − const row = firstOption(rows); | |
| 111 | − return Option.isSome(row) | |
| 112 | − ? Option.some(decodeOrganization(row.value)) | |
| 113 | − : Option.none<Organization>(); | |
| 114 | − }), | |
| 115 | − | |
| 116 | − removeById: (organizationId: Organization["id"]) => | |
| 117 | − client.use("rows.organizations.remove", async (db) => { | |
| 118 | − const deleted = await db | |
| 119 | − .delete(tables.organizationsTable) | |
| 120 | − .where(eq(tables.organizationsTable.id, organizationId)) | |
| 121 | − .returning(); | |
| 122 | − | |
| 123 | − return deleted.length > 0; | |
| 124 | − }), | |
| 125 | − | |
| 126 | − removeTreeById: (organizationId: Organization["id"]) => | |
| 127 | − client.useTx("rows.organizations.remove_tree", async (tx) => { | |
| 128 | − const workspaces = await tx | |
| 129 | − .select({ id: tables.workspacesTable.id }) | |
| 130 | − .from(tables.workspacesTable) | |
| 131 | − .where(eq(tables.workspacesTable.organizationId, organizationId)); | |
| 132 | − | |
| 133 | − const workspaceIds = workspaces.map((workspace) => workspace.id); | |
| 134 | − | |
| 135 | − if (workspaceIds.length > 0) { | |
| 136 | − const executionRows = await tx | |
| 137 | − .select({ id: tables.executionsTable.id }) | |
| 138 | − .from(tables.executionsTable) | |
| 139 | − .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | |
| 140 | − const sourceRows = await tx | |
| 141 | − .select({ | |
| 142 | − recipeId: tables.sourcesTable.recipeId, | |
| 143 | − recipeRevisionId: tables.sourcesTable.recipeRevisionId, | |
| 144 | − }) | |
| 145 | − .from(tables.sourcesTable) | |
| 146 | − .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | |
| 147 | − const authArtifacts = await tx | |
| 148 | − .select({ | |
| 149 | − artifactKind: tables.authArtifactsTable.artifactKind, | |
| 150 | − configJson: tables.authArtifactsTable.configJson, | |
| 151 | − }) | |
| 152 | − .from(tables.authArtifactsTable) | |
| 153 | − .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds)); | |
| 154 | − | |
| 155 | − const executionIds = executionRows.map((execution) => execution.id); | |
| 156 | − const recipeIds = sourceRows.map((source) => source.recipeId); | |
| 157 | − const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | |
| 158 | − const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts); | |
| 159 | − | |
| 160 | − if (executionIds.length > 0) { | |
| 161 | − await tx | |
| 162 | − .delete(tables.executionInteractionsTable) | |
| 163 | − .where( | |
| 164 | − inArray(tables.executionInteractionsTable.executionId, executionIds), | |
| 165 | − ); | |
| 166 | − } | |
| 167 | − | |
| 168 | − await tx | |
| 169 | − .delete(tables.executionsTable) | |
| 170 | − .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | |
| 171 | − | |
| 172 | − await tx | |
| 173 | − .delete(tables.authLeasesTable) | |
| 174 | − .where(inArray(tables.authLeasesTable.workspaceId, workspaceIds)); | |
| 175 | − | |
| 176 | − await tx | |
| 177 | − .delete(tables.sourceAuthSessionsTable) | |
| 178 | − .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); | |
| 179 | − | |
| 180 | − await tx | |
| 181 | − .delete(tables.workspaceSourceOauthClientsTable) | |
| 182 | − .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds)); | |
| 183 | − | |
| 184 | − await tx | |
| 185 | − .delete(tables.authArtifactsTable) | |
| 186 | − .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds)); | |
| 187 | − | |
| 188 | − await tx | |
| 189 | − .delete(tables.sourcesTable) | |
| 190 | − .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | |
| 191 | − | |
| 192 | − await cleanupOrphanedSourceRecipes({ | |
| 193 | − tx, | |
| 194 | − tables, | |
| 195 | − candidateRecipeIds: recipeIds, | |
| 196 | − candidateRecipeRevisionIds: recipeRevisionIds, | |
| 197 | − }); | |
| 198 | − | |
| 199 | − await tx | |
| 200 | − .delete(tables.policiesTable) | |
| 201 | − .where(inArray(tables.policiesTable.workspaceId, workspaceIds)); | |
| 202 | − | |
| 203 | − await tx | |
| 204 | − .delete(tables.workspacesTable) | |
| 205 | − .where(inArray(tables.workspacesTable.id, workspaceIds)); | |
| 206 | − | |
| 207 | − if (postgresSecretHandles.length > 0) { | |
| 208 | − await tx | |
| 209 | − .delete(tables.secretMaterialsTable) | |
| 210 | − .where(inArray(tables.secretMaterialsTable.id, postgresSecretHandles)); | |
| 211 | − } | |
| 212 | − } | |
| 213 | − | |
| 214 | − await tx | |
| 215 | − .delete(tables.localInstallationsTable) | |
| 216 | − .where(eq(tables.localInstallationsTable.organizationId, organizationId)); | |
| 217 | − | |
| 218 | − await tx | |
| 219 | − .delete(tables.organizationMembershipsTable) | |
| 220 | − .where(eq(tables.organizationMembershipsTable.organizationId, organizationId)); | |
| 221 | − | |
| 222 | − await tx | |
| 223 | − .delete(tables.policiesTable) | |
| 224 | − .where(eq(tables.policiesTable.organizationId, organizationId)); | |
| 225 | − | |
| 226 | − const deleted = await tx | |
| 227 | − .delete(tables.organizationsTable) | |
| 228 | − .where(eq(tables.organizationsTable.id, organizationId)) | |
| 229 | − .returning(); | |
| 230 | − | |
| 231 | − return deleted.length > 0; | |
| 232 | − }), | |
| 233 | −}); | |
| @@ -1,118 +0,0 @@ | ||
| 1 | −import { type Policy, PolicySchema } from "#schema"; | |
| 2 | −import * as Option from "effect/Option"; | |
| 3 | −import { Schema } from "effect"; | |
| 4 | −import { and, asc, desc, eq, or } from "drizzle-orm"; | |
| 5 | − | |
| 6 | −import type { DrizzleClient } from "../client"; | |
| 7 | −import type { DrizzleTables } from "../schema"; | |
| 8 | −import { firstOption } from "./shared"; | |
| 9 | − | |
| 10 | −const decodePolicy = Schema.decodeUnknownSync(PolicySchema); | |
| 11 | − | |
| 12 | −export const createPoliciesRepo = ( | |
| 13 | − client: DrizzleClient, | |
| 14 | − tables: DrizzleTables, | |
| 15 | −) => ({ | |
| 16 | − listByOrganizationId: (organizationId: Policy["organizationId"]) => | |
| 17 | − client.use("rows.policies.list_by_organization", async (db) => { | |
| 18 | − const rows = await db | |
| 19 | − .select() | |
| 20 | − .from(tables.policiesTable) | |
| 21 | − .where( | |
| 22 | − and( | |
| 23 | − eq(tables.policiesTable.organizationId, organizationId), | |
| 24 | − eq(tables.policiesTable.scopeType, "organization"), | |
| 25 | − ), | |
| 26 | − ) | |
| 27 | − .orderBy(desc(tables.policiesTable.priority), asc(tables.policiesTable.updatedAt)); | |
| 28 | − | |
| 29 | − return rows.map((row) => decodePolicy(row)); | |
| 30 | − }), | |
| 31 | − | |
| 32 | − listByWorkspaceId: (workspaceId: Exclude<Policy["workspaceId"], null>) => | |
| 33 | − client.use("rows.policies.list_by_workspace", async (db) => { | |
| 34 | − const rows = await db | |
| 35 | − .select() | |
| 36 | − .from(tables.policiesTable) | |
| 37 | − .where( | |
| 38 | − and( | |
| 39 | − eq(tables.policiesTable.workspaceId, workspaceId), | |
| 40 | − eq(tables.policiesTable.scopeType, "workspace"), | |
| 41 | − ), | |
| 42 | − ) | |
| 43 | − .orderBy(desc(tables.policiesTable.priority), asc(tables.policiesTable.updatedAt)); | |
| 44 | − | |
| 45 | − return rows.map((row) => decodePolicy(row)); | |
| 46 | − }), | |
| 47 | − | |
| 48 | − listForWorkspaceContext: (input: { | |
| 49 | − organizationId: Policy["organizationId"]; | |
| 50 | − workspaceId: Exclude<Policy["workspaceId"], null>; | |
| 51 | − }) => | |
| 52 | − client.use("rows.policies.list_for_workspace_context", async (db) => { | |
| 53 | − const rows = await db | |
| 54 | − .select() | |
| 55 | − .from(tables.policiesTable) | |
| 56 | − .where( | |
| 57 | − and( | |
| 58 | − eq(tables.policiesTable.organizationId, input.organizationId), | |
| 59 | − or( | |
| 60 | − eq(tables.policiesTable.scopeType, "organization"), | |
| 61 | − and( | |
| 62 | − eq(tables.policiesTable.scopeType, "workspace"), | |
| 63 | − eq(tables.policiesTable.workspaceId, input.workspaceId), | |
| 64 | − ), | |
| 65 | − ), | |
| 66 | − ), | |
| 67 | − ) | |
| 68 | − .orderBy(desc(tables.policiesTable.priority), asc(tables.policiesTable.updatedAt)); | |
| 69 | − | |
| 70 | − return rows.map((row) => decodePolicy(row)); | |
| 71 | − }), | |
| 72 | − | |
| 73 | − getById: (policyId: Policy["id"]) => | |
| 74 | − client.use("rows.policies.get_by_id", async (db) => { | |
| 75 | − const rows = await db | |
| 76 | − .select() | |
| 77 | − .from(tables.policiesTable) | |
| 78 | − .where(eq(tables.policiesTable.id, policyId)) | |
| 79 | − .limit(1); | |
| 80 | − | |
| 81 | − const row = firstOption(rows); | |
| 82 | − return Option.isSome(row) | |
| 83 | − ? Option.some(decodePolicy(row.value)) | |
| 84 | − : Option.none<Policy>(); | |
| 85 | − }), | |
| 86 | − | |
| 87 | − insert: (policy: Policy) => | |
| 88 | − client.use("rows.policies.insert", async (db) => { | |
| 89 | − await db.insert(tables.policiesTable).values(policy); | |
| 90 | − }), | |
| 91 | − | |
| 92 | − update: ( | |
| 93 | − policyId: Policy["id"], | |
| 94 | − patch: Partial<Omit<Policy, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">>, | |
| 95 | − ) => | |
| 96 | − client.use("rows.policies.update", async (db) => { | |
| 97 | − const rows = await db | |
| 98 | − .update(tables.policiesTable) | |
| 99 | − .set(patch) | |
| 100 | − .where(eq(tables.policiesTable.id, policyId)) | |
| 101 | − .returning(); | |
| 102 | − | |
| 103 | − const row = firstOption(rows); | |
| 104 | − return Option.isSome(row) | |
| 105 | − ? Option.some(decodePolicy(row.value)) | |
| 106 | − : Option.none<Policy>(); | |
| 107 | − }), | |
| 108 | − | |
| 109 | − removeById: (policyId: Policy["id"]) => | |
| 110 | − client.use("rows.policies.remove", async (db) => { | |
| 111 | − const deleted = await db | |
| 112 | − .delete(tables.policiesTable) | |
| 113 | − .where(eq(tables.policiesTable.id, policyId)) | |
| 114 | − .returning(); | |
| 115 | − | |
| 116 | − return deleted.length > 0; | |
| 117 | − }), | |
| 118 | −}); | |
| @@ -1,158 +0,0 @@ | ||
| 1 | −import { type Workspace, WorkspaceSchema } from "#schema"; | |
| 2 | −import * as Option from "effect/Option"; | |
| 3 | −import { Schema } from "effect"; | |
| 4 | −import { and, asc, eq, inArray } from "drizzle-orm"; | |
| 5 | − | |
| 6 | −import type { DrizzleClient } from "../client"; | |
| 7 | −import type { DrizzleTables } from "../schema"; | |
| 8 | −import { | |
| 9 | − cleanupOrphanedSourceRecipes, | |
| 10 | − firstOption, | |
| 11 | − postgresSecretHandlesFromAuthArtifacts, | |
| 12 | −} from "./shared"; | |
| 13 | − | |
| 14 | −const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); | |
| 15 | − | |
| 16 | −export const createWorkspacesRepo = ( | |
| 17 | − client: DrizzleClient, | |
| 18 | − tables: DrizzleTables, | |
| 19 | −) => ({ | |
| 20 | − listByOrganizationId: (organizationId: Workspace["organizationId"]) => | |
| 21 | − client.use("rows.workspaces.list_by_organization", async (db) => { | |
| 22 | − const rows = await db | |
| 23 | − .select() | |
| 24 | − .from(tables.workspacesTable) | |
| 25 | − .where(eq(tables.workspacesTable.organizationId, organizationId)) | |
| 26 | − .orderBy( | |
| 27 | − asc(tables.workspacesTable.updatedAt), | |
| 28 | − asc(tables.workspacesTable.id), | |
| 29 | − ); | |
| 30 | − | |
| 31 | − return rows.map((row) => decodeWorkspace(row)); | |
| 32 | − }), | |
| 33 | − | |
| 34 | − getById: (workspaceId: Workspace["id"]) => | |
| 35 | − client.use("rows.workspaces.get_by_id", async (db) => { | |
| 36 | − const rows = await db | |
| 37 | − .select() | |
| 38 | − .from(tables.workspacesTable) | |
| 39 | − .where(eq(tables.workspacesTable.id, workspaceId)) | |
| 40 | − .limit(1); | |
| 41 | − | |
| 42 | − const row = firstOption(rows); | |
| 43 | − return Option.isSome(row) | |
| 44 | − ? Option.some(decodeWorkspace(row.value)) | |
| 45 | − : Option.none<Workspace>(); | |
| 46 | − }), | |
| 47 | − | |
| 48 | − insert: (workspace: Workspace) => | |
| 49 | − client.use("rows.workspaces.insert", async (db) => { | |
| 50 | − await db.insert(tables.workspacesTable).values(workspace); | |
| 51 | − }), | |
| 52 | − | |
| 53 | − update: ( | |
| 54 | − workspaceId: Workspace["id"], | |
| 55 | − patch: Partial<Omit<Workspace, "id" | "createdAt">>, | |
| 56 | − ) => | |
| 57 | − client.use("rows.workspaces.update", async (db) => { | |
| 58 | − const rows = await db | |
| 59 | − .update(tables.workspacesTable) | |
| 60 | − .set(patch) | |
| 61 | − .where(eq(tables.workspacesTable.id, workspaceId)) | |
| 62 | − .returning(); | |
| 63 | − | |
| 64 | − const row = firstOption(rows); | |
| 65 | − return Option.isSome(row) | |
| 66 | − ? Option.some(decodeWorkspace(row.value)) | |
| 67 | − : Option.none<Workspace>(); | |
| 68 | − }), | |
| 69 | − | |
| 70 | − removeById: (workspaceId: Workspace["id"]) => | |
| 71 | − client.useTx("rows.workspaces.remove", async (tx) => { | |
| 72 | − const executionRows = await tx | |
| 73 | − .select({ id: tables.executionsTable.id }) | |
| 74 | − .from(tables.executionsTable) | |
| 75 | − .where(eq(tables.executionsTable.workspaceId, workspaceId)); | |
| 76 | − const sourceRows = await tx | |
| 77 | − .select({ | |
| 78 | − recipeId: tables.sourcesTable.recipeId, | |
| 79 | − recipeRevisionId: tables.sourcesTable.recipeRevisionId, | |
| 80 | − }) | |
| 81 | − .from(tables.sourcesTable) | |
| 82 | − .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | |
| 83 | − const executionIds = executionRows.map((execution) => execution.id); | |
| 84 | − const recipeIds = sourceRows.map((source) => source.recipeId); | |
| 85 | − const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | |
| 86 | − const authArtifacts = await tx | |
| 87 | − .select({ | |
| 88 | − artifactKind: tables.authArtifactsTable.artifactKind, | |
| 89 | − configJson: tables.authArtifactsTable.configJson, | |
| 90 | − }) | |
| 91 | − .from(tables.authArtifactsTable) | |
| 92 | − .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)); | |
| 93 | − const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts); | |
| 94 | − | |
| 95 | − if (executionIds.length > 0) { | |
| 96 | − await tx | |
| 97 | − .delete(tables.executionInteractionsTable) | |
| 98 | − .where(inArray(tables.executionInteractionsTable.executionId, executionIds)); | |
| 99 | − } | |
| 100 | − | |
| 101 | − await tx | |
| 102 | − .delete(tables.executionsTable) | |
| 103 | − .where(eq(tables.executionsTable.workspaceId, workspaceId)); | |
| 104 | − | |
| 105 | − await tx | |
| 106 | − .delete(tables.authLeasesTable) | |
| 107 | − .where(eq(tables.authLeasesTable.workspaceId, workspaceId)); | |
| 108 | − | |
| 109 | − await tx | |
| 110 | − .delete(tables.sourceAuthSessionsTable) | |
| 111 | − .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); | |
| 112 | − | |
| 113 | − await tx | |
| 114 | − .delete(tables.workspaceSourceOauthClientsTable) | |
| 115 | − .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId)); | |
| 116 | − | |
| 117 | − await tx | |
| 118 | − .delete(tables.authArtifactsTable) | |
| 119 | − .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)); | |
| 120 | − | |
| 121 | − await tx | |
| 122 | − .delete(tables.sourcesTable) | |
| 123 | − .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | |
| 124 | − | |
| 125 | − await cleanupOrphanedSourceRecipes({ | |
| 126 | − tx, | |
| 127 | − tables, | |
| 128 | − candidateRecipeIds: recipeIds, | |
| 129 | − candidateRecipeRevisionIds: recipeRevisionIds, | |
| 130 | − }); | |
| 131 | − | |
| 132 | − await tx | |
| 133 | − .delete(tables.policiesTable) | |
| 134 | − .where( | |
| 135 | − and( | |
| 136 | − eq(tables.policiesTable.scopeType, "workspace"), | |
| 137 | − eq(tables.policiesTable.workspaceId, workspaceId), | |
| 138 | − ), | |
| 139 | − ); | |
| 140 | − | |
| 141 | − await tx | |
| 142 | − .delete(tables.localInstallationsTable) | |
| 143 | − .where(eq(tables.localInstallationsTable.workspaceId, workspaceId)); | |
| 144 | − | |
| 145 | − if (postgresSecretHandles.length > 0) { | |
| 146 | − await tx | |
| 147 | − .delete(tables.secretMaterialsTable) | |
| 148 | − .where(inArray(tables.secretMaterialsTable.id, postgresSecretHandles)); | |
| 149 | − } | |
| 150 | − | |
| 151 | − const deleted = await tx | |
| 152 | − .delete(tables.workspacesTable) | |
| 153 | − .where(eq(tables.workspacesTable.id, workspaceId)) | |
| 154 | − .returning(); | |
| 155 | − | |
| 156 | − return deleted.length > 0; | |
| 157 | − }), | |
| 158 | −}); | |
| @@ -12,9 +12,6 @@ import { sql } from "drizzle-orm"; · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | export const tableNames = { |
| 14 | 14 | accounts: "accounts", |
| 15 | − organizations: "organizations", | |
| 16 | − organizationMemberships: "organization_memberships", | |
| 17 | − workspaces: "workspaces", | |
| 18 | 15 | sources: "sources", |
| 19 | 16 | sourceRecipes: "source_recipes", |
| 20 | 17 | sourceRecipeRevisions: "source_recipe_revisions", |
| @@ -27,8 +24,6 @@ export const tableNames = { · 6 unmodified lines | ||
| 27 | 24 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 28 | 25 | secretMaterials: "secret_materials", |
| 29 | 26 | sourceAuthSessions: "source_auth_sessions", |
| 30 | − policies: "policies", | |
| 31 | − localInstallations: "local_installations", | |
| 32 | 27 | executions: "executions", |
| 33 | 28 | executionInteractions: "execution_interactions", |
| 34 | 29 | } as const; |
| @@ -54,83 +49,6 @@ export const accountsTable = pgTable( · 19 unmodified lines | ||
| 54 | 49 | ], |
| 55 | 50 | ); |
| 56 | 51 | |
| 57 | −export const organizationsTable = pgTable(tableNames.organizations, { | |
| 58 | − id: text("id").notNull().primaryKey(), | |
| 59 | − slug: text("slug").notNull(), | |
| 60 | − name: text("name").notNull(), | |
| 61 | − status: text("status").notNull(), | |
| 62 | − createdByAccountId: text("created_by_account_id"), | |
| 63 | − createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 64 | − updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 65 | −}, (table) => [ | |
| 66 | − uniqueIndex("organizations_slug_idx").on(table.slug), | |
| 67 | − index("organizations_updated_idx").on(table.updatedAt, table.id), | |
| 68 | − check( | |
| 69 | − "organizations_status_check", | |
| 70 | − sql`${table.status} in ('active', 'suspended', 'archived')`, | |
| 71 | − ), | |
| 72 | −]); | |
| 73 | − | |
| 74 | −export const organizationMembershipsTable = pgTable( | |
| 75 | − tableNames.organizationMemberships, | |
| 76 | − { | |
| 77 | − id: text("id").notNull().primaryKey(), | |
| 78 | − organizationId: text("organization_id").notNull(), | |
| 79 | − accountId: text("account_id").notNull(), | |
| 80 | − role: text("role").notNull(), | |
| 81 | − status: text("status").notNull(), | |
| 82 | − billable: boolean("billable").notNull(), | |
| 83 | − invitedByAccountId: text("invited_by_account_id"), | |
| 84 | − joinedAt: bigint("joined_at", { mode: "number" }), | |
| 85 | − createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 86 | − updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 87 | − }, | |
| 88 | − (table) => [ | |
| 89 | − index("organization_memberships_org_updated_idx").on( | |
| 90 | − table.organizationId, | |
| 91 | − table.updatedAt, | |
| 92 | − table.id, | |
| 93 | − ), | |
| 94 | − index("organization_memberships_account_updated_idx").on( | |
| 95 | − table.accountId, | |
| 96 | − table.updatedAt, | |
| 97 | − table.id, | |
| 98 | − ), | |
| 99 | − uniqueIndex("organization_memberships_org_account_idx").on( | |
| 100 | − table.organizationId, | |
| 101 | − table.accountId, | |
| 102 | − ), | |
| 103 | − check( | |
| 104 | − "organization_memberships_role_check", | |
| 105 | − sql`${table.role} in ('viewer', 'editor', 'admin', 'owner')`, | |
| 106 | − ), | |
| 107 | − check( | |
| 108 | − "organization_memberships_status_check", | |
| 109 | − sql`${table.status} in ('invited', 'active', 'suspended', 'removed')`, | |
| 110 | − ), | |
| 111 | − ], | |
| 112 | −); | |
| 113 | − | |
| 114 | −export const workspacesTable = pgTable( | |
| 115 | − tableNames.workspaces, | |
| 116 | − { | |
| 117 | − id: text("id").notNull().primaryKey(), | |
| 118 | − organizationId: text("organization_id").notNull(), | |
| 119 | − name: text("name").notNull(), | |
| 120 | − createdByAccountId: text("created_by_account_id"), | |
| 121 | − createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 122 | − updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 123 | − }, | |
| 124 | − (table) => [ | |
| 125 | − index("workspaces_org_updated_idx").on( | |
| 126 | − table.organizationId, | |
| 127 | − table.updatedAt, | |
| 128 | − table.id, | |
| 129 | − ), | |
| 130 | − uniqueIndex("workspaces_org_name_idx").on(table.organizationId, table.name), | |
| 131 | − ], | |
| 132 | −); | |
| 133 | − | |
| 134 | 52 | export const sourcesTable = pgTable( |
| 135 | 53 | tableNames.sources, |
| 136 | 54 | { |
| @@ -520,86 +438,6 @@ export const sourceAuthSessionsTable = pgTable( · 383 unmodified lines | ||
| 520 | 438 | ], |
| 521 | 439 | ); |
| 522 | 440 | |
| 523 | −export const policiesTable = pgTable( | |
| 524 | − tableNames.policies, | |
| 525 | − { | |
| 526 | − id: text("id").notNull().primaryKey(), | |
| 527 | − scopeType: text("scope_type").notNull(), | |
| 528 | − organizationId: text("organization_id").notNull(), | |
| 529 | − workspaceId: text("workspace_id"), | |
| 530 | − targetAccountId: text("target_account_id"), | |
| 531 | − clientId: text("client_id"), | |
| 532 | − resourceType: text("resource_type").notNull(), | |
| 533 | − resourcePattern: text("resource_pattern").notNull(), | |
| 534 | − matchType: text("match_type").notNull(), | |
| 535 | − effect: text("effect").notNull(), | |
| 536 | − approvalMode: text("approval_mode").notNull(), | |
| 537 | − argumentConditionsJson: text("argument_conditions_json"), | |
| 538 | − priority: bigint("priority", { mode: "number" }).notNull(), | |
| 539 | − enabled: boolean("enabled").notNull(), | |
| 540 | − createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 541 | − updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 542 | − }, | |
| 543 | − (table) => [ | |
| 544 | − index("policies_organization_priority_idx").on( | |
| 545 | − table.organizationId, | |
| 546 | − table.priority.desc(), | |
| 547 | − table.updatedAt, | |
| 548 | − table.id, | |
| 549 | − ), | |
| 550 | − index("policies_workspace_priority_idx").on( | |
| 551 | − table.workspaceId, | |
| 552 | − table.priority.desc(), | |
| 553 | − table.updatedAt, | |
| 554 | − table.id, | |
| 555 | − ), | |
| 556 | − check( | |
| 557 | − "policies_scope_type_check", | |
| 558 | − sql`${table.scopeType} in ('organization', 'workspace')`, | |
| 559 | − ), | |
| 560 | − check( | |
| 561 | − "policies_scope_consistency_check", | |
| 562 | − sql`( | |
| 563 | − ${table.scopeType} = 'organization' and ${table.workspaceId} is null | |
| 564 | − ) or ( | |
| 565 | − ${table.scopeType} = 'workspace' and ${table.workspaceId} is not null | |
| 566 | − )`, | |
| 567 | − ), | |
| 568 | − check( | |
| 569 | − "policies_resource_type_check", | |
| 570 | − sql`${table.resourceType} in ('all_tools', 'source', 'namespace', 'tool_path')`, | |
| 571 | − ), | |
| 572 | − check( | |
| 573 | − "policies_match_type_check", | |
| 574 | − sql`${table.matchType} in ('glob', 'exact')`, | |
| 575 | − ), | |
| 576 | − check( | |
| 577 | − "policies_effect_check", | |
| 578 | − sql`${table.effect} in ('allow', 'deny')`, | |
| 579 | − ), | |
| 580 | − check( | |
| 581 | − "policies_approval_mode_check", | |
| 582 | − sql`${table.approvalMode} in ('auto', 'required')`, | |
| 583 | − ), | |
| 584 | − ], | |
| 585 | −); | |
| 586 | − | |
| 587 | −export const localInstallationsTable = pgTable( | |
| 588 | − tableNames.localInstallations, | |
| 589 | − { | |
| 590 | − id: text("id").notNull().primaryKey(), | |
| 591 | − accountId: text("account_id").notNull(), | |
| 592 | − organizationId: text("organization_id").notNull(), | |
| 593 | − workspaceId: text("workspace_id").notNull(), | |
| 594 | − createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 595 | − updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 596 | − }, | |
| 597 | − (table) => [ | |
| 598 | − index("local_installations_organization_idx").on(table.organizationId), | |
| 599 | − index("local_installations_workspace_idx").on(table.workspaceId), | |
| 600 | − ], | |
| 601 | −); | |
| 602 | − | |
| 603 | 441 | export const executionsTable = pgTable( |
| 604 | 442 | tableNames.executions, |
| 605 | 443 | { |
| @@ -653,9 +491,6 @@ export const executionInteractionsTable = pgTable( · 47 unmodified lines | ||
| 653 | 491 | |
| 654 | 492 | export const drizzleSchema = { |
| 655 | 493 | accountsTable, |
| 656 | − organizationsTable, | |
| 657 | − organizationMembershipsTable, | |
| 658 | − workspacesTable, | |
| 659 | 494 | sourcesTable, |
| 660 | 495 | sourceRecipesTable, |
| 661 | 496 | sourceRecipeRevisionsTable, |
| @@ -668,8 +503,6 @@ export const drizzleSchema = { · 6 unmodified lines | ||
| 668 | 503 | workspaceSourceOauthClientsTable, |
| 669 | 504 | secretMaterialsTable, |
| 670 | 505 | sourceAuthSessionsTable, |
| 671 | − policiesTable, | |
| 672 | − localInstallationsTable, | |
| 673 | 506 | executionsTable, |
| 674 | 507 | executionInteractionsTable, |
| 675 | 508 | }; |
| @@ -1,160 +0,0 @@ | ||
| 1 | −import * as PlatformHeaders from "@effect/platform/Headers"; | |
| 2 | −import { ControlPlaneAuthHeaders } from "../auth-headers"; | |
| 3 | −import { | |
| 4 | − ControlPlaneActorResolver, | |
| 5 | − type ControlPlaneActorResolverShape, | |
| 6 | − deriveWorkspaceMembershipsForPrincipal, | |
| 7 | −} from "#api"; | |
| 8 | −import { | |
| 9 | − ActorUnauthenticatedError, | |
| 10 | − createActor, | |
| 11 | −} from "#domain"; | |
| 12 | −import { | |
| 13 | − SqlControlPlaneRowsService, | |
| 14 | − type SqlControlPlaneRows, | |
| 15 | −} from "#persistence"; | |
| 16 | −import { | |
| 17 | − PrincipalProviderSchema, | |
| 18 | − PrincipalSchema, | |
| 19 | − type Principal, | |
| 20 | −} from "#schema"; | |
| 21 | −import * as Effect from "effect/Effect"; | |
| 22 | −import * as Layer from "effect/Layer"; | |
| 23 | −import * as Option from "effect/Option"; | |
| 24 | −import * as ParseResult from "effect/ParseResult"; | |
| 25 | −import * as Schema from "effect/Schema"; | |
| 26 | − | |
| 27 | −export { ControlPlaneAuthHeaders }; | |
| 28 | − | |
| 29 | −const decodePrincipal = Schema.decodeUnknown(PrincipalSchema); | |
| 30 | −const decodePrincipalProvider = Schema.decodeUnknown(PrincipalProviderSchema); | |
| 31 | − | |
| 32 | −const headerValue = ( | |
| 33 | − headers: PlatformHeaders.Headers, | |
| 34 | − name: string, | |
| 35 | −): string | null => { | |
| 36 | − const value = Option.getOrNull(PlatformHeaders.get(headers, name)); | |
| 37 | − if (value === null) { | |
| 38 | − return null; | |
| 39 | − } | |
| 40 | − | |
| 41 | − const trimmed = value.trim(); | |
| 42 | − return trimmed.length > 0 ? trimmed : null; | |
| 43 | −}; | |
| 44 | − | |
| 45 | −const toUnauthenticatedError = ( | |
| 46 | − message: string, | |
| 47 | − cause?: unknown, | |
| 48 | −): ActorUnauthenticatedError => | |
| 49 | − new ActorUnauthenticatedError({ | |
| 50 | − message: | |
| 51 | − cause === undefined | |
| 52 | − ? message | |
| 53 | − : `${message}: ${ | |
| 54 | − ParseResult.isParseError(cause) | |
| 55 | − ? ParseResult.TreeFormatter.formatErrorSync(cause) | |
| 56 | − : String(cause) | |
| 57 | − }`, | |
| 58 | − }); | |
| 59 | − | |
| 60 | −const readPrincipalFromHeaders = ( | |
| 61 | − headers: PlatformHeaders.Headers, | |
| 62 | −): Effect.Effect<Principal, ActorUnauthenticatedError> => | |
| 63 | − Effect.gen(function* () { | |
| 64 | − const accountId = headerValue(headers, ControlPlaneAuthHeaders.accountId); | |
| 65 | − if (accountId === null) { | |
| 66 | − return yield* Effect.fail( | |
| 67 | − new ActorUnauthenticatedError({ | |
| 68 | − message: `Missing required header: ${ControlPlaneAuthHeaders.accountId}`, | |
| 69 | − }), | |
| 70 | − ); | |
| 71 | − } | |
| 72 | − | |
| 73 | − const providerRaw = | |
| 74 | − headerValue(headers, ControlPlaneAuthHeaders.principalProvider) ?? "local"; | |
| 75 | − const provider = yield* decodePrincipalProvider(providerRaw).pipe( | |
| 76 | − Effect.mapError((cause) => | |
| 77 | − toUnauthenticatedError("Invalid principal provider header", cause), | |
| 78 | − ), | |
| 79 | − ); | |
| 80 | − | |
| 81 | − const subject = | |
| 82 | − headerValue(headers, ControlPlaneAuthHeaders.principalSubject) | |
| 83 | − ?? `${provider}:${accountId}`; | |
| 84 | − | |
| 85 | − return yield* decodePrincipal({ | |
| 86 | − accountId, | |
| 87 | − provider, | |
| 88 | − subject, | |
| 89 | − email: headerValue(headers, ControlPlaneAuthHeaders.principalEmail), | |
| 90 | − displayName: headerValue(headers, ControlPlaneAuthHeaders.principalDisplayName), | |
| 91 | − }).pipe( | |
| 92 | − Effect.mapError((cause) => | |
| 93 | − toUnauthenticatedError("Invalid principal headers", cause), | |
| 94 | − ), | |
| 95 | − ); | |
| 96 | − }); | |
| 97 | − | |
| 98 | −export const createHeaderActorResolver = ( | |
| 99 | − rows: SqlControlPlaneRows, | |
| 100 | −): ControlPlaneActorResolverShape => ({ | |
| 101 | − resolveActor: ({ headers }) => | |
| 102 | − Effect.gen(function* () { | |
| 103 | − const principal = yield* readPrincipalFromHeaders(headers); | |
| 104 | − const organizationMemberships = yield* rows.organizationMemberships | |
| 105 | − .listByAccountId(principal.accountId) | |
| 106 | − .pipe( | |
| 107 | − Effect.mapError((cause) => | |
| 108 | − toUnauthenticatedError("Failed loading memberships", cause), | |
| 109 | − ), | |
| 110 | − ); | |
| 111 | − | |
| 112 | − return yield* createActor({ | |
| 113 | − principal, | |
| 114 | − workspaceMemberships: [], | |
| 115 | − organizationMemberships, | |
| 116 | − }); | |
| 117 | − }), | |
| 118 | − | |
| 119 | − resolveWorkspaceActor: ({ workspaceId, headers }) => | |
| 120 | − Effect.gen(function* () { | |
| 121 | − const principal = yield* readPrincipalFromHeaders(headers); | |
| 122 | − const organizationMemberships = yield* rows.organizationMemberships | |
| 123 | − .listByAccountId(principal.accountId) | |
| 124 | − .pipe( | |
| 125 | − Effect.mapError((cause) => | |
| 126 | − toUnauthenticatedError("Failed loading memberships", cause), | |
| 127 | − ), | |
| 128 | − ); | |
| 129 | − | |
| 130 | − const workspaceOption = yield* rows.workspaces.getById(workspaceId).pipe( | |
| 131 | − Effect.mapError((cause) => | |
| 132 | − toUnauthenticatedError("Failed loading workspace context", cause), | |
| 133 | − ), | |
| 134 | − ); | |
| 135 | − const workspace = Option.getOrNull(workspaceOption); | |
| 136 | − | |
| 137 | − const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({ | |
| 138 | − principalAccountId: principal.accountId, | |
| 139 | − workspaceId, | |
| 140 | − workspace, | |
| 141 | − organizationMemberships, | |
| 142 | − }); | |
| 143 | − | |
| 144 | − return yield* createActor({ | |
| 145 | − principal, | |
| 146 | − workspaceMemberships, | |
| 147 | − organizationMemberships, | |
| 148 | − }); | |
| 149 | − }), | |
| 150 | −}); | |
| 151 | − | |
| 152 | −export const RuntimeActorResolverLive = ( | |
| 153 | − actorResolver?: ControlPlaneActorResolverShape, | |
| 154 | −) => | |
| 155 | − actorResolver | |
| 156 | − ? Layer.succeed(ControlPlaneActorResolver, actorResolver) | |
| 157 | − : Layer.effect( | |
| 158 | − ControlPlaneActorResolver, | |
| 159 | − Effect.map(SqlControlPlaneRowsService, (rows) => createHeaderActorResolver(rows)), | |
| 160 | − ); | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import { createServer } from "node:http"; |
| 2 | +import { mkdtempSync, readFileSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 2 | 5 | |
| 3 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | 7 | import { assertTrue } from "@effect/vitest/utils"; |
| @@ -12,33 +15,26 @@ import { · 7 unmodified lines | ||
| 12 | 15 | ExecutionInteractionIdSchema, |
| 13 | 16 | SecretMaterialIdSchema, |
| 14 | 17 | SourceIdSchema, |
| 15 | − SourceRecipeIdSchema, | |
| 16 | − SourceRecipeRevisionIdSchema, | |
| 17 | 18 | } from "#schema"; |
| 18 | 19 | import type { ToolPath } from "@executor/codemode-core"; |
| 19 | 20 | |
| 20 | 21 | import { |
| 21 | − createSqlControlPlaneRuntime, | |
| 22 | + createControlPlaneRuntime, | |
| 22 | 23 | LiveExecutionManagerService, |
| 23 | 24 | } from "./index"; |
| 25 | +import { createSourceFromPayload } from "./source-definitions"; | |
| 24 | 26 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; |
| 27 | +import { persistSource } from "./source-store"; | |
| 25 | 28 | import { withControlPlaneClient } from "./test-http-client"; |
| 26 | 29 | |
| 27 | 30 | const makeRuntime = Effect.acquireRelease( |
| 28 | − createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | |
| 31 | + createControlPlaneRuntime({ | |
| 32 | + localDataDir: ":memory:", | |
| 33 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")), | |
| 34 | + }), | |
| 29 | 35 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 30 | 36 | ); |
| 31 | 37 | |
| 32 | −const openApiBindingConfigJson = (specUrl: string): string => | |
| 33 | − JSON.stringify({ | |
| 34 | − adapterKey: "openapi", | |
| 35 | − version: 1, | |
| 36 | − payload: { | |
| 37 | − specUrl, | |
| 38 | − defaultHeaders: null, | |
| 39 | − }, | |
| 40 | − }); | |
| 41 | − | |
| 42 | 38 | type OpenApiSpecServer = { |
| 43 | 39 | baseUrl: string; |
| 44 | 40 | specUrl: string; |
| @@ -123,155 +119,72 @@ const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => · 78 unmodified lines | ||
| 123 | 119 | ); |
| 124 | 120 | |
| 125 | 121 | describe("control-plane-runtime", () => { |
| 126 | − it.scoped("supports full CRUD flow over HTTP API", () => | |
| 122 | + it.scoped("writes local source changes through executor.jsonc", () => | |
| 127 | 123 | Effect.gen(function* () { |
| 128 | − const runtime = yield* makeRuntime; | |
| 129 | − const openApiServer = yield* makeOpenApiSpecServer; | |
| 130 | − | |
| 131 | − const createOrg = yield* withControlPlaneClient( | |
| 132 | − { runtime, accountId: "acc_1" }, | |
| 133 | − (client) => | |
| 134 | − client.organizations.create({ | |
| 135 | − payload: { | |
| 136 | − name: "Acme", | |
| 137 | − }, | |
| 138 | − }), | |
| 139 | − ); | |
| 140 | − const organizationId = createOrg.id; | |
| 141 | − | |
| 142 | − const createWorkspace = yield* withControlPlaneClient( | |
| 143 | − { runtime, accountId: "acc_1" }, | |
| 144 | − (client) => | |
| 145 | − client.workspaces.create({ | |
| 146 | − path: { organizationId }, | |
| 147 | − payload: { name: "Primary" }, | |
| 148 | − }), | |
| 124 | + const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-local-config-runtime-")); | |
| 125 | + const runtime = yield* Effect.acquireRelease( | |
| 126 | + createControlPlaneRuntime({ | |
| 127 | + localDataDir: ":memory:", | |
| 128 | + workspaceRoot, | |
| 129 | + }), | |
| 130 | + (createdRuntime) => Effect.promise(() => createdRuntime.close()).pipe(Effect.orDie), | |
| 149 | 131 | ); |
| 150 | − expect(createWorkspace.createdByAccountId).toBe("acc_1"); | |
| 151 | − const workspaceId = createWorkspace.id; | |
| 132 | + const openApiServer = yield* makeOpenApiSpecServer; | |
| 133 | + const installation = runtime.localInstallation; | |
| 152 | 134 | |
| 153 | − yield* withControlPlaneClient( | |
| 154 | − { runtime, accountId: "acc_1" }, | |
| 135 | + const createdSource = yield* withControlPlaneClient( | |
| 136 | + { runtime, accountId: installation.accountId }, | |
| 155 | 137 | (client) => |
| 156 | 138 | client.sources.create({ |
| 157 | − path: { workspaceId }, | |
| 139 | + path: { workspaceId: installation.workspaceId }, | |
| 158 | 140 | payload: { |
| 159 | − name: "Github", | |
| 141 | + name: "GitHub", | |
| 160 | 142 | kind: "openapi", |
| 161 | 143 | endpoint: openApiServer.baseUrl, |
| 144 | + namespace: "github", | |
| 162 | 145 | binding: { |
| 163 | 146 | specUrl: openApiServer.specUrl, |
| 164 | 147 | defaultHeaders: null, |
| 165 | 148 | }, |
| 166 | − auth: { | |
| 167 | − kind: "none", | |
| 168 | − }, | |
| 169 | − }, | |
| 170 | − }), | |
| 171 | − ); | |
| 172 | − | |
| 173 | − yield* withControlPlaneClient( | |
| 174 | − { runtime, accountId: "acc_1" }, | |
| 175 | − (client) => | |
| 176 | − client.policies.create({ | |
| 177 | − path: { workspaceId }, | |
| 178 | − payload: { | |
| 179 | − resourceType: "tool_path", | |
| 180 | − resourcePattern: "source.github.*", | |
| 181 | − matchType: "glob", | |
| 182 | − effect: "allow", | |
| 183 | − approvalMode: "auto", | |
| 184 | − priority: 50, | |
| 185 | − enabled: true, | |
| 149 | + auth: { kind: "none" }, | |
| 186 | 150 | }, |
| 187 | 151 | }), |
| 188 | 152 | ); |
| 189 | 153 | |
| 190 | − const listSources = yield* withControlPlaneClient( | |
| 191 | − { runtime, accountId: "acc_1" }, | |
| 192 | − (client) => | |
| 193 | − client.sources.list({ | |
| 194 | − path: { workspaceId }, | |
| 195 | − }), | |
| 196 | − ); | |
| 197 | − expect(listSources.length).toBe(1); | |
| 198 | − | |
| 199 | − const listPolicies = yield* withControlPlaneClient( | |
| 200 | − { runtime, accountId: "acc_1" }, | |
| 201 | − (client) => | |
| 202 | − client.policies.list({ | |
| 203 | − path: { workspaceId }, | |
| 204 | − }), | |
| 205 | − ); | |
| 206 | − expect(listPolicies.length).toBe(1); | |
| 207 | − }), | |
| 208 | − ); | |
| 209 | − | |
| 210 | − it.scoped("discovers and connects an OpenAPI source through the HTTP API", () => | |
| 211 | − Effect.gen(function* () { | |
| 212 | − const runtime = yield* makeRuntime; | |
| 213 | − const openApiServer = yield* makeOpenApiSpecServer; | |
| 154 | + const configPath = join(workspaceRoot, ".executor", "executor.jsonc"); | |
| 155 | + const createdConfig = JSON.parse(readFileSync(configPath, "utf8")) as { | |
| 156 | + sources?: Record<string, { kind: string; connection: { endpoint: string } }>; | |
| 157 | + }; | |
| 158 | + expect(createdConfig.sources?.github?.kind).toBe("openapi"); | |
| 159 | + expect(createdConfig.sources?.github?.connection.endpoint).toBe(openApiServer.baseUrl); | |
| 214 | 160 | |
| 215 | − const organization = yield* withControlPlaneClient( | |
| 216 | − { runtime, accountId: "acc_discover" }, | |
| 217 | − (client) => | |
| 218 | − client.organizations.create({ | |
| 219 | − payload: { name: "Discovery Org" }, | |
| 220 | − }), | |
| 221 | − ); | |
| 222 | − const workspace = yield* withControlPlaneClient( | |
| 223 | − { runtime, accountId: "acc_discover" }, | |
| 161 | + const removed = yield* withControlPlaneClient( | |
| 162 | + { runtime, accountId: installation.accountId }, | |
| 224 | 163 | (client) => |
| 225 | − client.workspaces.create({ | |
| 226 | − path: { organizationId: organization.id }, | |
| 227 | − payload: { name: "Discovery Workspace" }, | |
| 228 | − }), | |
| 229 | − ); | |
| 230 | − | |
| 231 | − const discovered = yield* withControlPlaneClient( | |
| 232 | − { runtime, accountId: "acc_discover" }, | |
| 233 | − (client) => | |
| 234 | − client.sources.discover({ | |
| 235 | − payload: { | |
| 236 | − url: openApiServer.specUrl, | |
| 164 | + client.sources.remove({ | |
| 165 | + path: { | |
| 166 | + workspaceId: installation.workspaceId, | |
| 167 | + sourceId: createdSource.id, | |
| 237 | 168 | }, |
| 238 | 169 | }), |
| 239 | 170 | ); |
| 240 | − expect(discovered.detectedKind).toBe("openapi"); | |
| 241 | − expect(discovered.specUrl).toBe(openApiServer.specUrl); | |
| 242 | − expect(discovered.endpoint).toBe(openApiServer.baseUrl); | |
| 171 | + expect(removed.removed).toBe(true); | |
| 243 | 172 | |
| 244 | − const connected = yield* withControlPlaneClient( | |
| 245 | − { runtime, accountId: "acc_discover" }, | |
| 246 | − (client) => | |
| 247 | − client.sources.connect({ | |
| 248 | − path: { workspaceId: workspace.id }, | |
| 249 | − payload: { | |
| 250 | − kind: "openapi", | |
| 251 | − endpoint: discovered.endpoint, | |
| 252 | − specUrl: discovered.specUrl ?? openApiServer.specUrl, | |
| 253 | − name: discovered.name, | |
| 254 | − namespace: discovered.namespace, | |
| 255 | − auth: { kind: "none" }, | |
| 256 | − }, | |
| 257 | − }), | |
| 258 | − ); | |
| 259 | − expect(connected.kind).toBe("connected"); | |
| 260 | − expect(connected.source.kind).toBe("openapi"); | |
| 261 | − expect(connected.source.status).toBe("connected"); | |
| 262 | − expect(connected.source.binding).toEqual({ | |
| 263 | − specUrl: openApiServer.specUrl, | |
| 264 | − defaultHeaders: null, | |
| 265 | − }); | |
| 173 | + const removedConfig = JSON.parse(readFileSync(configPath, "utf8")) as { | |
| 174 | + sources?: Record<string, unknown>; | |
| 175 | + }; | |
| 176 | + expect(removedConfig.sources?.github).toBeUndefined(); | |
| 266 | 177 | }), |
| 178 | + 60_000, | |
| 267 | 179 | ); |
| 268 | 180 | |
| 181 | + | |
| 269 | 182 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => |
| 270 | 183 | Effect.gen(function* () { |
| 271 | 184 | const runtime = yield* makeRuntime; |
| 272 | 185 | const installation = runtime.localInstallation; |
| 273 | 186 | const executionId = ExecutionIdSchema.make("exec_local_credential"); |
| 274 | − const sourceId = SourceIdSchema.make("src_local_credential"); | |
| 187 | + const sourceId = SourceIdSchema.make("github"); | |
| 275 | 188 | const interactionSuffix = "executor.sources.add:test"; |
| 276 | 189 | const interactionId = ExecutionInteractionIdSchema.make( |
| 277 | 190 | `${executionId}:${interactionSuffix}`, |
| @@ -293,24 +206,32 @@ describe("control-plane-runtime", () => { · 15 unmodified lines | ||
| 293 | 206 | updatedAt: now, |
| 294 | 207 | }); |
| 295 | 208 | |
| 296 | − yield* runtime.persistence.rows.sources.insert({ | |
| 297 | − id: sourceId, | |
| 209 | + const localSource = yield* createSourceFromPayload({ | |
| 298 | 210 | workspaceId: installation.workspaceId, |
| 299 | − recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`), | |
| 300 | − recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`), | |
| 301 | − name: "GitHub", | |
| 302 | − kind: "openapi", | |
| 303 | − endpoint: "https://api.github.com", | |
| 304 | − status: "auth_required", | |
| 305 | − enabled: true, | |
| 306 | − namespace: "github", | |
| 307 | − importAuthPolicy: "reuse_runtime", | |
| 308 | − bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), | |
| 309 | − sourceHash: null, | |
| 310 | − lastError: null, | |
| 311 | − createdAt: now, | |
| 312 | − updatedAt: now, | |
| 313 | − }); | |
| 211 | + sourceId, | |
| 212 | + payload: { | |
| 213 | + name: "GitHub", | |
| 214 | + kind: "openapi", | |
| 215 | + endpoint: "https://api.github.com", | |
| 216 | + status: "auth_required", | |
| 217 | + enabled: true, | |
| 218 | + namespace: "github", | |
| 219 | + importAuthPolicy: "reuse_runtime", | |
| 220 | + binding: { | |
| 221 | + specUrl: "https://example.com/github-openapi.yaml", | |
| 222 | + defaultHeaders: null, | |
| 223 | + }, | |
| 224 | + importAuth: { kind: "none" }, | |
| 225 | + auth: { kind: "none" }, | |
| 226 | + }, | |
| 227 | + now, | |
| 228 | + }).pipe(Effect.orDie); | |
| 229 | + yield* persistSource(runtime.persistence.rows, localSource, { | |
| 230 | + actorAccountId: installation.accountId, | |
| 231 | + }).pipe( | |
| 232 | + Effect.provide(runtime.runtimeLayer), | |
| 233 | + Effect.orDie, | |
| 234 | + ); | |
| 314 | 235 | |
| 315 | 236 | const interactionFiber = yield* Effect.gen(function* () { |
| 316 | 237 | const liveExecutionManager = yield* LiveExecutionManagerService; |
| @@ -426,7 +347,7 @@ describe("control-plane-runtime", () => { · 109 unmodified lines | ||
| 426 | 347 | const runtime = yield* makeRuntime; |
| 427 | 348 | const installation = runtime.localInstallation; |
| 428 | 349 | const executionId = ExecutionIdSchema.make("exec_local_credential_continue"); |
| 429 | − const sourceId = SourceIdSchema.make("src_local_credential_continue"); | |
| 350 | + const sourceId = SourceIdSchema.make("github"); | |
| 430 | 351 | const interactionSuffix = "executor.sources.add:continue"; |
| 431 | 352 | const interactionId = ExecutionInteractionIdSchema.make( |
| 432 | 353 | `${executionId}:${interactionSuffix}`, |
| @@ -448,24 +369,32 @@ describe("control-plane-runtime", () => { · 15 unmodified lines | ||
| 448 | 369 | updatedAt: now, |
| 449 | 370 | }); |
| 450 | 371 | |
| 451 | − yield* runtime.persistence.rows.sources.insert({ | |
| 452 | − id: sourceId, | |
| 372 | + const localSource = yield* createSourceFromPayload({ | |
| 453 | 373 | workspaceId: installation.workspaceId, |
| 454 | − recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`), | |
| 455 | − recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`), | |
| 456 | − name: "GitHub", | |
| 457 | − kind: "openapi", | |
| 458 | − endpoint: "https://api.github.com", | |
| 459 | − status: "auth_required", | |
| 460 | − enabled: true, | |
| 461 | − namespace: "github", | |
| 462 | − importAuthPolicy: "reuse_runtime", | |
| 463 | − bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), | |
| 464 | − sourceHash: null, | |
| 465 | − lastError: null, | |
| 466 | − createdAt: now, | |
| 467 | − updatedAt: now, | |
| 468 | − }); | |
| 374 | + sourceId, | |
| 375 | + payload: { | |
| 376 | + name: "GitHub", | |
| 377 | + kind: "openapi", | |
| 378 | + endpoint: "https://api.github.com", | |
| 379 | + status: "auth_required", | |
| 380 | + enabled: true, | |
| 381 | + namespace: "github", | |
| 382 | + importAuthPolicy: "reuse_runtime", | |
| 383 | + binding: { | |
| 384 | + specUrl: "https://example.com/github-openapi.yaml", | |
| 385 | + defaultHeaders: null, | |
| 386 | + }, | |
| 387 | + importAuth: { kind: "none" }, | |
| 388 | + auth: { kind: "none" }, | |
| 389 | + }, | |
| 390 | + now, | |
| 391 | + }).pipe(Effect.orDie); | |
| 392 | + yield* persistSource(runtime.persistence.rows, localSource, { | |
| 393 | + actorAccountId: installation.accountId, | |
| 394 | + }).pipe( | |
| 395 | + Effect.provide(runtime.runtimeLayer), | |
| 396 | + Effect.orDie, | |
| 397 | + ); | |
| 469 | 398 | |
| 470 | 399 | const interactionFiber = yield* Effect.gen(function* () { |
| 471 | 400 | const liveExecutionManager = yield* LiveExecutionManagerService; |
| @@ -538,264 +467,4 @@ describe("control-plane-runtime", () => { · 66 unmodified lines | ||
| 538 | 467 | }), |
| 539 | 468 | ); |
| 540 | 469 | |
| 541 | − it.scoped("scopes organization list/get to memberships", () => | |
| 542 | − Effect.gen(function* () { | |
| 543 | − const runtime = yield* makeRuntime; | |
| 544 | − | |
| 545 | − const orgOne = yield* withControlPlaneClient( | |
| 546 | − { runtime, accountId: "acc_1" }, | |
| 547 | − (client) => | |
| 548 | − client.organizations.create({ | |
| 549 | − payload: { name: "Org One" }, | |
| 550 | − }), | |
| 551 | − ); | |
| 552 | − | |
| 553 | − const orgTwo = yield* withControlPlaneClient( | |
| 554 | − { runtime, accountId: "acc_2" }, | |
| 555 | − (client) => | |
| 556 | − client.organizations.create({ | |
| 557 | − payload: { name: "Org Two" }, | |
| 558 | − }), | |
| 559 | − ); | |
| 560 | − | |
| 561 | − const listForAcc1 = yield* withControlPlaneClient( | |
| 562 | − { runtime, accountId: "acc_1" }, | |
| 563 | − (client) => client.organizations.list({}), | |
| 564 | − ); | |
| 565 | − | |
| 566 | − expect(listForAcc1.length).toBe(1); | |
| 567 | − | |
| 568 | − const getOtherOrgError = yield* expectLeft( | |
| 569 | − withControlPlaneClient( | |
| 570 | − { runtime, accountId: "acc_1" }, | |
| 571 | − (client) => | |
| 572 | − client.organizations.get({ | |
| 573 | − path: { organizationId: orgTwo.id }, | |
| 574 | − }), | |
| 575 | − ), | |
| 576 | − ); | |
| 577 | − | |
| 578 | − assertTrue(orgOne.id.length > 0); | |
| 579 | − expect(getOtherOrgError._tag).toBe("ControlPlaneNotFoundError"); | |
| 580 | − }), | |
| 581 | − ); | |
| 582 | − | |
| 583 | − it.scoped("prevents viewers from workspace manage actions", () => | |
| 584 | − Effect.gen(function* () { | |
| 585 | − const runtime = yield* makeRuntime; | |
| 586 | − | |
| 587 | − const organization = yield* withControlPlaneClient( | |
| 588 | − { runtime, accountId: "owner_acc" }, | |
| 589 | − (client) => | |
| 590 | − client.organizations.create({ | |
| 591 | − payload: { name: "Secured Org" }, | |
| 592 | − }), | |
| 593 | − ); | |
| 594 | − | |
| 595 | − const workspace = yield* withControlPlaneClient( | |
| 596 | − { runtime, accountId: "owner_acc" }, | |
| 597 | − (client) => | |
| 598 | − client.workspaces.create({ | |
| 599 | − path: { organizationId: organization.id }, | |
| 600 | − payload: { name: "Secured WS" }, | |
| 601 | − }), | |
| 602 | − ); | |
| 603 | − | |
| 604 | − yield* withControlPlaneClient( | |
| 605 | − { runtime, accountId: "owner_acc" }, | |
| 606 | − (client) => | |
| 607 | − client.memberships.create({ | |
| 608 | − path: { organizationId: organization.id }, | |
| 609 | − payload: { | |
| 610 | − accountId: "viewer_acc" as AccountId, | |
| 611 | − role: "viewer", | |
| 612 | − status: "active", | |
| 613 | − }, | |
| 614 | − }), | |
| 615 | − ); | |
| 616 | − | |
| 617 | − const viewerDeleteError = yield* expectLeft( | |
| 618 | − withControlPlaneClient( | |
| 619 | − { runtime, accountId: "viewer_acc" }, | |
| 620 | − (client) => | |
| 621 | − client.workspaces.remove({ | |
| 622 | − path: { workspaceId: workspace.id }, | |
| 623 | − }), | |
| 624 | − ), | |
| 625 | − ); | |
| 626 | − | |
| 627 | − expect(viewerDeleteError._tag).toBe("ControlPlaneForbiddenError"); | |
| 628 | − }), | |
| 629 | − ); | |
| 630 | − | |
| 631 | − it.scoped("blocks organization manage across tenants", () => | |
| 632 | − Effect.gen(function* () { | |
| 633 | − const runtime = yield* makeRuntime; | |
| 634 | − | |
| 635 | − const orgA = yield* withControlPlaneClient( | |
| 636 | − { runtime, accountId: "acc_a" }, | |
| 637 | − (client) => | |
| 638 | − client.organizations.create({ | |
| 639 | − payload: { name: "Org A" }, | |
| 640 | − }), | |
| 641 | − ); | |
| 642 | − | |
| 643 | − const orgB = yield* withControlPlaneClient( | |
| 644 | − { runtime, accountId: "acc_b" }, | |
| 645 | − (client) => | |
| 646 | − client.organizations.create({ | |
| 647 | − payload: { name: "Org B" }, | |
| 648 | − }), | |
| 649 | − ); | |
| 650 | − | |
| 651 | − const patchOtherOrgError = yield* expectLeft( | |
| 652 | − withControlPlaneClient( | |
| 653 | − { runtime, accountId: "acc_a" }, | |
| 654 | − (client) => | |
| 655 | − client.organizations.update({ | |
| 656 | − path: { organizationId: orgB.id }, | |
| 657 | − payload: { name: "Renamed" }, | |
| 658 | − }), | |
| 659 | − ), | |
| 660 | − ); | |
| 661 | − assertTrue(orgA.id.length > 0); | |
| 662 | − expect(patchOtherOrgError._tag).toBe("ControlPlaneForbiddenError"); | |
| 663 | − | |
| 664 | − const deleteOtherOrgError = yield* expectLeft( | |
| 665 | − withControlPlaneClient( | |
| 666 | − { runtime, accountId: "acc_a" }, | |
| 667 | − (client) => | |
| 668 | − client.organizations.remove({ | |
| 669 | − path: { organizationId: orgB.id }, | |
| 670 | − }), | |
| 671 | − ), | |
| 672 | − ); | |
| 673 | − expect(deleteOtherOrgError._tag).toBe("ControlPlaneForbiddenError"); | |
| 674 | − }), | |
| 675 | − ); | |
| 676 | − | |
| 677 | − it.scoped("suspended creators cannot manage previously created workspaces", () => | |
| 678 | − Effect.gen(function* () { | |
| 679 | − const runtime = yield* makeRuntime; | |
| 680 | − | |
| 681 | − const organization = yield* withControlPlaneClient( | |
| 682 | − { runtime, accountId: "creator_acc" }, | |
| 683 | − (client) => | |
| 684 | − client.organizations.create({ | |
| 685 | − payload: { name: "Creator Org" }, | |
| 686 | − }), | |
| 687 | − ); | |
| 688 | − | |
| 689 | − const workspace = yield* withControlPlaneClient( | |
| 690 | − { runtime, accountId: "creator_acc" }, | |
| 691 | − (client) => | |
| 692 | − client.workspaces.create({ | |
| 693 | − path: { organizationId: organization.id }, | |
| 694 | − payload: { name: "Creator WS" }, | |
| 695 | − }), | |
| 696 | − ); | |
| 697 | − | |
| 698 | − yield* withControlPlaneClient( | |
| 699 | − { runtime, accountId: "creator_acc" }, | |
| 700 | − (client) => | |
| 701 | − client.memberships.update({ | |
| 702 | − path: { | |
| 703 | − organizationId: organization.id, | |
| 704 | − accountId: "creator_acc" as AccountId, | |
| 705 | − }, | |
| 706 | − payload: { status: "suspended" }, | |
| 707 | − }), | |
| 708 | − ); | |
| 709 | − | |
| 710 | − const deleteWorkspaceError = yield* expectLeft( | |
| 711 | − withControlPlaneClient( | |
| 712 | − { runtime, accountId: "creator_acc" }, | |
| 713 | − (client) => | |
| 714 | − client.workspaces.remove({ | |
| 715 | − path: { workspaceId: workspace.id }, | |
| 716 | − }), | |
| 717 | − ), | |
| 718 | − ); | |
| 719 | − | |
| 720 | − expect(deleteWorkspaceError._tag).toBe("ControlPlaneForbiddenError"); | |
| 721 | − }), | |
| 722 | − ); | |
| 723 | − | |
| 724 | − it.scoped("deleting organization cascades and blocks stale org operations", () => | |
| 725 | − Effect.gen(function* () { | |
| 726 | − const runtime = yield* makeRuntime; | |
| 727 | − | |
| 728 | − const organization = yield* withControlPlaneClient( | |
| 729 | − { runtime, accountId: "acc_del" }, | |
| 730 | − (client) => | |
| 731 | − client.organizations.create({ | |
| 732 | − payload: { name: "Delete Me" }, | |
| 733 | − }), | |
| 734 | − ); | |
| 735 | − | |
| 736 | − const workspace = yield* withControlPlaneClient( | |
| 737 | − { runtime, accountId: "acc_del" }, | |
| 738 | − (client) => | |
| 739 | − client.workspaces.create({ | |
| 740 | − path: { organizationId: organization.id }, | |
| 741 | − payload: { name: "Delete WS" }, | |
| 742 | − }), | |
| 743 | − ); | |
| 744 | − | |
| 745 | − const deleteOrg = yield* withControlPlaneClient( | |
| 746 | − { runtime, accountId: "acc_del" }, | |
| 747 | − (client) => | |
| 748 | − client.organizations.remove({ | |
| 749 | − path: { organizationId: organization.id }, | |
| 750 | − }), | |
| 751 | − ); | |
| 752 | − assertTrue(deleteOrg.removed); | |
| 753 | − | |
| 754 | − const listStaleWorkspacesError = yield* expectLeft( | |
| 755 | − withControlPlaneClient( | |
| 756 | − { runtime, accountId: "acc_del" }, | |
| 757 | − (client) => | |
| 758 | − client.workspaces.list({ | |
| 759 | − path: { organizationId: organization.id }, | |
| 760 | − }), | |
| 761 | − ), | |
| 762 | − ); | |
| 763 | − expect(listStaleWorkspacesError._tag).toBe("ControlPlaneForbiddenError"); | |
| 764 | − | |
| 765 | − const createStaleWorkspaceError = yield* expectLeft( | |
| 766 | − withControlPlaneClient( | |
| 767 | − { runtime, accountId: "acc_del" }, | |
| 768 | − (client) => | |
| 769 | − client.workspaces.create({ | |
| 770 | − path: { organizationId: organization.id }, | |
| 771 | − payload: { name: "Should Fail" }, | |
| 772 | − }), | |
| 773 | − ), | |
| 774 | − ); | |
| 775 | − expect(createStaleWorkspaceError._tag).toBe("ControlPlaneForbiddenError"); | |
| 776 | − | |
| 777 | − const getDeletedWorkspaceError = yield* expectLeft( | |
| 778 | − withControlPlaneClient( | |
| 779 | − { runtime, accountId: "acc_del" }, | |
| 780 | − (client) => | |
| 781 | − client.workspaces.get({ | |
| 782 | − path: { workspaceId: workspace.id }, | |
| 783 | − }), | |
| 784 | − ), | |
| 785 | − ); | |
| 786 | − expect(getDeletedWorkspaceError._tag).toBe("ControlPlaneForbiddenError"); | |
| 787 | − }), | |
| 788 | − ); | |
| 789 | − | |
| 790 | − it.scoped("rejects unauthenticated calls", () => | |
| 791 | − Effect.gen(function* () { | |
| 792 | − const runtime = yield* makeRuntime; | |
| 793 | − | |
| 794 | − const error = yield* expectLeft( | |
| 795 | − withControlPlaneClient({ runtime }, (client) => client.organizations.list({})), | |
| 796 | − ); | |
| 797 | − | |
| 798 | − expect(error._tag).toBe("ControlPlaneUnauthorizedError"); | |
| 799 | − }), | |
| 800 | − ); | |
| 801 | 470 | }); |
| @@ -1,3 +1,6 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 1 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 5 | import * as Effect from "effect/Effect"; |
| 3 | 6 | import * as Schema from "effect/Schema"; |
| @@ -6,7 +9,7 @@ import { makeToolInvokerFromTools } from "@executor/codemode-core"; · 2 unmodified lines | ||
| 6 | 9 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; |
| 7 | 10 | |
| 8 | 11 | import { |
| 9 | − createSqlControlPlaneRuntime, | |
| 12 | + createControlPlaneRuntime, | |
| 10 | 13 | } from "./index"; |
| 11 | 14 | import { withControlPlaneClient } from "./test-http-client"; |
| 12 | 15 | |
| @@ -37,8 +40,9 @@ const makeExecutionResolver = () => { · 24 unmodified lines | ||
| 37 | 40 | }; |
| 38 | 41 | |
| 39 | 42 | const makeRuntime = Effect.acquireRelease( |
| 40 | − createSqlControlPlaneRuntime({ | |
| 43 | + createControlPlaneRuntime({ | |
| 41 | 44 | localDataDir: ":memory:", |
| 45 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")), | |
| 42 | 46 | executionResolver: makeExecutionResolver(), |
| 43 | 47 | }), |
| 44 | 48 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| @@ -88,5 +92,6 @@ describe("execution-http", () => { · 43 unmodified lines | ||
| 88 | 92 | expect(getExecution.execution.status).toBe("completed"); |
| 89 | 93 | expect(getExecution.pendingInteraction).toBeNull(); |
| 90 | 94 | }), |
| 95 | + 60_000, | |
| 91 | 96 | ); |
| 92 | 97 | }); |
| @@ -5,6 +5,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines | ||
| 5 | 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 8 | +import { mkdtempSync } from "node:fs"; | |
| 9 | +import { tmpdir } from "node:os"; | |
| 10 | +import { join } from "node:path"; | |
| 8 | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | 12 | import { assertTrue } from "@effect/vitest/utils"; |
| 10 | 13 | import * as Effect from "effect/Effect"; |
| @@ -15,7 +18,7 @@ import { discoverMcpToolsFromConnector } from "@executor/codemode-mcp"; · 4 unmodified lines | ||
| 15 | 18 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; |
| 16 | 19 | |
| 17 | 20 | import { |
| 18 | − createSqlControlPlaneRuntime, | |
| 21 | + createControlPlaneRuntime, | |
| 19 | 22 | type ResolveExecutionEnvironment, |
| 20 | 23 | } from "./index"; |
| 21 | 24 | import { withControlPlaneClient } from "./test-http-client"; |
| @@ -281,8 +284,9 @@ describe("execution-mcp-resume", () => { · 259 unmodified lines | ||
| 281 | 284 | Effect.gen(function* () { |
| 282 | 285 | const mcpServer = yield* makeFormElicitationServer; |
| 283 | 286 | const runtime = yield* Effect.acquireRelease( |
| 284 | − createSqlControlPlaneRuntime({ | |
| 287 | + createControlPlaneRuntime({ | |
| 285 | 288 | localDataDir: ":memory:", |
| 289 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")), | |
| 286 | 290 | executionResolver: makeMcpExecutionResolver(mcpServer.endpoint), |
| 287 | 291 | }), |
| 288 | 292 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| @@ -354,5 +358,6 @@ describe("execution-mcp-resume", () => { · 65 unmodified lines | ||
| 354 | 358 | }), |
| 355 | 359 | ); |
| 356 | 360 | }), |
| 361 | + 60_000, | |
| 357 | 362 | ); |
| 358 | 363 | }); |
| @@ -2,7 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | ControlPlaneBadRequestError, |
| 3 | 3 | ControlPlaneNotFoundError, |
| 4 | 4 | ControlPlaneStorageError, |
| 5 | −} from "#api"; | |
| 5 | +} from "../api/errors"; | |
| 6 | 6 | import type { |
| 7 | 7 | CreateExecutionPayload, |
| 8 | 8 | ResumeExecutionPayload, |
| @@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines | ||
| 19 | 19 | import * as Cause from "effect/Cause"; |
| 20 | 20 | import * as Exit from "effect/Exit"; |
| 21 | 21 | import * as Schema from "effect/Schema"; |
| 22 | +import type { RuntimeLocalWorkspaceState } from "./local-runtime-context"; | |
| 23 | +import { RuntimeLocalWorkspaceService } from "./local-runtime-context"; | |
| 22 | 24 | |
| 23 | 25 | /** Run an Effect as a Promise, preserving the original error (not FiberFailure). */ |
| 24 | −const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => { | |
| 25 | − const exit = await Effect.runPromiseExit(effect); | |
| 26 | +const runEffect = async <A>( | |
| 27 | + effect: Effect.Effect<A, unknown, never>, | |
| 28 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null = null, | |
| 29 | +): Promise<A> => { | |
| 30 | + const provided = | |
| 31 | + runtimeLocalWorkspace === null | |
| 32 | + ? effect | |
| 33 | + : effect.pipe( | |
| 34 | + Effect.provideService( | |
| 35 | + RuntimeLocalWorkspaceService, | |
| 36 | + runtimeLocalWorkspace, | |
| 37 | + ), | |
| 38 | + ); | |
| 39 | + const exit = await Effect.runPromiseExit(provided); | |
| 26 | 40 | if (Exit.isSuccess(exit)) return exit.value; |
| 27 | 41 | throw Cause.squash(exit.cause); |
| 28 | 42 | }; |
| @@ -106,6 +120,9 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 77 unmodified lines | ||
| 106 | 120 | return trimmed.length > 0 ? trimmed : null; |
| 107 | 121 | }; |
| 108 | 122 | |
| 123 | +const toSerializableValue = <A>(value: A): A => | |
| 124 | + JSON.parse(JSON.stringify(value)) as A; | |
| 125 | + | |
| 109 | 126 | const resolveLocalCredentialUrl = (input: { |
| 110 | 127 | baseUrl: string; |
| 111 | 128 | workspaceId: WorkspaceId; |
| @@ -205,6 +222,7 @@ export const createExecutorToolMap = (input: { · 93 unmodified lines | ||
| 205 | 222 | workspaceId: WorkspaceId; |
| 206 | 223 | accountId: AccountId; |
| 207 | 224 | sourceAuthService: RuntimeSourceAuthService; |
| 225 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | |
| 208 | 226 | }): ToolMap => ({ |
| 209 | 227 | "executor.sources.add": toTool({ |
| 210 | 228 | tool: { |
| @@ -281,10 +299,11 @@ export const createExecutorToolMap = (input: { · 70 unmodified lines | ||
| 281 | 299 | } |
| 282 | 300 | : undefined, |
| 283 | 301 | ), |
| 302 | + input.runtimeLocalWorkspace, | |
| 284 | 303 | ); |
| 285 | 304 | |
| 286 | 305 | if (result.kind === "connected") { |
| 287 | − return result.source; | |
| 306 | + return toSerializableValue(result.source); | |
| 288 | 307 | } |
| 289 | 308 | |
| 290 | 309 | if (result.kind === "credential_required") { |
| @@ -313,6 +332,7 @@ export const createExecutorToolMap = (input: { · 22 unmodified lines | ||
| 313 | 332 | invocation: context?.invocation, |
| 314 | 333 | onElicitation: context?.onElicitation, |
| 315 | 334 | }), |
| 335 | + input.runtimeLocalWorkspace, | |
| 316 | 336 | ); |
| 317 | 337 | |
| 318 | 338 | pendingArgs = pendingResult.credentialSlot === "import" |
| @@ -342,10 +362,11 @@ export const createExecutorToolMap = (input: { · 23 unmodified lines | ||
| 342 | 362 | } |
| 343 | 363 | : undefined, |
| 344 | 364 | ), |
| 365 | + input.runtimeLocalWorkspace, | |
| 345 | 366 | ); |
| 346 | 367 | |
| 347 | 368 | if (completed.kind === "connected") { |
| 348 | − return completed.source; | |
| 369 | + return toSerializableValue(completed.source); | |
| 349 | 370 | } |
| 350 | 371 | |
| 351 | 372 | if (completed.kind === "credential_required") { |
| @@ -385,19 +406,22 @@ export const createExecutorToolMap = (input: { · 33 unmodified lines | ||
| 385 | 406 | elicitationId: result.sessionId, |
| 386 | 407 | }, |
| 387 | 408 | }), |
| 409 | + input.runtimeLocalWorkspace, | |
| 388 | 410 | ); |
| 389 | 411 | |
| 390 | 412 | if (response.action !== "accept") { |
| 391 | 413 | throw new Error(`Source add was not completed for ${result.source.id}`); |
| 392 | 414 | } |
| 393 | 415 | |
| 394 | − return await runEffect( | |
| 416 | + const connected = await runEffect( | |
| 395 | 417 | input.sourceAuthService.getSourceById({ |
| 396 | 418 | workspaceId: input.workspaceId, |
| 397 | 419 | sourceId: result.source.id, |
| 398 | 420 | actorAccountId: input.accountId, |
| 399 | 421 | }), |
| 422 | + input.runtimeLocalWorkspace, | |
| 400 | 423 | ); |
| 424 | + return toSerializableValue(connected); | |
| 401 | 425 | }, |
| 402 | 426 | }, |
| 403 | 427 | metadata: { |
| @@ -6,13 +6,12 @@ import * as Scope from "effect/Scope"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | 8 | type ControlPlaneApiRuntimeContext, |
| 9 | − ControlPlaneActorResolver, | |
| 10 | − type ControlPlaneActorResolverShape, | |
| 11 | 9 | } from "#api"; |
| 12 | 10 | import { |
| 13 | 11 | SqlControlPlanePersistenceLive, |
| 14 | 12 | SqlControlPlanePersistenceService, |
| 15 | 13 | SqlControlPlaneRowsLive, |
| 14 | + SqlControlPlaneRowsService, | |
| 16 | 15 | SqlPersistenceBootstrapError, |
| 17 | 16 | type CreateSqlRuntimeOptions, |
| 18 | 17 | type SqlControlPlanePersistence, |
| @@ -20,43 +19,48 @@ import { · 1 unmodified line | ||
| 20 | 19 | import type { LocalInstallation } from "#schema"; |
| 21 | 20 | |
| 22 | 21 | import { |
| 23 | − ControlPlaneAuthHeaders, | |
| 24 | − RuntimeActorResolverLive, | |
| 25 | − createHeaderActorResolver, | |
| 26 | −} from "./actor-resolver"; | |
| 27 | −import { type ResolveExecutionEnvironment } from "./execution-state"; | |
| 22 | + type ResolveExecutionEnvironment, | |
| 23 | +} from "./execution-state"; | |
| 28 | 24 | import { |
| 25 | + createLiveExecutionManager, | |
| 29 | 26 | LiveExecutionManagerLive, |
| 30 | 27 | LiveExecutionManagerService, |
| 31 | 28 | } from "./live-execution"; |
| 32 | 29 | import { getOrProvisionLocalInstallation } from "./local-installation"; |
| 30 | +import { | |
| 31 | + loadLocalExecutorConfig, | |
| 32 | + resolveLocalWorkspaceContext, | |
| 33 | +} from "./local-config"; | |
| 34 | +import { RuntimeLocalWorkspaceService } from "./local-runtime-context"; | |
| 35 | +import { synchronizeLocalWorkspaceState } from "./local-workspace-sync"; | |
| 33 | 36 | import { |
| 34 | 37 | ControlPlaneStore, |
| 35 | 38 | ControlPlaneStoreLive, |
| 36 | 39 | } from "./store"; |
| 37 | 40 | import { |
| 41 | + createRuntimeSourceAuthService, | |
| 38 | 42 | RuntimeSourceAuthServiceLive, |
| 39 | 43 | RuntimeSourceAuthServiceTag, |
| 40 | 44 | } from "./source-auth-service"; |
| 41 | 45 | import type { ResolveSecretMaterial } from "./secret-material-providers"; |
| 46 | +import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | |
| 42 | 47 | import { |
| 48 | + createWorkspaceExecutionEnvironmentResolver, | |
| 43 | 49 | RuntimeExecutionResolverLive, |
| 44 | 50 | RuntimeExecutionResolverService, |
| 45 | 51 | } from "./workspace-execution-environment"; |
| 46 | 52 | |
| 47 | −export { | |
| 48 | − ControlPlaneAuthHeaders, | |
| 49 | − createHeaderActorResolver, | |
| 50 | −}; | |
| 51 | − | |
| 52 | 53 | export * from "./execution-state"; |
| 53 | 54 | export * from "./executor-tools"; |
| 54 | 55 | export * from "./live-execution"; |
| 56 | +export * from "./local-config"; | |
| 55 | 57 | export * from "./local-installation"; |
| 58 | +export * from "./local-source-artifacts"; | |
| 56 | 59 | export * from "./schema-type-signature"; |
| 57 | 60 | export * from "./source-auth-service"; |
| 58 | 61 | export * from "./secret-material-providers"; |
| 59 | 62 | export * from "./source-credential-interactions"; |
| 63 | +export * from "./source-adapters/mcp"; | |
| 60 | 64 | export * from "./store"; |
| 61 | 65 | export * from "./workspace-execution-environment"; |
| 62 | 66 | export * from "./source-inspection"; |
| @@ -64,21 +68,21 @@ export * from "./source-discovery"; · 1 unmodified line | ||
| 64 | 68 | export * from "./execution-service"; |
| 65 | 69 | |
| 66 | 70 | export type RuntimeControlPlaneOptions = { |
| 67 | − actorResolver?: ControlPlaneActorResolverShape; | |
| 68 | 71 | executionResolver?: ResolveExecutionEnvironment; |
| 69 | 72 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 70 | 73 | getLocalServerBaseUrl?: () => string | undefined; |
| 74 | + workspaceRoot?: string; | |
| 71 | 75 | }; |
| 72 | 76 | |
| 73 | 77 | const detailsFromCause = (cause: unknown): string => |
| 74 | 78 | cause instanceof Error ? cause.message : String(cause); |
| 75 | 79 | |
| 76 | −const toLocalInstallationBootstrapError = ( | |
| 80 | +const toLocalRuntimeBootstrapError = ( | |
| 77 | 81 | cause: unknown, |
| 78 | 82 | ): SqlPersistenceBootstrapError => { |
| 79 | 83 | const details = detailsFromCause(cause); |
| 80 | 84 | return new SqlPersistenceBootstrapError({ |
| 81 | − message: `Failed provisioning local installation: ${details}`, | |
| 85 | + message: `Failed initializing local runtime: ${details}`, | |
| 82 | 86 | details, |
| 83 | 87 | }); |
| 84 | 88 | }; |
| @@ -93,18 +97,18 @@ export type RuntimeControlPlaneLayer = Layer.Layer< · 8 unmodified lines | ||
| 93 | 97 | >; |
| 94 | 98 | |
| 95 | 99 | const runtimeContextTags = [ |
| 96 | − ControlPlaneActorResolver, | |
| 97 | 100 | ControlPlaneStore, |
| 98 | 101 | LiveExecutionManagerService, |
| 102 | + RuntimeLocalWorkspaceService, | |
| 99 | 103 | RuntimeSourceAuthServiceTag, |
| 100 | 104 | RuntimeExecutionResolverService, |
| 101 | 105 | ] as const; |
| 102 | 106 | |
| 103 | 107 | const createRuntimeLayerFromContext = ( |
| 104 | 108 | context: Context.Context< |
| 105 | − ControlPlaneActorResolver | |
| 106 | − | ControlPlaneStore | |
| 109 | + ControlPlaneStore | |
| 107 | 110 | | LiveExecutionManagerService |
| 111 | + | RuntimeLocalWorkspaceService | |
| 108 | 112 | | RuntimeSourceAuthServiceTag |
| 109 | 113 | | RuntimeExecutionResolverService |
| 110 | 114 | >, |
| @@ -112,9 +116,9 @@ const createRuntimeLayerFromContext = ( · 1 unmodified line | ||
| 112 | 116 | const runtimeContext = context.pipe( |
| 113 | 117 | Context.pick(...runtimeContextTags), |
| 114 | 118 | ) as Context.Context< |
| 115 | − ControlPlaneActorResolver | |
| 116 | − | ControlPlaneStore | |
| 119 | + ControlPlaneStore | |
| 117 | 120 | | LiveExecutionManagerService |
| 121 | + | RuntimeLocalWorkspaceService | |
| 118 | 122 | | RuntimeSourceAuthServiceTag |
| 119 | 123 | | RuntimeExecutionResolverService |
| 120 | 124 | >; |
| @@ -140,54 +144,133 @@ export const createRuntimeControlPlaneLayer = ( · 19 unmodified lines | ||
| 140 | 144 | |
| 141 | 145 | return Layer.mergeAll( |
| 142 | 146 | ControlPlaneStoreLive, |
| 143 | − RuntimeActorResolverLive(options.actorResolver), | |
| 144 | 147 | liveExecutionManagerLayer, |
| 145 | 148 | sourceAuthLayer, |
| 146 | 149 | executionResolverLayer, |
| 147 | 150 | ); |
| 148 | 151 | }; |
| 149 | 152 | |
| 150 | −export type SqlControlPlaneRuntime = { | |
| 153 | +export type ControlPlaneRuntime = { | |
| 151 | 154 | persistence: SqlControlPlanePersistence; |
| 152 | 155 | localInstallation: LocalInstallation; |
| 153 | 156 | runtimeLayer: RuntimeControlPlaneLayer; |
| 154 | 157 | close: () => Promise<void>; |
| 155 | 158 | }; |
| 156 | 159 | |
| 157 | −export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions | |
| 160 | +export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions | |
| 158 | 161 | & RuntimeControlPlaneOptions; |
| 159 | 162 | |
| 160 | −export const createSqlControlPlaneRuntime = ( | |
| 161 | − options: CreateSqlControlPlaneRuntimeOptions, | |
| 162 | −): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> => | |
| 163 | +export const createControlPlaneRuntime = ( | |
| 164 | + options: CreateControlPlaneRuntimeOptions, | |
| 165 | +): Effect.Effect<ControlPlaneRuntime, SqlPersistenceBootstrapError> => | |
| 163 | 166 | Effect.gen(function* () { |
| 164 | 167 | const scope = yield* Scope.make(); |
| 165 | 168 | const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe( |
| 166 | 169 | Layer.provideMerge(SqlControlPlanePersistenceLive(options)), |
| 167 | 170 | ); |
| 168 | − const runtimeLayer = createRuntimeControlPlaneLayer(options).pipe( | |
| 169 | − Layer.provideMerge(persistenceAndRowsLayer), | |
| 171 | + const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe( | |
| 172 | + Effect.catchAll((error) => | |
| 173 | + closeScope(scope).pipe( | |
| 174 | + Effect.zipRight(Effect.fail(error)), | |
| 175 | + )), | |
| 170 | 176 | ); |
| 171 | 177 | |
| 172 | − const context = yield* Layer.buildWithScope(runtimeLayer, scope).pipe( | |
| 178 | + const persistence = Context.get(baseContext, SqlControlPlanePersistenceService); | |
| 179 | + const rows = Context.get(baseContext, SqlControlPlaneRowsService); | |
| 180 | + | |
| 181 | + const localWorkspaceContext = yield* resolveLocalWorkspaceContext({ | |
| 182 | + workspaceRoot: options.workspaceRoot, | |
| 183 | + }).pipe( | |
| 184 | + Effect.mapError(toLocalRuntimeBootstrapError), | |
| 173 | 185 | Effect.catchAll((error) => |
| 174 | 186 | closeScope(scope).pipe( |
| 175 | 187 | Effect.zipRight(Effect.fail(error)), |
| 176 | 188 | )), |
| 177 | 189 | ); |
| 178 | 190 | |
| 179 | − const persistence = Context.get(context, SqlControlPlanePersistenceService); | |
| 180 | − const store = Context.get(context, ControlPlaneStore); | |
| 181 | − const concreteRuntimeLayer = createRuntimeLayerFromContext(context); | |
| 191 | + const localInstallation = yield* getOrProvisionLocalInstallation({ | |
| 192 | + context: localWorkspaceContext, | |
| 193 | + }).pipe( | |
| 194 | + Effect.mapError(toLocalRuntimeBootstrapError), | |
| 195 | + Effect.catchAll((error) => | |
| 196 | + closeScope(scope).pipe( | |
| 197 | + Effect.zipRight(Effect.fail(error)), | |
| 198 | + )), | |
| 199 | + ); | |
| 182 | 200 | |
| 183 | − const localInstallation = yield* getOrProvisionLocalInstallation(store).pipe( | |
| 184 | − Effect.mapError(toLocalInstallationBootstrapError), | |
| 201 | + const loadedLocalConfig = yield* loadLocalExecutorConfig(localWorkspaceContext).pipe( | |
| 202 | + Effect.mapError(toLocalRuntimeBootstrapError), | |
| 185 | 203 | Effect.catchAll((error) => |
| 186 | 204 | closeScope(scope).pipe( |
| 187 | 205 | Effect.zipRight(Effect.fail(error)), |
| 188 | 206 | )), |
| 189 | 207 | ); |
| 190 | 208 | |
| 209 | + const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({ | |
| 210 | + context: localWorkspaceContext, | |
| 211 | + loadedConfig: loadedLocalConfig, | |
| 212 | + }).pipe( | |
| 213 | + Effect.mapError(toLocalRuntimeBootstrapError), | |
| 214 | + Effect.catchAll((error) => | |
| 215 | + closeScope(scope).pipe( | |
| 216 | + Effect.zipRight(Effect.fail(error)), | |
| 217 | + )), | |
| 218 | + ); | |
| 219 | + | |
| 220 | + const resolveSecretMaterial = | |
| 221 | + options.resolveSecretMaterial | |
| 222 | + ?? createDefaultSecretMaterialResolver({ | |
| 223 | + rows, | |
| 224 | + localConfig: effectiveLocalConfig, | |
| 225 | + workspaceRoot: localWorkspaceContext.workspaceRoot, | |
| 226 | + }); | |
| 227 | + | |
| 228 | + const liveExecutionManager = createLiveExecutionManager(); | |
| 229 | + const sourceAuthService = createRuntimeSourceAuthService({ | |
| 230 | + rows, | |
| 231 | + liveExecutionManager, | |
| 232 | + getLocalServerBaseUrl: options.getLocalServerBaseUrl, | |
| 233 | + localConfig: effectiveLocalConfig, | |
| 234 | + workspaceRoot: localWorkspaceContext.workspaceRoot, | |
| 235 | + localWorkspaceState: { | |
| 236 | + context: localWorkspaceContext, | |
| 237 | + installation: { | |
| 238 | + workspaceId: localInstallation.workspaceId, | |
| 239 | + accountId: localInstallation.accountId, | |
| 240 | + }, | |
| 241 | + loadedConfig: { | |
| 242 | + ...loadedLocalConfig, | |
| 243 | + config: effectiveLocalConfig, | |
| 244 | + }, | |
| 245 | + }, | |
| 246 | + }); | |
| 247 | + const executionResolver = | |
| 248 | + options.executionResolver | |
| 249 | + ?? createWorkspaceExecutionEnvironmentResolver({ | |
| 250 | + rows, | |
| 251 | + sourceAuthService, | |
| 252 | + resolveSecretMaterial, | |
| 253 | + }); | |
| 254 | + | |
| 255 | + const runtimeContext = Context.empty().pipe( | |
| 256 | + Context.add(ControlPlaneStore, rows), | |
| 257 | + Context.add(LiveExecutionManagerService, liveExecutionManager), | |
| 258 | + Context.add(RuntimeLocalWorkspaceService, { | |
| 259 | + context: localWorkspaceContext, | |
| 260 | + installation: { | |
| 261 | + workspaceId: localInstallation.workspaceId, | |
| 262 | + accountId: localInstallation.accountId, | |
| 263 | + }, | |
| 264 | + loadedConfig: { | |
| 265 | + ...loadedLocalConfig, | |
| 266 | + config: effectiveLocalConfig, | |
| 267 | + }, | |
| 268 | + }), | |
| 269 | + Context.add(RuntimeSourceAuthServiceTag, sourceAuthService), | |
| 270 | + Context.add(RuntimeExecutionResolverService, executionResolver), | |
| 271 | + ); | |
| 272 | + const concreteRuntimeLayer = createRuntimeLayerFromContext(runtimeContext); | |
| 273 | + | |
| 191 | 274 | return { |
| 192 | 275 | persistence, |
| 193 | 276 | localInstallation, |
| @@ -1,9 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import type { Policy } from "#schema"; | |
| 3 | +import type { LocalWorkspacePolicy } from "#schema"; | |
| 4 | 4 | import { |
| 5 | − AccountIdSchema, | |
| 6 | − OrganizationIdSchema, | |
| 7 | 5 | PolicyIdSchema, |
| 8 | 6 | SourceIdSchema, |
| 9 | 7 | WorkspaceIdSchema, |
| @@ -15,7 +13,6 @@ import { · 5 unmodified lines | ||
| 15 | 13 | } from "./invocation-policy-engine"; |
| 16 | 14 | |
| 17 | 15 | const workspaceId = WorkspaceIdSchema.make("ws_policy_engine"); |
| 18 | −const organizationId = OrganizationIdSchema.make("org_policy_engine"); | |
| 19 | 16 | const sourceId = SourceIdSchema.make("src_policy_engine"); |
| 20 | 17 | |
| 21 | 18 | const now = 1_700_000_000_000; |
| @@ -32,20 +29,14 @@ const baseDescriptor: InvocationDescriptor = { · 10 unmodified lines | ||
| 32 | 29 | }; |
| 33 | 30 | |
| 34 | 31 | const basePolicy = ( |
| 35 | − patch: Partial<Policy> = {}, | |
| 36 | −): Policy => ({ | |
| 32 | + patch: Partial<LocalWorkspacePolicy> = {}, | |
| 33 | +): LocalWorkspacePolicy => ({ | |
| 37 | 34 | id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`), |
| 38 | − scopeType: "workspace", | |
| 39 | − organizationId, | |
| 35 | + key: "vercel-dns", | |
| 40 | 36 | workspaceId, |
| 41 | − targetAccountId: null, | |
| 42 | − clientId: null, | |
| 43 | − resourceType: "tool_path", | |
| 44 | 37 | resourcePattern: "vercel.api.dns.createRecord", |
| 45 | − matchType: "exact", | |
| 46 | 38 | effect: "allow", |
| 47 | 39 | approvalMode: "auto", |
| 48 | − argumentConditionsJson: null, | |
| 49 | 40 | priority: 0, |
| 50 | 41 | enabled: true, |
| 51 | 42 | createdAt: now, |
| @@ -54,7 +45,7 @@ const basePolicy = ( · 2 unmodified lines | ||
| 54 | 45 | }); |
| 55 | 46 | |
| 56 | 47 | describe("invocation-policy-engine", () => { |
| 57 | − it("allows GET requests by default", () => { | |
| 48 | + it("allows read requests by default", () => { | |
| 58 | 49 | const decision = evaluateInvocationPolicy({ |
| 59 | 50 | descriptor: { |
| 60 | 51 | ...baseDescriptor, |
| @@ -67,107 +58,83 @@ describe("invocation-policy-engine", () => { · 6 unmodified lines | ||
| 67 | 58 | policies: [], |
| 68 | 59 | context: { |
| 69 | 60 | workspaceId, |
| 70 | − organizationId, | |
| 71 | − accountId: null, | |
| 72 | − clientId: null, | |
| 73 | 61 | }, |
| 74 | 62 | }); |
| 75 | 63 | |
| 76 | 64 | expect(decision.kind).toBe("allow"); |
| 77 | 65 | }); |
| 78 | 66 | |
| 79 | − it("requires interaction for mutating OpenAPI requests by default", () => { | |
| 67 | + it("requires interaction for mutating requests by default", () => { | |
| 80 | 68 | const decision = evaluateInvocationPolicy({ |
| 81 | 69 | descriptor: baseDescriptor, |
| 82 | 70 | args: { domain: "testing.executor.sh" }, |
| 83 | 71 | policies: [], |
| 84 | 72 | context: { |
| 85 | 73 | workspaceId, |
| 86 | − organizationId, | |
| 87 | − accountId: null, | |
| 88 | − clientId: null, | |
| 89 | 74 | }, |
| 90 | 75 | }); |
| 91 | 76 | |
| 92 | 77 | expect(decision.kind).toBe("require_interaction"); |
| 93 | 78 | }); |
| 94 | 79 | |
| 95 | − it("lets an organization policy allow a mutating request", () => { | |
| 80 | + it("allows a mutating request when a matching policy exists", () => { | |
| 96 | 81 | const decision = evaluateInvocationPolicy({ |
| 97 | 82 | descriptor: baseDescriptor, |
| 98 | 83 | args: { domain: "testing.executor.sh" }, |
| 99 | 84 | policies: [basePolicy({ |
| 100 | − id: PolicyIdSchema.make("pol_org_allow"), | |
| 101 | − scopeType: "organization", | |
| 102 | − workspaceId: null, | |
| 85 | + id: PolicyIdSchema.make("pol_allow"), | |
| 103 | 86 | })], |
| 104 | 87 | context: { |
| 105 | 88 | workspaceId, |
| 106 | − organizationId, | |
| 107 | − accountId: null, | |
| 108 | − clientId: null, | |
| 109 | 89 | }, |
| 110 | 90 | }); |
| 111 | 91 | |
| 112 | 92 | expect(decision.kind).toBe("allow"); |
| 113 | − expect(decision.matchedPolicyId).toBe("pol_org_allow"); | |
| 93 | + expect(decision.matchedPolicyId).toBe("pol_allow"); | |
| 114 | 94 | }); |
| 115 | 95 | |
| 116 | − it("prefers a more specific workspace deny over an organization allow", () => { | |
| 96 | + it("prefers a more specific deny over a broad allow", () => { | |
| 117 | 97 | const decision = evaluateInvocationPolicy({ |
| 118 | 98 | descriptor: baseDescriptor, |
| 119 | 99 | args: { domain: "testing.executor.sh" }, |
| 120 | 100 | policies: [ |
| 121 | 101 | basePolicy({ |
| 122 | − id: PolicyIdSchema.make("pol_org_allow"), | |
| 123 | − scopeType: "organization", | |
| 124 | − workspaceId: null, | |
| 125 | − approvalMode: "auto", | |
| 102 | + id: PolicyIdSchema.make("pol_allow_all_vercel"), | |
| 103 | + key: "allow-all-vercel", | |
| 104 | + resourcePattern: "vercel.api.*", | |
| 126 | 105 | effect: "allow", |
| 106 | + priority: 0, | |
| 127 | 107 | }), |
| 128 | 108 | basePolicy({ |
| 129 | − id: PolicyIdSchema.make("pol_ws_deny"), | |
| 109 | + id: PolicyIdSchema.make("pol_deny_create_record"), | |
| 110 | + key: "deny-create-record", | |
| 130 | 111 | effect: "deny", |
| 131 | − priority: 10, | |
| 112 | + priority: 1, | |
| 132 | 113 | }), |
| 133 | 114 | ], |
| 134 | 115 | context: { |
| 135 | 116 | workspaceId, |
| 136 | − organizationId, | |
| 137 | − accountId: null, | |
| 138 | − clientId: null, | |
| 139 | 117 | }, |
| 140 | 118 | }); |
| 141 | 119 | |
| 142 | 120 | expect(decision.kind).toBe("deny"); |
| 143 | − expect(decision.matchedPolicyId).toBe("pol_ws_deny"); | |
| 121 | + expect(decision.matchedPolicyId).toBe("pol_deny_create_record"); | |
| 144 | 122 | }); |
| 145 | 123 | |
| 146 | − it("prefers an account-targeted workspace allow over a generic workspace policy", () => { | |
| 124 | + it("ignores disabled policies", () => { | |
| 147 | 125 | const decision = evaluateInvocationPolicy({ |
| 148 | 126 | descriptor: baseDescriptor, |
| 149 | 127 | args: { domain: "testing.executor.sh" }, |
| 150 | − policies: [ | |
| 151 | − basePolicy({ | |
| 152 | − id: PolicyIdSchema.make("pol_ws_gate"), | |
| 153 | − approvalMode: "required", | |
| 154 | − }), | |
| 155 | − basePolicy({ | |
| 156 | − id: PolicyIdSchema.make("pol_ws_user_allow"), | |
| 157 | − targetAccountId: AccountIdSchema.make("acc_user"), | |
| 158 | − approvalMode: "auto", | |
| 159 | − priority: 1, | |
| 160 | − }), | |
| 161 | − ], | |
| 128 | + policies: [basePolicy({ | |
| 129 | + id: PolicyIdSchema.make("pol_disabled"), | |
| 130 | + enabled: false, | |
| 131 | + })], | |
| 162 | 132 | context: { |
| 163 | 133 | workspaceId, |
| 164 | − organizationId, | |
| 165 | − accountId: AccountIdSchema.make("acc_user"), | |
| 166 | − clientId: null, | |
| 167 | 134 | }, |
| 168 | 135 | }); |
| 169 | 136 | |
| 170 | − expect(decision.kind).toBe("allow"); | |
| 171 | − expect(decision.matchedPolicyId).toBe("pol_ws_user_allow"); | |
| 137 | + expect(decision.kind).toBe("require_interaction"); | |
| 138 | + expect(decision.matchedPolicyId).toBeNull(); | |
| 172 | 139 | }); |
| 173 | 140 | }); |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | − Policy, | |
| 3 | − PolicyArgumentCondition, | |
| 2 | + LocalWorkspacePolicy, | |
| 4 | 3 | Source, |
| 5 | 4 | } from "#schema"; |
| 6 | 5 | |
| @@ -23,201 +22,24 @@ export type InvocationDescriptor = { · 16 unmodified lines | ||
| 23 | 22 | }; |
| 24 | 23 | |
| 25 | 24 | export type InvocationPolicyContext = { |
| 26 | − workspaceId: Exclude<Policy["workspaceId"], null>; | |
| 27 | − organizationId: Policy["organizationId"]; | |
| 28 | − accountId?: Policy["targetAccountId"]; | |
| 29 | − clientId?: Policy["clientId"]; | |
| 25 | + workspaceId: LocalWorkspacePolicy["workspaceId"]; | |
| 30 | 26 | }; |
| 31 | 27 | |
| 32 | 28 | export type InvocationAuthorizationDecision = { |
| 33 | 29 | kind: "allow" | "deny" | "require_interaction"; |
| 34 | 30 | reason: string; |
| 35 | − matchedPolicyId: Policy["id"] | null; | |
| 31 | + matchedPolicyId: LocalWorkspacePolicy["id"] | null; | |
| 36 | 32 | }; |
| 37 | 33 | |
| 38 | −const namespaceFromToolPath = (toolPath: string): string | null => { | |
| 39 | − const parts = toolPath.split(".").filter(Boolean); | |
| 40 | − if (parts.length <= 1) { | |
| 41 | − return null; | |
| 42 | − } | |
| 43 | − | |
| 44 | − return parts.slice(0, -1).join("."); | |
| 45 | −}; | |
| 46 | − | |
| 47 | −const matchesPattern = ( | |
| 48 | − pattern: string, | |
| 49 | − value: string, | |
| 50 | − matchType: Policy["matchType"], | |
| 51 | −): boolean => { | |
| 52 | − if (matchType === "exact") { | |
| 53 | − return pattern === value; | |
| 54 | − } | |
| 55 | − | |
| 34 | +const matchesGlob = (pattern: string, value: string): boolean => { | |
| 56 | 35 | const escaped = pattern |
| 57 | 36 | .replace(/[.+?^${}()|[\]\\]/g, "\\$&") |
| 58 | 37 | .replace(/\*/g, ".*"); |
| 59 | 38 | return new RegExp(`^${escaped}$`).test(value); |
| 60 | 39 | }; |
| 61 | 40 | |
| 62 | −const parseArgumentConditions = ( | |
| 63 | − argumentConditionsJson: Policy["argumentConditionsJson"], | |
| 64 | −): ReadonlyArray<PolicyArgumentCondition> | null => { | |
| 65 | − if (argumentConditionsJson === null) { | |
| 66 | − return []; | |
| 67 | − } | |
| 68 | − | |
| 69 | − try { | |
| 70 | − const parsed = JSON.parse(argumentConditionsJson) as unknown; | |
| 71 | − if (!Array.isArray(parsed)) { | |
| 72 | − return null; | |
| 73 | − } | |
| 74 | − | |
| 75 | − return parsed.flatMap((item) => { | |
| 76 | − if (!item || typeof item !== "object" || Array.isArray(item)) { | |
| 77 | − return []; | |
| 78 | − } | |
| 79 | − | |
| 80 | − const candidate = item as Record<string, unknown>; | |
| 81 | − return typeof candidate.key === "string" | |
| 82 | − && typeof candidate.operator === "string" | |
| 83 | − && typeof candidate.value === "string" | |
| 84 | − ? [{ | |
| 85 | − key: candidate.key, | |
| 86 | − operator: candidate.operator as PolicyArgumentCondition["operator"], | |
| 87 | − value: candidate.value, | |
| 88 | − } satisfies PolicyArgumentCondition] | |
| 89 | − : []; | |
| 90 | − }); | |
| 91 | − } catch { | |
| 92 | − return null; | |
| 93 | − } | |
| 94 | −}; | |
| 95 | − | |
| 96 | −const asString = (value: unknown): string => value == null ? "" : String(value); | |
| 97 | − | |
| 98 | −const matchesArgumentCondition = ( | |
| 99 | − condition: PolicyArgumentCondition, | |
| 100 | − args: Record<string, unknown>, | |
| 101 | −): boolean => { | |
| 102 | − const value = asString(args[condition.key]); | |
| 103 | − switch (condition.operator) { | |
| 104 | − case "equals": | |
| 105 | − return value === condition.value; | |
| 106 | − case "not_equals": | |
| 107 | − return value !== condition.value; | |
| 108 | − case "contains": | |
| 109 | − return value.includes(condition.value); | |
| 110 | − case "starts_with": | |
| 111 | − return value.startsWith(condition.value); | |
| 112 | − default: | |
| 113 | − return false; | |
| 114 | − } | |
| 115 | −}; | |
| 116 | − | |
| 117 | −const matchesArgumentConditions = ( | |
| 118 | − policy: Policy, | |
| 119 | − args: unknown, | |
| 120 | −): boolean => { | |
| 121 | − const conditions = parseArgumentConditions(policy.argumentConditionsJson); | |
| 122 | − if (conditions === null) { | |
| 123 | − return false; | |
| 124 | − } | |
| 125 | − if (conditions.length === 0) { | |
| 126 | − return true; | |
| 127 | − } | |
| 128 | − if (!args || typeof args !== "object" || Array.isArray(args)) { | |
| 129 | − return false; | |
| 130 | − } | |
| 131 | − | |
| 132 | − const record = args as Record<string, unknown>; | |
| 133 | − return conditions.every((condition) => matchesArgumentCondition(condition, record)); | |
| 134 | −}; | |
| 135 | − | |
| 136 | −const matchesScope = ( | |
| 137 | − policy: Policy, | |
| 138 | − context: InvocationPolicyContext, | |
| 139 | −): boolean => { | |
| 140 | − if (policy.organizationId !== context.organizationId) { | |
| 141 | − return false; | |
| 142 | − } | |
| 143 | − if (policy.scopeType === "workspace" && policy.workspaceId !== context.workspaceId) { | |
| 144 | − return false; | |
| 145 | − } | |
| 146 | − if (policy.scopeType === "organization" && policy.workspaceId !== null) { | |
| 147 | − return false; | |
| 148 | − } | |
| 149 | − if (policy.targetAccountId && policy.targetAccountId !== context.accountId) { | |
| 150 | − return false; | |
| 151 | − } | |
| 152 | − if (policy.clientId && policy.clientId !== context.clientId) { | |
| 153 | − return false; | |
| 154 | − } | |
| 155 | − | |
| 156 | − return true; | |
| 157 | −}; | |
| 158 | − | |
| 159 | −const matchesResource = ( | |
| 160 | − policy: Policy, | |
| 161 | − descriptor: InvocationDescriptor, | |
| 162 | −): boolean => { | |
| 163 | − if (policy.resourceType === "all_tools") { | |
| 164 | − return true; | |
| 165 | − } | |
| 166 | − | |
| 167 | − if (policy.resourceType === "source") { | |
| 168 | − return [descriptor.sourceId, `source:${descriptor.sourceId}`].some((candidate) => | |
| 169 | − matchesPattern(policy.resourcePattern, candidate, policy.matchType)); | |
| 170 | − } | |
| 171 | − | |
| 172 | − if (policy.resourceType === "namespace") { | |
| 173 | − const namespace = descriptor.sourceNamespace ?? namespaceFromToolPath(descriptor.toolPath); | |
| 174 | − return namespace === null | |
| 175 | − ? false | |
| 176 | − : matchesPattern(policy.resourcePattern, namespace, policy.matchType); | |
| 177 | − } | |
| 178 | − | |
| 179 | − return matchesPattern(policy.resourcePattern, descriptor.toolPath, policy.matchType); | |
| 180 | −}; | |
| 181 | − | |
| 182 | −const policySpecificity = ( | |
| 183 | − policy: Policy, | |
| 184 | − context: InvocationPolicyContext, | |
| 185 | −): number => { | |
| 186 | − let score = 0; | |
| 187 | − | |
| 188 | − score += policy.scopeType === "workspace" ? 64 : 32; | |
| 189 | − if (policy.targetAccountId && policy.targetAccountId === context.accountId) { | |
| 190 | − score += 128; | |
| 191 | − } | |
| 192 | − if (policy.clientId && policy.clientId === context.clientId) { | |
| 193 | − score += 16; | |
| 194 | − } | |
| 195 | − | |
| 196 | − switch (policy.resourceType) { | |
| 197 | − case "tool_path": | |
| 198 | − score += 24; | |
| 199 | − break; | |
| 200 | − case "namespace": | |
| 201 | − score += 18; | |
| 202 | − break; | |
| 203 | − case "source": | |
| 204 | − score += 12; | |
| 205 | − break; | |
| 206 | − default: | |
| 207 | − break; | |
| 208 | − } | |
| 209 | − | |
| 210 | − if (policy.matchType === "exact") { | |
| 211 | − score += 4; | |
| 212 | − } | |
| 213 | − if (policy.argumentConditionsJson !== null) { | |
| 214 | − score += 32; | |
| 215 | − } | |
| 216 | − | |
| 217 | − score += policy.priority; | |
| 218 | − score += Math.max(1, policy.resourcePattern.replace(/\*/g, "").length); | |
| 219 | − return score; | |
| 220 | −}; | |
| 41 | +const policySpecificity = (policy: LocalWorkspacePolicy): number => | |
| 42 | + policy.priority + Math.max(1, policy.resourcePattern.replace(/\*/g, "").length); | |
| 221 | 43 | |
| 222 | 44 | const defaultDecisionForInvocation = ( |
| 223 | 45 | descriptor: InvocationDescriptor, |
| @@ -238,7 +60,7 @@ const defaultDecisionForInvocation = ( · 14 unmodified lines | ||
| 238 | 60 | }; |
| 239 | 61 | |
| 240 | 62 | const resolvePolicyDecision = ( |
| 241 | − policy: Policy, | |
| 63 | + policy: LocalWorkspacePolicy, | |
| 242 | 64 | ): InvocationAuthorizationDecision => { |
| 243 | 65 | if (policy.effect === "deny") { |
| 244 | 66 | return { |
| @@ -266,18 +88,19 @@ const resolvePolicyDecision = ( · 21 unmodified lines | ||
| 266 | 88 | export const evaluateInvocationPolicy = (input: { |
| 267 | 89 | descriptor: InvocationDescriptor; |
| 268 | 90 | args: unknown; |
| 269 | − policies: ReadonlyArray<Policy>; | |
| 91 | + policies: ReadonlyArray<LocalWorkspacePolicy>; | |
| 270 | 92 | context: InvocationPolicyContext; |
| 271 | 93 | }): InvocationAuthorizationDecision => { |
| 272 | − const candidates = input.policies | |
| 273 | − .filter((policy) => policy.enabled) | |
| 274 | − .filter((policy) => matchesScope(policy, input.context)) | |
| 275 | − .filter((policy) => matchesResource(policy, input.descriptor)) | |
| 276 | − .filter((policy) => matchesArgumentConditions(policy, input.args)) | |
| 94 | + const matchingPolicies = input.policies | |
| 95 | + .filter((policy) => | |
| 96 | + policy.enabled | |
| 97 | + && policy.workspaceId === input.context.workspaceId | |
| 98 | + && matchesGlob(policy.resourcePattern, input.descriptor.toolPath)) | |
| 277 | 99 | .sort((left, right) => |
| 278 | − policySpecificity(right, input.context) - policySpecificity(left, input.context)); | |
| 100 | + policySpecificity(right) - policySpecificity(left) | |
| 101 | + || left.updatedAt - right.updatedAt, | |
| 102 | + ); | |
| 279 | 103 | |
| 280 | − return candidates[0] | |
| 281 | − ? resolvePolicyDecision(candidates[0]) | |
| 282 | − : defaultDecisionForInvocation(input.descriptor); | |
| 104 | + const matched = matchingPolicies[0]; | |
| 105 | + return matched ? resolvePolicyDecision(matched) : defaultDecisionForInvocation(input.descriptor); | |
| 283 | 106 | }; |
| @@ -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,128 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { mkdir, writeFile } from "node:fs/promises"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 5 | +import { describe, expect, it } from "@effect/vitest"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | + | |
| 8 | +import { | |
| 9 | + loadLocalExecutorConfig, | |
| 10 | + resolveDefaultHomeConfigCandidates, | |
| 11 | + resolveHomeConfigPath, | |
| 12 | + resolveLocalWorkspaceContext, | |
| 13 | +} from "./local-config"; | |
| 14 | + | |
| 15 | +const makeWorkspaceRoot = () => | |
| 16 | + mkdtempSync(join(tmpdir(), "executor-local-config-")); | |
| 17 | + | |
| 18 | +describe("local-config", () => { | |
| 19 | + it.effect("parses jsonc project config with comments and trailing commas", () => | |
| 20 | + Effect.gen(function* () { | |
| 21 | + const workspaceRoot = makeWorkspaceRoot(); | |
| 22 | + const configDirectory = join(workspaceRoot, ".executor"); | |
| 23 | + yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })); | |
| 24 | + yield* Effect.promise(() => | |
| 25 | + writeFile( | |
| 26 | + join(configDirectory, "executor.jsonc"), | |
| 27 | + `{ | |
| 28 | + // local workspace config | |
| 29 | + "sources": { | |
| 30 | + "github": { | |
| 31 | + "kind": "openapi", | |
| 32 | + "connection": { | |
| 33 | + "endpoint": "https://api.github.com", | |
| 34 | + }, | |
| 35 | + "binding": { | |
| 36 | + "specUrl": "https://example.com/openapi.json", | |
| 37 | + }, | |
| 38 | + }, | |
| 39 | + }, | |
| 40 | +} | |
| 41 | +`, | |
| 42 | + "utf8", | |
| 43 | + ), | |
| 44 | + ); | |
| 45 | + | |
| 46 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 47 | + const loaded = yield* loadLocalExecutorConfig(context); | |
| 48 | + | |
| 49 | + expect(loaded.config?.sources?.github?.kind).toBe("openapi"); | |
| 50 | + expect(loaded.config?.sources?.github?.connection.endpoint).toBe( | |
| 51 | + "https://api.github.com", | |
| 52 | + ); | |
| 53 | + }), | |
| 54 | + ); | |
| 55 | + | |
| 56 | + it.effect("reports jsonc syntax errors with line and column details", () => | |
| 57 | + Effect.gen(function* () { | |
| 58 | + const workspaceRoot = makeWorkspaceRoot(); | |
| 59 | + const configDirectory = join(workspaceRoot, ".executor"); | |
| 60 | + yield* Effect.promise(() => mkdir(configDirectory, { recursive: true })); | |
| 61 | + yield* Effect.promise(() => | |
| 62 | + writeFile( | |
| 63 | + join(configDirectory, "executor.jsonc"), | |
| 64 | + `{ | |
| 65 | + "sources": { | |
| 66 | + "github": { | |
| 67 | + "kind": "openapi" | |
| 68 | + "connection": { | |
| 69 | + "endpoint": "https://api.github.com" | |
| 70 | + } | |
| 71 | + } | |
| 72 | + } | |
| 73 | +} | |
| 74 | +`, | |
| 75 | + "utf8", | |
| 76 | + ), | |
| 77 | + ); | |
| 78 | + | |
| 79 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 80 | + const failure = yield* Effect.flip(loadLocalExecutorConfig(context)); | |
| 81 | + | |
| 82 | + expect(failure.message).toContain("Invalid executor config"); | |
| 83 | + expect(failure.message).toContain("line 5, column 7"); | |
| 84 | + }), | |
| 85 | + ); | |
| 86 | + | |
| 87 | + it("uses platform-standard home config candidates", () => { | |
| 88 | + const linuxCandidates = resolveDefaultHomeConfigCandidates({ | |
| 89 | + platform: "linux", | |
| 90 | + homeDirectory: "/home/alice", | |
| 91 | + env: {}, | |
| 92 | + }); | |
| 93 | + const macCandidates = resolveDefaultHomeConfigCandidates({ | |
| 94 | + platform: "darwin", | |
| 95 | + homeDirectory: "/Users/alice", | |
| 96 | + env: {}, | |
| 97 | + }); | |
| 98 | + | |
| 99 | + expect(linuxCandidates[0]).toBe("/home/alice/.config/executor/executor.jsonc"); | |
| 100 | + expect(macCandidates[0]).toBe( | |
| 101 | + "/Users/alice/Library/Application Support/Executor/executor.jsonc", | |
| 102 | + ); | |
| 103 | + expect(macCandidates[2]).toBe("/Users/alice/.config/executor/executor.jsonc"); | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it.effect("prefers an existing legacy home config path before the canonical path", () => | |
| 107 | + Effect.gen(function* () { | |
| 108 | + const homeDirectory = makeWorkspaceRoot(); | |
| 109 | + const legacyConfigDirectory = join(homeDirectory, ".config", "executor"); | |
| 110 | + yield* Effect.promise(() => mkdir(legacyConfigDirectory, { recursive: true })); | |
| 111 | + yield* Effect.promise(() => | |
| 112 | + writeFile( | |
| 113 | + join(legacyConfigDirectory, "executor.jsonc"), | |
| 114 | + "{\n \"sources\": {}\n}\n", | |
| 115 | + "utf8", | |
| 116 | + ), | |
| 117 | + ); | |
| 118 | + | |
| 119 | + const resolvedPath = yield* resolveHomeConfigPath({ | |
| 120 | + platform: "darwin", | |
| 121 | + homeDirectory, | |
| 122 | + env: {}, | |
| 123 | + }); | |
| 124 | + | |
| 125 | + expect(resolvedPath).toBe(join(legacyConfigDirectory, "executor.jsonc")); | |
| 126 | + }), | |
| 127 | + ); | |
| 128 | +}); | |
| @@ -0,0 +1,450 @@ | ||
| 1 | +import { homedir } from "node:os"; | |
| 2 | +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; | |
| 3 | +import { FileSystem } from "@effect/platform"; | |
| 4 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 5 | +import { | |
| 6 | + type ParseError as JsoncParseError, | |
| 7 | + parse as parseJsoncDocument, | |
| 8 | + printParseErrorCode, | |
| 9 | +} from "jsonc-parser"; | |
| 10 | + | |
| 11 | +import { | |
| 12 | + LocalExecutorConfigSchema, | |
| 13 | + type LocalExecutorConfig, | |
| 14 | + type LocalConfigPolicy, | |
| 15 | + type LocalConfigSecretProvider, | |
| 16 | + type LocalConfigSource, | |
| 17 | +} from "#schema"; | |
| 18 | +import * as Effect from "effect/Effect"; | |
| 19 | +import * as Schema from "effect/Schema"; | |
| 20 | +import { | |
| 21 | + LocalExecutorConfigDecodeError, | |
| 22 | + LocalFileSystemError, | |
| 23 | + unknownLocalErrorDetails, | |
| 24 | +} from "./local-errors"; | |
| 25 | + | |
| 26 | +const decodeLocalExecutorConfig = Schema.decodeUnknownSync(LocalExecutorConfigSchema); | |
| 27 | + | |
| 28 | +const PROJECT_CONFIG_BASENAME = "executor.jsonc"; | |
| 29 | +const PROJECT_CONFIG_FALLBACK_BASENAME = "executor.json"; | |
| 30 | +const PROJECT_CONFIG_DIRECTORY = ".executor"; | |
| 31 | +const EXECUTOR_CONFIG_DIR_ENV = "EXECUTOR_CONFIG_DIR"; | |
| 32 | + | |
| 33 | +const provideNodeFileSystem = <A, E, R>( | |
| 34 | + effect: Effect.Effect<A, E, R | FileSystem.FileSystem>, | |
| 35 | +): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> => | |
| 36 | + effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect< | |
| 37 | + A, | |
| 38 | + E, | |
| 39 | + Exclude<R, FileSystem.FileSystem> | |
| 40 | + >; | |
| 41 | + | |
| 42 | +const mapFileSystemError = (path: string, action: string) => (cause: unknown) => | |
| 43 | + new LocalFileSystemError({ | |
| 44 | + message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`, | |
| 45 | + action, | |
| 46 | + path, | |
| 47 | + details: unknownLocalErrorDetails(cause), | |
| 48 | + }); | |
| 49 | + | |
| 50 | +const trimOrUndefined = (value: string | undefined | null): string | undefined => { | |
| 51 | + const trimmed = value?.trim(); | |
| 52 | + return trimmed && trimmed.length > 0 ? trimmed : undefined; | |
| 53 | +}; | |
| 54 | + | |
| 55 | +const defaultExecutorConfigDirectory = (input: { | |
| 56 | + env?: NodeJS.ProcessEnv; | |
| 57 | + platform?: NodeJS.Platform; | |
| 58 | + homeDirectory?: string; | |
| 59 | +} = {}): string => { | |
| 60 | + const env = input.env ?? process.env; | |
| 61 | + const platform = input.platform ?? process.platform; | |
| 62 | + const homeDirectory = input.homeDirectory ?? homedir(); | |
| 63 | + const explicitConfigDirectory = trimOrUndefined(env[EXECUTOR_CONFIG_DIR_ENV]); | |
| 64 | + | |
| 65 | + if (explicitConfigDirectory) { | |
| 66 | + return explicitConfigDirectory; | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (platform === "win32") { | |
| 70 | + return join( | |
| 71 | + trimOrUndefined(env.LOCALAPPDATA) ?? join(homeDirectory, "AppData", "Local"), | |
| 72 | + "Executor", | |
| 73 | + ); | |
| 74 | + } | |
| 75 | + | |
| 76 | + if (platform === "darwin") { | |
| 77 | + return join(homeDirectory, "Library", "Application Support", "Executor"); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return join( | |
| 81 | + trimOrUndefined(env.XDG_CONFIG_HOME) ?? join(homeDirectory, ".config"), | |
| 82 | + "executor", | |
| 83 | + ); | |
| 84 | +}; | |
| 85 | + | |
| 86 | +const legacyExecutorConfigDirectory = (input: { | |
| 87 | + homeDirectory?: string; | |
| 88 | +} = {}): string => | |
| 89 | + join(input.homeDirectory ?? homedir(), ".config", "executor"); | |
| 90 | + | |
| 91 | +export const resolveDefaultHomeConfigCandidates = (input: { | |
| 92 | + env?: NodeJS.ProcessEnv; | |
| 93 | + platform?: NodeJS.Platform; | |
| 94 | + homeDirectory?: string; | |
| 95 | +} = {}): string[] => { | |
| 96 | + const homeDirectory = input.homeDirectory ?? homedir(); | |
| 97 | + const canonicalDirectory = defaultExecutorConfigDirectory({ | |
| 98 | + env: input.env, | |
| 99 | + platform: input.platform, | |
| 100 | + homeDirectory, | |
| 101 | + }); | |
| 102 | + const legacyDirectory = legacyExecutorConfigDirectory({ homeDirectory }); | |
| 103 | + const directories = canonicalDirectory === legacyDirectory | |
| 104 | + ? [canonicalDirectory] | |
| 105 | + : [canonicalDirectory, legacyDirectory]; | |
| 106 | + | |
| 107 | + return directories.flatMap((directory) => [ | |
| 108 | + join(directory, PROJECT_CONFIG_BASENAME), | |
| 109 | + join(directory, PROJECT_CONFIG_FALLBACK_BASENAME), | |
| 110 | + ]); | |
| 111 | +}; | |
| 112 | + | |
| 113 | +export const resolveHomeConfigPath = (input: { | |
| 114 | + env?: NodeJS.ProcessEnv; | |
| 115 | + platform?: NodeJS.Platform; | |
| 116 | + homeDirectory?: string; | |
| 117 | +} = {}): Effect.Effect<string, LocalFileSystemError> => | |
| 118 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 119 | + const fs = yield* FileSystem.FileSystem; | |
| 120 | + const candidates = resolveDefaultHomeConfigCandidates(input); | |
| 121 | + | |
| 122 | + for (const candidate of candidates) { | |
| 123 | + const exists = yield* fs.exists(candidate).pipe( | |
| 124 | + Effect.mapError(mapFileSystemError(candidate, "check config path")), | |
| 125 | + ); | |
| 126 | + if (exists) { | |
| 127 | + return candidate; | |
| 128 | + } | |
| 129 | + } | |
| 130 | + | |
| 131 | + return candidates[0]!; | |
| 132 | + })); | |
| 133 | + | |
| 134 | +const formatJsoncParseErrors = (content: string, errors: readonly JsoncParseError[]): string => { | |
| 135 | + const lines = content.split("\n"); | |
| 136 | + | |
| 137 | + return errors | |
| 138 | + .map((error) => { | |
| 139 | + const beforeOffset = content.slice(0, error.offset).split("\n"); | |
| 140 | + const line = beforeOffset.length; | |
| 141 | + const column = beforeOffset[beforeOffset.length - 1]?.length ?? 0; | |
| 142 | + const lineText = lines[line - 1]; | |
| 143 | + const location = `line ${line}, column ${column + 1}`; | |
| 144 | + const detail = printParseErrorCode(error.error); | |
| 145 | + | |
| 146 | + if (!lineText) { | |
| 147 | + return `${detail} at ${location}`; | |
| 148 | + } | |
| 149 | + | |
| 150 | + return `${detail} at ${location}\n${lineText}`; | |
| 151 | + }) | |
| 152 | + .join("\n"); | |
| 153 | +}; | |
| 154 | + | |
| 155 | +const parseJsonc = (input: { path: string; content: string }): LocalExecutorConfig => { | |
| 156 | + const errors: JsoncParseError[] = []; | |
| 157 | + | |
| 158 | + try { | |
| 159 | + const parsed = parseJsoncDocument(input.content, errors, { | |
| 160 | + allowTrailingComma: true, | |
| 161 | + }); | |
| 162 | + if (errors.length > 0) { | |
| 163 | + throw new LocalExecutorConfigDecodeError({ | |
| 164 | + message: `Invalid executor config at ${input.path}: ${formatJsoncParseErrors(input.content, errors)}`, | |
| 165 | + path: input.path, | |
| 166 | + details: formatJsoncParseErrors(input.content, errors), | |
| 167 | + }); | |
| 168 | + } | |
| 169 | + | |
| 170 | + return decodeLocalExecutorConfig(parsed); | |
| 171 | + } catch (cause) { | |
| 172 | + if (cause instanceof LocalExecutorConfigDecodeError) { | |
| 173 | + throw cause; | |
| 174 | + } | |
| 175 | + throw new LocalExecutorConfigDecodeError({ | |
| 176 | + message: `Invalid executor config at ${input.path}: ${unknownLocalErrorDetails(cause)}`, | |
| 177 | + path: input.path, | |
| 178 | + details: unknownLocalErrorDetails(cause), | |
| 179 | + }); | |
| 180 | + } | |
| 181 | +}; | |
| 182 | + | |
| 183 | +const mergeSourceMaps = ( | |
| 184 | + base: Record<string, LocalConfigSource> | undefined, | |
| 185 | + extra: Record<string, LocalConfigSource> | undefined, | |
| 186 | +): Record<string, LocalConfigSource> | undefined => { | |
| 187 | + if (!base && !extra) { | |
| 188 | + return undefined; | |
| 189 | + } | |
| 190 | + return { | |
| 191 | + ...(base ?? {}), | |
| 192 | + ...(extra ?? {}), | |
| 193 | + }; | |
| 194 | +}; | |
| 195 | + | |
| 196 | +const mergePolicyMaps = ( | |
| 197 | + base: Record<string, LocalConfigPolicy> | undefined, | |
| 198 | + extra: Record<string, LocalConfigPolicy> | undefined, | |
| 199 | +): Record<string, LocalConfigPolicy> | undefined => { | |
| 200 | + if (!base && !extra) { | |
| 201 | + return undefined; | |
| 202 | + } | |
| 203 | + return { | |
| 204 | + ...(base ?? {}), | |
| 205 | + ...(extra ?? {}), | |
| 206 | + }; | |
| 207 | +}; | |
| 208 | + | |
| 209 | +const mergeSecretProviderMaps = ( | |
| 210 | + base: Record<string, LocalConfigSecretProvider> | undefined, | |
| 211 | + extra: Record<string, LocalConfigSecretProvider> | undefined, | |
| 212 | +): Record<string, LocalConfigSecretProvider> | undefined => { | |
| 213 | + if (!base && !extra) { | |
| 214 | + return undefined; | |
| 215 | + } | |
| 216 | + return { | |
| 217 | + ...(base ?? {}), | |
| 218 | + ...(extra ?? {}), | |
| 219 | + }; | |
| 220 | +}; | |
| 221 | + | |
| 222 | +export const mergeLocalExecutorConfigs = ( | |
| 223 | + base: LocalExecutorConfig | null, | |
| 224 | + extra: LocalExecutorConfig | null, | |
| 225 | +): LocalExecutorConfig | null => { | |
| 226 | + if (!base && !extra) { | |
| 227 | + return null; | |
| 228 | + } | |
| 229 | + | |
| 230 | + return decodeLocalExecutorConfig({ | |
| 231 | + workspace: { | |
| 232 | + ...(base?.workspace ?? {}), | |
| 233 | + ...(extra?.workspace ?? {}), | |
| 234 | + }, | |
| 235 | + sources: mergeSourceMaps(base?.sources, extra?.sources), | |
| 236 | + policies: mergePolicyMaps(base?.policies, extra?.policies), | |
| 237 | + secrets: { | |
| 238 | + providers: mergeSecretProviderMaps( | |
| 239 | + base?.secrets?.providers, | |
| 240 | + extra?.secrets?.providers, | |
| 241 | + ), | |
| 242 | + defaults: { | |
| 243 | + ...(base?.secrets?.defaults ?? {}), | |
| 244 | + ...(extra?.secrets?.defaults ?? {}), | |
| 245 | + }, | |
| 246 | + }, | |
| 247 | + }); | |
| 248 | +}; | |
| 249 | + | |
| 250 | +const resolveProjectConfigPathEffect = (workspaceRoot: string) => | |
| 251 | + Effect.gen(function* () { | |
| 252 | + const fs = yield* FileSystem.FileSystem; | |
| 253 | + const jsoncPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME); | |
| 254 | + const jsoncExists = yield* fs.exists(jsoncPath).pipe( | |
| 255 | + Effect.mapError(mapFileSystemError(jsoncPath, "check project config path")), | |
| 256 | + ); | |
| 257 | + if (jsoncExists) { | |
| 258 | + return jsoncPath; | |
| 259 | + } | |
| 260 | + | |
| 261 | + const jsonPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_FALLBACK_BASENAME); | |
| 262 | + const jsonExists = yield* fs.exists(jsonPath).pipe( | |
| 263 | + Effect.mapError(mapFileSystemError(jsonPath, "check project config path")), | |
| 264 | + ); | |
| 265 | + return jsonExists ? jsonPath : jsoncPath; | |
| 266 | + }); | |
| 267 | + | |
| 268 | +const hasProjectConfigEffect = (workspaceRoot: string) => | |
| 269 | + Effect.gen(function* () { | |
| 270 | + const fs = yield* FileSystem.FileSystem; | |
| 271 | + const jsoncPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_BASENAME); | |
| 272 | + const jsonPath = join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, PROJECT_CONFIG_FALLBACK_BASENAME); | |
| 273 | + const [jsoncExists, jsonExists] = yield* Effect.all([ | |
| 274 | + fs.exists(jsoncPath).pipe( | |
| 275 | + Effect.mapError(mapFileSystemError(jsoncPath, "check project config path")), | |
| 276 | + ), | |
| 277 | + fs.exists(jsonPath).pipe( | |
| 278 | + Effect.mapError(mapFileSystemError(jsonPath, "check project config path")), | |
| 279 | + ), | |
| 280 | + ]); | |
| 281 | + return jsoncExists || jsonExists; | |
| 282 | + }); | |
| 283 | + | |
| 284 | +const resolveWorkspaceRootFromCwdEffect = (cwd: string) => | |
| 285 | + Effect.gen(function* () { | |
| 286 | + const fs = yield* FileSystem.FileSystem; | |
| 287 | + let current = resolve(cwd); | |
| 288 | + let nearestProjectConfigRoot: string | null = null; | |
| 289 | + let nearestGitRoot: string | null = null; | |
| 290 | + | |
| 291 | + while (true) { | |
| 292 | + if (nearestProjectConfigRoot === null) { | |
| 293 | + const hasProjectConfig = yield* hasProjectConfigEffect(current); | |
| 294 | + if (hasProjectConfig) { | |
| 295 | + nearestProjectConfigRoot = current; | |
| 296 | + } | |
| 297 | + } | |
| 298 | + | |
| 299 | + if (nearestGitRoot === null) { | |
| 300 | + const gitPath = join(current, ".git"); | |
| 301 | + const gitExists = yield* fs.exists(gitPath).pipe( | |
| 302 | + Effect.mapError(mapFileSystemError(gitPath, "check git root")), | |
| 303 | + ); | |
| 304 | + if (gitExists) { | |
| 305 | + nearestGitRoot = current; | |
| 306 | + } | |
| 307 | + } | |
| 308 | + | |
| 309 | + const parent = dirname(current); | |
| 310 | + if (parent === current) { | |
| 311 | + break; | |
| 312 | + } | |
| 313 | + current = parent; | |
| 314 | + } | |
| 315 | + | |
| 316 | + return nearestProjectConfigRoot ?? nearestGitRoot ?? resolve(cwd); | |
| 317 | + }); | |
| 318 | + | |
| 319 | +export type ResolvedLocalWorkspaceContext = { | |
| 320 | + cwd: string; | |
| 321 | + workspaceRoot: string; | |
| 322 | + workspaceName: string; | |
| 323 | + configDirectory: string; | |
| 324 | + projectConfigPath: string; | |
| 325 | + homeConfigPath: string; | |
| 326 | + artifactsDirectory: string; | |
| 327 | + stateDirectory: string; | |
| 328 | +}; | |
| 329 | + | |
| 330 | +export type LoadedLocalExecutorConfig = { | |
| 331 | + config: LocalExecutorConfig | null; | |
| 332 | + homeConfig: LocalExecutorConfig | null; | |
| 333 | + projectConfig: LocalExecutorConfig | null; | |
| 334 | + homeConfigPath: string; | |
| 335 | + projectConfigPath: string; | |
| 336 | +}; | |
| 337 | + | |
| 338 | +export const resolveLocalWorkspaceContext = (input: { | |
| 339 | + cwd?: string; | |
| 340 | + workspaceRoot?: string; | |
| 341 | +} = {}): Effect.Effect<ResolvedLocalWorkspaceContext, LocalFileSystemError> => | |
| 342 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 343 | + const cwd = resolve(input.cwd ?? process.cwd()); | |
| 344 | + const workspaceRoot = resolve( | |
| 345 | + input.workspaceRoot ?? (yield* resolveWorkspaceRootFromCwdEffect(cwd)), | |
| 346 | + ); | |
| 347 | + const workspaceName = basename(workspaceRoot) || "workspace"; | |
| 348 | + const projectConfigPath = yield* resolveProjectConfigPathEffect(workspaceRoot); | |
| 349 | + const homeConfigPath = yield* resolveHomeConfigPath(); | |
| 350 | + | |
| 351 | + return { | |
| 352 | + cwd, | |
| 353 | + workspaceRoot, | |
| 354 | + workspaceName, | |
| 355 | + configDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY), | |
| 356 | + projectConfigPath, | |
| 357 | + homeConfigPath, | |
| 358 | + artifactsDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, "artifacts"), | |
| 359 | + stateDirectory: join(workspaceRoot, PROJECT_CONFIG_DIRECTORY, "state"), | |
| 360 | + }; | |
| 361 | + })); | |
| 362 | + | |
| 363 | +export const readOptionalLocalExecutorConfig = ( | |
| 364 | + path: string, | |
| 365 | +): Effect.Effect<LocalExecutorConfig | null, LocalFileSystemError | LocalExecutorConfigDecodeError> => | |
| 366 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 367 | + const fs = yield* FileSystem.FileSystem; | |
| 368 | + const exists = yield* fs.exists(path).pipe( | |
| 369 | + Effect.mapError(mapFileSystemError(path, "check config path")), | |
| 370 | + ); | |
| 371 | + if (!exists) { | |
| 372 | + return null; | |
| 373 | + } | |
| 374 | + | |
| 375 | + const content = yield* fs.readFileString(path, "utf8").pipe( | |
| 376 | + Effect.mapError(mapFileSystemError(path, "read config")), | |
| 377 | + ); | |
| 378 | + return yield* Effect.try({ | |
| 379 | + try: () => parseJsonc({ path, content }), | |
| 380 | + catch: (cause) => | |
| 381 | + cause instanceof LocalExecutorConfigDecodeError | |
| 382 | + ? cause | |
| 383 | + : new LocalExecutorConfigDecodeError({ | |
| 384 | + message: `Invalid executor config at ${path}: ${unknownLocalErrorDetails(cause)}`, | |
| 385 | + path, | |
| 386 | + details: unknownLocalErrorDetails(cause), | |
| 387 | + }), | |
| 388 | + }); | |
| 389 | + })); | |
| 390 | + | |
| 391 | +export const loadLocalExecutorConfig = ( | |
| 392 | + context: ResolvedLocalWorkspaceContext, | |
| 393 | +): Effect.Effect< | |
| 394 | + LoadedLocalExecutorConfig, | |
| 395 | + LocalFileSystemError | LocalExecutorConfigDecodeError | |
| 396 | +> => | |
| 397 | + Effect.gen(function* () { | |
| 398 | + const [homeConfig, projectConfig] = yield* Effect.all([ | |
| 399 | + readOptionalLocalExecutorConfig(context.homeConfigPath), | |
| 400 | + readOptionalLocalExecutorConfig(context.projectConfigPath), | |
| 401 | + ]); | |
| 402 | + | |
| 403 | + return { | |
| 404 | + config: mergeLocalExecutorConfigs(homeConfig, projectConfig), | |
| 405 | + homeConfig, | |
| 406 | + projectConfig, | |
| 407 | + homeConfigPath: context.homeConfigPath, | |
| 408 | + projectConfigPath: context.projectConfigPath, | |
| 409 | + }; | |
| 410 | + }); | |
| 411 | + | |
| 412 | +export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string => | |
| 413 | + `${JSON.stringify(config, null, 2)}\n`; | |
| 414 | + | |
| 415 | +export const writeProjectLocalExecutorConfig = (input: { | |
| 416 | + context: ResolvedLocalWorkspaceContext; | |
| 417 | + config: LocalExecutorConfig; | |
| 418 | +}): Effect.Effect<void, LocalFileSystemError> => | |
| 419 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 420 | + const fs = yield* FileSystem.FileSystem; | |
| 421 | + yield* fs.makeDirectory(input.context.configDirectory, { recursive: true }).pipe( | |
| 422 | + Effect.mapError(mapFileSystemError(input.context.configDirectory, "create config directory")), | |
| 423 | + ); | |
| 424 | + yield* fs.writeFileString( | |
| 425 | + input.context.projectConfigPath, | |
| 426 | + encodeLocalExecutorConfig(input.config), | |
| 427 | + ).pipe( | |
| 428 | + Effect.mapError(mapFileSystemError(input.context.projectConfigPath, "write config")), | |
| 429 | + ); | |
| 430 | + })); | |
| 431 | + | |
| 432 | +export const resolveConfigRelativePath = (input: { | |
| 433 | + path: string; | |
| 434 | + workspaceRoot: string; | |
| 435 | +}): string => { | |
| 436 | + const trimmed = input.path.trim(); | |
| 437 | + if (trimmed.startsWith("~/")) { | |
| 438 | + return join(homedir(), trimmed.slice(2)); | |
| 439 | + } | |
| 440 | + if (trimmed === "~") { | |
| 441 | + return homedir(); | |
| 442 | + } | |
| 443 | + if (isAbsolute(trimmed)) { | |
| 444 | + return trimmed; | |
| 445 | + } | |
| 446 | + return resolve(input.workspaceRoot, trimmed); | |
| 447 | +}; | |
| 448 | + | |
| 449 | +export const defaultWorkspaceDisplayName = (context: ResolvedLocalWorkspaceContext): string => | |
| 450 | + trimOrUndefined(context.workspaceName) ?? "workspace"; | |
| @@ -0,0 +1,54 @@ | ||
| 1 | +import * as Data from "effect/Data"; | |
| 2 | + | |
| 3 | +export const unknownLocalErrorDetails = (cause: unknown): string => | |
| 4 | + cause instanceof Error ? cause.message : String(cause); | |
| 5 | + | |
| 6 | +export class LocalFileSystemError extends Data.TaggedError("LocalFileSystemError")<{ | |
| 7 | + readonly message: string; | |
| 8 | + readonly action: string; | |
| 9 | + readonly path: string; | |
| 10 | + readonly details: string; | |
| 11 | +}> {} | |
| 12 | + | |
| 13 | +export class LocalExecutorConfigDecodeError extends Data.TaggedError("LocalExecutorConfigDecodeError")<{ | |
| 14 | + readonly message: string; | |
| 15 | + readonly path: string; | |
| 16 | + readonly details: string; | |
| 17 | +}> {} | |
| 18 | + | |
| 19 | +export class LocalWorkspaceStateDecodeError extends Data.TaggedError("LocalWorkspaceStateDecodeError")<{ | |
| 20 | + readonly message: string; | |
| 21 | + readonly path: string; | |
| 22 | + readonly details: string; | |
| 23 | +}> {} | |
| 24 | + | |
| 25 | +export class LocalSourceArtifactDecodeError extends Data.TaggedError("LocalSourceArtifactDecodeError")<{ | |
| 26 | + readonly message: string; | |
| 27 | + readonly path: string; | |
| 28 | + readonly details: string; | |
| 29 | +}> {} | |
| 30 | + | |
| 31 | +export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError("RuntimeLocalWorkspaceUnavailableError")<{ | |
| 32 | + readonly message: string; | |
| 33 | +}> {} | |
| 34 | + | |
| 35 | +export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError("RuntimeLocalWorkspaceMismatchError")<{ | |
| 36 | + readonly message: string; | |
| 37 | + readonly requestedWorkspaceId: string; | |
| 38 | + readonly activeWorkspaceId: string; | |
| 39 | +}> {} | |
| 40 | + | |
| 41 | +export class LocalConfiguredSourceNotFoundError extends Data.TaggedError("LocalConfiguredSourceNotFoundError")<{ | |
| 42 | + readonly message: string; | |
| 43 | + readonly sourceId: string; | |
| 44 | +}> {} | |
| 45 | + | |
| 46 | +export class LocalSourceArtifactMissingError extends Data.TaggedError("LocalSourceArtifactMissingError")<{ | |
| 47 | + readonly message: string; | |
| 48 | + readonly sourceId: string; | |
| 49 | +}> {} | |
| 50 | + | |
| 51 | +export class LocalUnsupportedSourceKindError extends Data.TaggedError("LocalUnsupportedSourceKindError")<{ | |
| 52 | + readonly message: string; | |
| 53 | + readonly kind: string; | |
| 54 | +}> {} | |
| @@ -1,45 +1,49 @@ | ||
| 1 | +import { existsSync, mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 1 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { assertTrue } from "@effect/vitest/utils"; | |
| 3 | 5 | import * as Effect from "effect/Effect"; |
| 4 | 6 | |
| 5 | −import { createSqlControlPlaneRuntime } from "./index"; | |
| 7 | +import { createControlPlaneRuntime } from "./index"; | |
| 6 | 8 | import { getOrProvisionLocalInstallation } from "./local-installation"; |
| 9 | +import { resolveLocalWorkspaceContext } from "./local-config"; | |
| 10 | + | |
| 11 | +const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-")); | |
| 7 | 12 | |
| 8 | 13 | const makeRuntime = Effect.acquireRelease( |
| 9 | − createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | |
| 14 | + createControlPlaneRuntime({ | |
| 15 | + localDataDir: ":memory:", | |
| 16 | + workspaceRoot: TEST_WORKSPACE_ROOT, | |
| 17 | + }), | |
| 10 | 18 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 11 | 19 | ); |
| 12 | 20 | |
| 13 | 21 | describe("local-installation", () => { |
| 14 | − it.scoped("provisions a local account, organization, and workspace on first boot", () => | |
| 22 | + it.scoped("derives a stable local identity on first boot", () => | |
| 15 | 23 | Effect.gen(function* () { |
| 16 | 24 | const runtime = yield* makeRuntime; |
| 17 | 25 | const installation = runtime.localInstallation; |
| 18 | 26 | |
| 19 | − const account = yield* runtime.persistence.rows.accounts.getById(installation.accountId); | |
| 20 | − const organization = yield* runtime.persistence.rows.organizations.getById( | |
| 21 | − installation.organizationId, | |
| 22 | − ); | |
| 23 | − const workspace = yield* runtime.persistence.rows.workspaces.getById( | |
| 24 | − installation.workspaceId, | |
| 25 | − ); | |
| 26 | − | |
| 27 | − assertTrue(account._tag === "Some"); | |
| 28 | − assertTrue(organization._tag === "Some"); | |
| 29 | − assertTrue(workspace._tag === "Some"); | |
| 27 | + expect(installation.accountId).toBe("acc_local_default"); | |
| 28 | + expect(installation.workspaceId.startsWith("ws_local_")).toBe(true); | |
| 29 | + expect(existsSync(join(TEST_WORKSPACE_ROOT, ".executor", "executor.jsonc"))).toBe(false); | |
| 30 | 30 | }), |
| 31 | + 60_000, | |
| 31 | 32 | ); |
| 32 | 33 | |
| 33 | 34 | it.scoped("is idempotent when loading the default local installation", () => |
| 34 | 35 | Effect.gen(function* () { |
| 35 | 36 | const runtime = yield* makeRuntime; |
| 37 | + const context = yield* resolveLocalWorkspaceContext({ | |
| 38 | + workspaceRoot: TEST_WORKSPACE_ROOT, | |
| 39 | + }); | |
| 36 | 40 | |
| 37 | 41 | const first = runtime.localInstallation; |
| 38 | − const second = yield* getOrProvisionLocalInstallation(runtime.persistence.rows); | |
| 42 | + const second = yield* getOrProvisionLocalInstallation({ | |
| 43 | + context, | |
| 44 | + }); | |
| 39 | 45 | |
| 40 | − expect(second.id).toBe(first.id); | |
| 41 | 46 | expect(second.accountId).toBe(first.accountId); |
| 42 | − expect(second.organizationId).toBe(first.organizationId); | |
| 43 | 47 | expect(second.workspaceId).toBe(first.workspaceId); |
| 44 | 48 | }), |
| 45 | 49 | ); |
| @@ -1,119 +1,40 @@ | ||
| 1 | −import { type SqlControlPlaneRows } from "#persistence"; | |
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | + | |
| 2 | 3 | import { |
| 3 | 4 | AccountIdSchema, |
| 4 | − InstallationIdSchema, | |
| 5 | − OrganizationIdSchema, | |
| 6 | − OrganizationMemberIdSchema, | |
| 7 | 5 | WorkspaceIdSchema, |
| 8 | − type Account, | |
| 9 | 6 | type LocalInstallation, |
| 10 | − type Organization, | |
| 11 | − type OrganizationMembership, | |
| 12 | − type Workspace, | |
| 13 | 7 | } from "#schema"; |
| 14 | 8 | import * as Effect from "effect/Effect"; |
| 15 | −import * as Option from "effect/Option"; | |
| 16 | 9 | |
| 17 | −const LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default"); | |
| 10 | +import type { ResolvedLocalWorkspaceContext } from "./local-config"; | |
| 18 | 11 | |
| 19 | −const buildAccount = (now: number): Account => { | |
| 20 | − const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`); | |
| 12 | +const LOCAL_ACCOUNT_ID = AccountIdSchema.make("acc_local_default"); | |
| 21 | 13 | |
| 22 | − return { | |
| 23 | − id, | |
| 24 | − provider: "local", | |
| 25 | − subject: `local:${id}`, | |
| 26 | − email: null, | |
| 27 | − displayName: "Local User", | |
| 28 | − createdAt: now, | |
| 29 | − updatedAt: now, | |
| 30 | − }; | |
| 31 | −}; | |
| 14 | +const stableHash = (value: string): string => | |
| 15 | + createHash("sha256").update(value).digest("hex").slice(0, 16); | |
| 32 | 16 | |
| 33 | −const buildOrganization = ( | |
| 34 | − accountId: Account["id"], | |
| 35 | − now: number, | |
| 36 | −): Organization => ({ | |
| 37 | − id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`), | |
| 38 | − slug: "personal", | |
| 39 | − name: "Personal", | |
| 40 | − status: "active", | |
| 41 | − createdByAccountId: accountId, | |
| 42 | − createdAt: now, | |
| 43 | − updatedAt: now, | |
| 44 | −}); | |
| 17 | +const normalizeSlashPath = (value: string): string => | |
| 18 | + value.replaceAll("\\", "/"); | |
| 45 | 19 | |
| 46 | −const buildOwnerMembership = ( | |
| 47 | − organizationId: Organization["id"], | |
| 48 | − accountId: Account["id"], | |
| 49 | − now: number, | |
| 50 | −): OrganizationMembership => ({ | |
| 51 | − id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`), | |
| 52 | − organizationId, | |
| 53 | − accountId, | |
| 54 | − role: "owner", | |
| 55 | − status: "active", | |
| 56 | − billable: true, | |
| 57 | − invitedByAccountId: null, | |
| 58 | − joinedAt: now, | |
| 59 | − createdAt: now, | |
| 60 | − updatedAt: now, | |
| 61 | −}); | |
| 20 | +const deriveWorkspaceId = (context: ResolvedLocalWorkspaceContext) => | |
| 21 | + WorkspaceIdSchema.make( | |
| 22 | + `ws_local_${stableHash(normalizeSlashPath(context.workspaceRoot))}`, | |
| 23 | + ); | |
| 62 | 24 | |
| 63 | −const buildWorkspace = ( | |
| 64 | − organizationId: Organization["id"], | |
| 65 | − accountId: Account["id"], | |
| 66 | − now: number, | |
| 67 | −): Workspace => ({ | |
| 68 | − id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`), | |
| 69 | − organizationId, | |
| 70 | − name: "Default", | |
| 71 | − createdByAccountId: accountId, | |
| 72 | − createdAt: now, | |
| 73 | − updatedAt: now, | |
| 25 | +export const deriveLocalInstallation = ( | |
| 26 | + context: ResolvedLocalWorkspaceContext, | |
| 27 | +): LocalInstallation => ({ | |
| 28 | + accountId: LOCAL_ACCOUNT_ID, | |
| 29 | + workspaceId: deriveWorkspaceId(context), | |
| 74 | 30 | }); |
| 75 | 31 | |
| 76 | 32 | export const loadLocalInstallation = ( |
| 77 | − rows: SqlControlPlaneRows, | |
| 78 | −): Effect.Effect<LocalInstallation | null, unknown> => | |
| 79 | − rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe( | |
| 80 | − Effect.map((result) => (Option.isSome(result) ? result.value : null)), | |
| 81 | − ); | |
| 82 | − | |
| 83 | −export const provisionLocalInstallation = ( | |
| 84 | − rows: SqlControlPlaneRows, | |
| 85 | −): Effect.Effect<LocalInstallation, unknown> => | |
| 86 | − Effect.gen(function* () { | |
| 87 | − const existing = yield* rows.localInstallations.getById(LOCAL_INSTALLATION_ID); | |
| 88 | − if (Option.isSome(existing)) { | |
| 89 | − return existing.value; | |
| 90 | − } | |
| 91 | − | |
| 92 | − const now = Date.now(); | |
| 93 | − const account = buildAccount(now); | |
| 94 | − const organization = buildOrganization(account.id, now); | |
| 95 | − const membership = buildOwnerMembership(organization.id, account.id, now); | |
| 96 | − const workspace = buildWorkspace(organization.id, account.id, now); | |
| 97 | − const installation: LocalInstallation = { | |
| 98 | − id: LOCAL_INSTALLATION_ID, | |
| 99 | − accountId: account.id, | |
| 100 | − organizationId: organization.id, | |
| 101 | − workspaceId: workspace.id, | |
| 102 | − createdAt: now, | |
| 103 | − updatedAt: now, | |
| 104 | − }; | |
| 105 | − | |
| 106 | − yield* rows.accounts.upsert(account); | |
| 107 | − yield* rows.organizations.insertWithOwnerMembership(organization, membership); | |
| 108 | − yield* rows.workspaces.insert(workspace); | |
| 109 | − yield* rows.localInstallations.upsert(installation); | |
| 110 | − | |
| 111 | − return installation; | |
| 112 | − }); | |
| 113 | − | |
| 114 | −export const getOrProvisionLocalInstallation = ( | |
| 115 | − rows: SqlControlPlaneRows, | |
| 116 | −): Effect.Effect<LocalInstallation, unknown> => | |
| 117 | − Effect.flatMap(loadLocalInstallation(rows), (existing) => | |
| 118 | − existing ? Effect.succeed(existing) : provisionLocalInstallation(rows) | |
| 119 | − ); | |
| 33 | + context: ResolvedLocalWorkspaceContext, | |
| 34 | +): Effect.Effect<LocalInstallation, never> => | |
| 35 | + Effect.succeed(deriveLocalInstallation(context)); | |
| 36 | + | |
| 37 | +export const getOrProvisionLocalInstallation = (input: { | |
| 38 | + context: ResolvedLocalWorkspaceContext; | |
| 39 | +}): Effect.Effect<LocalInstallation, never> => | |
| 40 | + loadLocalInstallation(input.context); | |
| @@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines | ||
| 17 | 17 | import { |
| 18 | 18 | operationErrors, |
| 19 | 19 | } from "./operation-errors"; |
| 20 | +import { resolveLocalWorkspaceContext } from "./local-config"; | |
| 21 | +import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context"; | |
| 20 | 22 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; |
| 21 | 23 | import { |
| 22 | 24 | createSourceCredentialSelectionBearerContent, |
| @@ -180,29 +182,18 @@ const loadSourceCredentialInteraction = (input: { · 157 unmodified lines | ||
| 180 | 182 | |
| 181 | 183 | export const getLocalInstallation = () => |
| 182 | 184 | Effect.gen(function* () { |
| 183 | − const store = yield* ControlPlaneStore; | |
| 184 | − | |
| 185 | − const installation = yield* loadLocalInstallation(store).pipe( | |
| 186 | − Effect.mapError((error) => | |
| 187 | − error instanceof ControlPlanePersistenceError | |
| 188 | − ? localOps.installation.storage(error) | |
| 189 | − : localOps.installation.unknownStorage( | |
| 190 | − error, | |
| 191 | − "Failed loading local installation", | |
| 192 | − ), | |
| 193 | − ), | |
| 194 | − ); | |
| 195 | − | |
| 196 | − if (installation === null) { | |
| 197 | − return yield* Effect.fail( | |
| 198 | − localOps.installation.notFound( | |
| 199 | − "Local installation not found", | |
| 200 | − "No local installation has been provisioned", | |
| 201 | − ), | |
| 202 | − ); | |
| 203 | − } | |
| 185 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 186 | + const context = runtimeLocalWorkspace?.context | |
| 187 | + ?? (yield* resolveLocalWorkspaceContext().pipe( | |
| 188 | + Effect.mapError((error) => | |
| 189 | + localOps.installation.unknownStorage( | |
| 190 | + error, | |
| 191 | + "Failed resolving local workspace context", | |
| 192 | + ), | |
| 193 | + ), | |
| 194 | + )); | |
| 204 | 195 | |
| 205 | − return installation; | |
| 196 | + return yield* loadLocalInstallation(context); | |
| 206 | 197 | }); |
| 207 | 198 | |
| 208 | 199 | export const getSourceCredentialInteraction = (input: { |
| @@ -0,0 +1,71 @@ | ||
| 1 | +import * as Context from "effect/Context"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Option from "effect/Option"; | |
| 4 | + | |
| 5 | +import type { AccountId, WorkspaceId } from "#schema"; | |
| 6 | +import type { | |
| 7 | + LoadedLocalExecutorConfig, | |
| 8 | + ResolvedLocalWorkspaceContext, | |
| 9 | +} from "./local-config"; | |
| 10 | +import { | |
| 11 | + RuntimeLocalWorkspaceMismatchError, | |
| 12 | + RuntimeLocalWorkspaceUnavailableError, | |
| 13 | +} from "./local-errors"; | |
| 14 | + | |
| 15 | +export type RuntimeLocalWorkspaceState = { | |
| 16 | + context: ResolvedLocalWorkspaceContext; | |
| 17 | + installation: { | |
| 18 | + workspaceId: WorkspaceId; | |
| 19 | + accountId: AccountId; | |
| 20 | + }; | |
| 21 | + loadedConfig: LoadedLocalExecutorConfig; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export class RuntimeLocalWorkspaceService extends Context.Tag( | |
| 25 | + "#runtime/RuntimeLocalWorkspaceService", | |
| 26 | +)<RuntimeLocalWorkspaceService, RuntimeLocalWorkspaceState>() {} | |
| 27 | + | |
| 28 | +export const getRuntimeLocalWorkspaceOption = () => | |
| 29 | + Effect.contextWith((context) => | |
| 30 | + Context.getOption(context, RuntimeLocalWorkspaceService), | |
| 31 | + ).pipe( | |
| 32 | + Effect.map((option) => (Option.isSome(option) ? option.value : null)), | |
| 33 | + ) as Effect.Effect<RuntimeLocalWorkspaceState | null, never, never>; | |
| 34 | + | |
| 35 | +export const requireRuntimeLocalWorkspace = ( | |
| 36 | + workspaceId?: WorkspaceId, | |
| 37 | +): Effect.Effect< | |
| 38 | + RuntimeLocalWorkspaceState, | |
| 39 | + RuntimeLocalWorkspaceUnavailableError | RuntimeLocalWorkspaceMismatchError, | |
| 40 | + never | |
| 41 | +> => | |
| 42 | + Effect.gen(function* () { | |
| 43 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 44 | + if (runtimeLocalWorkspace === null) { | |
| 45 | + return yield* Effect.fail( | |
| 46 | + new RuntimeLocalWorkspaceUnavailableError({ | |
| 47 | + message: "Runtime local workspace is unavailable", | |
| 48 | + }), | |
| 49 | + ); | |
| 50 | + } | |
| 51 | + | |
| 52 | + if ( | |
| 53 | + workspaceId !== undefined | |
| 54 | + && runtimeLocalWorkspace.installation.workspaceId !== workspaceId | |
| 55 | + ) { | |
| 56 | + return yield* Effect.fail( | |
| 57 | + new RuntimeLocalWorkspaceMismatchError({ | |
| 58 | + message: `Workspace ${workspaceId} is not the active local workspace ${runtimeLocalWorkspace.installation.workspaceId}`, | |
| 59 | + requestedWorkspaceId: workspaceId, | |
| 60 | + activeWorkspaceId: runtimeLocalWorkspace.installation.workspaceId, | |
| 61 | + }), | |
| 62 | + ); | |
| 63 | + } | |
| 64 | + | |
| 65 | + return runtimeLocalWorkspace; | |
| 66 | + }); | |
| 67 | + | |
| 68 | +export const requireRuntimeLocalAccountId = (workspaceId?: WorkspaceId) => | |
| 69 | + requireRuntimeLocalWorkspace(workspaceId).pipe( | |
| 70 | + Effect.map((runtimeLocalWorkspace) => runtimeLocalWorkspace.installation.accountId), | |
| 71 | + ); | |
| @@ -0,0 +1,230 @@ | ||
| 1 | +import { join } from "node:path"; | |
| 2 | +import { FileSystem } from "@effect/platform"; | |
| 3 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + SourceIdSchema, | |
| 7 | + SourceRecipeIdSchema, | |
| 8 | + StoredSourceRecipeDocumentRecordSchema, | |
| 9 | + StoredSourceRecipeOperationRecordSchema, | |
| 10 | + StoredSourceRecipeRevisionRecordSchema, | |
| 11 | + StoredSourceRecipeSchemaBundleRecordSchema, | |
| 12 | + TimestampMsSchema, | |
| 13 | + type Source, | |
| 14 | + type SourceRecipeId, | |
| 15 | +} from "#schema"; | |
| 16 | +import * as Effect from "effect/Effect"; | |
| 17 | +import * as Schema from "effect/Schema"; | |
| 18 | + | |
| 19 | +import type { ResolvedLocalWorkspaceContext } from "./local-config"; | |
| 20 | +import { | |
| 21 | + LocalFileSystemError, | |
| 22 | + LocalSourceArtifactDecodeError, | |
| 23 | + unknownLocalErrorDetails, | |
| 24 | +} from "./local-errors"; | |
| 25 | +import { | |
| 26 | + createSourceRecipeRevisionRecord, | |
| 27 | + stableSourceRecipeId, | |
| 28 | +} from "./source-definitions"; | |
| 29 | +import { | |
| 30 | + contentHash, | |
| 31 | + type SourceRecipeMaterialization, | |
| 32 | +} from "./source-recipe-support"; | |
| 33 | + | |
| 34 | +const LOCAL_SOURCE_ARTIFACT_VERSION = 1 as const; | |
| 35 | + | |
| 36 | +export const LocalSourceArtifactSchema = Schema.Struct({ | |
| 37 | + version: Schema.Literal(LOCAL_SOURCE_ARTIFACT_VERSION), | |
| 38 | + sourceId: SourceIdSchema, | |
| 39 | + recipeId: SourceRecipeIdSchema, | |
| 40 | + generatedAt: TimestampMsSchema, | |
| 41 | + revision: StoredSourceRecipeRevisionRecordSchema, | |
| 42 | + documents: Schema.Array(StoredSourceRecipeDocumentRecordSchema), | |
| 43 | + schemaBundles: Schema.Array(StoredSourceRecipeSchemaBundleRecordSchema), | |
| 44 | + operations: Schema.Array(StoredSourceRecipeOperationRecordSchema), | |
| 45 | +}); | |
| 46 | + | |
| 47 | +export type LocalSourceArtifact = typeof LocalSourceArtifactSchema.Type; | |
| 48 | + | |
| 49 | +const decodeLocalSourceArtifact = Schema.decodeUnknownSync(LocalSourceArtifactSchema); | |
| 50 | + | |
| 51 | +const provideNodeFileSystem = <A, E, R>( | |
| 52 | + effect: Effect.Effect<A, E, R | FileSystem.FileSystem>, | |
| 53 | +): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> => | |
| 54 | + effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect< | |
| 55 | + A, | |
| 56 | + E, | |
| 57 | + Exclude<R, FileSystem.FileSystem> | |
| 58 | + >; | |
| 59 | + | |
| 60 | +const mapFileSystemError = (path: string, action: string) => (cause: unknown) => | |
| 61 | + new LocalFileSystemError({ | |
| 62 | + message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`, | |
| 63 | + action, | |
| 64 | + path, | |
| 65 | + details: unknownLocalErrorDetails(cause), | |
| 66 | + }); | |
| 67 | + | |
| 68 | +const localSourceArtifactPath = (input: { | |
| 69 | + context: ResolvedLocalWorkspaceContext; | |
| 70 | + sourceId: string; | |
| 71 | +}): string => | |
| 72 | + join( | |
| 73 | + input.context.artifactsDirectory, | |
| 74 | + "sources", | |
| 75 | + `${input.sourceId}.json`, | |
| 76 | + ); | |
| 77 | + | |
| 78 | +const canonicalMaterializationHash = (input: { | |
| 79 | + materialization: SourceRecipeMaterialization; | |
| 80 | +}): string => { | |
| 81 | + const documents = [...input.materialization.documents] | |
| 82 | + .map((document) => ({ | |
| 83 | + documentKind: document.documentKind, | |
| 84 | + documentKey: document.documentKey, | |
| 85 | + contentHash: document.contentHash, | |
| 86 | + })) | |
| 87 | + .sort((left, right) => | |
| 88 | + left.documentKind.localeCompare(right.documentKind) | |
| 89 | + || left.documentKey.localeCompare(right.documentKey) | |
| 90 | + || left.contentHash.localeCompare(right.contentHash) | |
| 91 | + ); | |
| 92 | + const schemaBundles = [...input.materialization.schemaBundles] | |
| 93 | + .map((bundle) => ({ | |
| 94 | + bundleKind: bundle.bundleKind, | |
| 95 | + contentHash: bundle.contentHash, | |
| 96 | + })) | |
| 97 | + .sort((left, right) => | |
| 98 | + left.bundleKind.localeCompare(right.bundleKind) | |
| 99 | + || left.contentHash.localeCompare(right.contentHash) | |
| 100 | + ); | |
| 101 | + const operations = [...input.materialization.operations] | |
| 102 | + .map((operation) => ({ | |
| 103 | + operationKey: operation.operationKey, | |
| 104 | + transportKind: operation.transportKind, | |
| 105 | + toolId: operation.toolId, | |
| 106 | + title: operation.title, | |
| 107 | + description: operation.description, | |
| 108 | + operationKind: operation.operationKind, | |
| 109 | + searchText: operation.searchText, | |
| 110 | + inputSchemaJson: operation.inputSchemaJson, | |
| 111 | + outputSchemaJson: operation.outputSchemaJson, | |
| 112 | + providerKind: operation.providerKind, | |
| 113 | + providerDataJson: operation.providerDataJson, | |
| 114 | + })) | |
| 115 | + .sort((left, right) => left.operationKey.localeCompare(right.operationKey)); | |
| 116 | + | |
| 117 | + return contentHash(JSON.stringify({ | |
| 118 | + schemaVersion: 1, | |
| 119 | + manifestHash: input.materialization.manifestHash, | |
| 120 | + manifestJson: input.materialization.manifestJson, | |
| 121 | + documents, | |
| 122 | + schemaBundles, | |
| 123 | + operations, | |
| 124 | + })); | |
| 125 | +}; | |
| 126 | + | |
| 127 | +const bindRevisionId = <T extends { recipeRevisionId: string }>( | |
| 128 | + items: readonly T[], | |
| 129 | + recipeRevisionId: string, | |
| 130 | +): T[] => | |
| 131 | + items.map((item) => ({ | |
| 132 | + ...item, | |
| 133 | + recipeRevisionId, | |
| 134 | + })); | |
| 135 | + | |
| 136 | +export const buildLocalSourceArtifact = (input: { | |
| 137 | + source: Source; | |
| 138 | + materialization: SourceRecipeMaterialization; | |
| 139 | +}): LocalSourceArtifact => { | |
| 140 | + const recipeId: SourceRecipeId = stableSourceRecipeId(input.source); | |
| 141 | + const now = Date.now(); | |
| 142 | + const revision = createSourceRecipeRevisionRecord({ | |
| 143 | + source: input.source, | |
| 144 | + recipeId, | |
| 145 | + revisionNumber: 1, | |
| 146 | + manifestJson: input.materialization.manifestJson, | |
| 147 | + manifestHash: input.materialization.manifestHash, | |
| 148 | + materializationHash: canonicalMaterializationHash({ | |
| 149 | + materialization: input.materialization, | |
| 150 | + }), | |
| 151 | + }); | |
| 152 | + | |
| 153 | + return { | |
| 154 | + version: LOCAL_SOURCE_ARTIFACT_VERSION, | |
| 155 | + sourceId: input.source.id, | |
| 156 | + recipeId, | |
| 157 | + generatedAt: now, | |
| 158 | + revision, | |
| 159 | + documents: bindRevisionId(input.materialization.documents, revision.id), | |
| 160 | + schemaBundles: bindRevisionId(input.materialization.schemaBundles, revision.id), | |
| 161 | + operations: bindRevisionId(input.materialization.operations, revision.id), | |
| 162 | + }; | |
| 163 | +}; | |
| 164 | + | |
| 165 | +export const readLocalSourceArtifact = (input: { | |
| 166 | + context: ResolvedLocalWorkspaceContext; | |
| 167 | + sourceId: string; | |
| 168 | +}): Effect.Effect< | |
| 169 | + LocalSourceArtifact | null, | |
| 170 | + LocalFileSystemError | LocalSourceArtifactDecodeError | |
| 171 | +> => | |
| 172 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 173 | + const fs = yield* FileSystem.FileSystem; | |
| 174 | + const path = localSourceArtifactPath(input); | |
| 175 | + const exists = yield* fs.exists(path).pipe( | |
| 176 | + Effect.mapError(mapFileSystemError(path, "check source artifact path")), | |
| 177 | + ); | |
| 178 | + if (!exists) { | |
| 179 | + return null; | |
| 180 | + } | |
| 181 | + | |
| 182 | + const content = yield* fs.readFileString(path, "utf8").pipe( | |
| 183 | + Effect.mapError(mapFileSystemError(path, "read source artifact")), | |
| 184 | + ); | |
| 185 | + return yield* Effect.try({ | |
| 186 | + try: () => decodeLocalSourceArtifact(JSON.parse(content) as unknown), | |
| 187 | + catch: (cause) => { | |
| 188 | + return new LocalSourceArtifactDecodeError({ | |
| 189 | + message: `Invalid local source artifact at ${path}: ${unknownLocalErrorDetails(cause)}`, | |
| 190 | + path, | |
| 191 | + details: unknownLocalErrorDetails(cause), | |
| 192 | + }); | |
| 193 | + }, | |
| 194 | + }); | |
| 195 | + })); | |
| 196 | + | |
| 197 | +export const writeLocalSourceArtifact = (input: { | |
| 198 | + context: ResolvedLocalWorkspaceContext; | |
| 199 | + sourceId: string; | |
| 200 | + artifact: LocalSourceArtifact; | |
| 201 | +}): Effect.Effect<void, LocalFileSystemError> => | |
| 202 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 203 | + const fs = yield* FileSystem.FileSystem; | |
| 204 | + const directory = join(input.context.artifactsDirectory, "sources"); | |
| 205 | + const path = localSourceArtifactPath(input); | |
| 206 | + yield* fs.makeDirectory(directory, { recursive: true }).pipe( | |
| 207 | + Effect.mapError(mapFileSystemError(directory, "create source artifact directory")), | |
| 208 | + ); | |
| 209 | + yield* fs.writeFileString(path, `${JSON.stringify(input.artifact, null, 2)}\n`).pipe( | |
| 210 | + Effect.mapError(mapFileSystemError(path, "write source artifact")), | |
| 211 | + ); | |
| 212 | + })); | |
| 213 | + | |
| 214 | +export const removeLocalSourceArtifact = (input: { | |
| 215 | + context: ResolvedLocalWorkspaceContext; | |
| 216 | + sourceId: string; | |
| 217 | +}): Effect.Effect<void, LocalFileSystemError> => | |
| 218 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 219 | + const fs = yield* FileSystem.FileSystem; | |
| 220 | + const path = localSourceArtifactPath(input); | |
| 221 | + const exists = yield* fs.exists(path).pipe( | |
| 222 | + Effect.mapError(mapFileSystemError(path, "check source artifact path")), | |
| 223 | + ); | |
| 224 | + if (!exists) { | |
| 225 | + return; | |
| 226 | + } | |
| 227 | + yield* fs.remove(path).pipe( | |
| 228 | + Effect.mapError(mapFileSystemError(path, "remove source artifact")), | |
| 229 | + ); | |
| 230 | + })); | |
| @@ -0,0 +1,124 @@ | ||
| 1 | +import { join } from "node:path"; | |
| 2 | +import { FileSystem } from "@effect/platform"; | |
| 3 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + PolicyIdSchema, | |
| 7 | + SourceStatusSchema, | |
| 8 | + TimestampMsSchema, | |
| 9 | +} from "#schema"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 11 | +import * as Schema from "effect/Schema"; | |
| 12 | + | |
| 13 | +import type { ResolvedLocalWorkspaceContext } from "./local-config"; | |
| 14 | +import { | |
| 15 | + LocalFileSystemError, | |
| 16 | + LocalWorkspaceStateDecodeError, | |
| 17 | + unknownLocalErrorDetails, | |
| 18 | +} from "./local-errors"; | |
| 19 | + | |
| 20 | +const WORKSPACE_STATE_BASENAME = "workspace-state.json"; | |
| 21 | + | |
| 22 | +const LocalWorkspaceSourceStateSchema = Schema.Struct({ | |
| 23 | + status: SourceStatusSchema, | |
| 24 | + lastError: Schema.NullOr(Schema.String), | |
| 25 | + sourceHash: Schema.NullOr(Schema.String), | |
| 26 | + createdAt: TimestampMsSchema, | |
| 27 | + updatedAt: TimestampMsSchema, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const LocalWorkspacePolicyStateSchema = Schema.Struct({ | |
| 31 | + id: PolicyIdSchema, | |
| 32 | + createdAt: TimestampMsSchema, | |
| 33 | + updatedAt: TimestampMsSchema, | |
| 34 | +}); | |
| 35 | + | |
| 36 | +export const LocalWorkspaceStateSchema = Schema.Struct({ | |
| 37 | + version: Schema.Literal(1), | |
| 38 | + sources: Schema.Record({ | |
| 39 | + key: Schema.String, | |
| 40 | + value: LocalWorkspaceSourceStateSchema, | |
| 41 | + }), | |
| 42 | + policies: Schema.Record({ | |
| 43 | + key: Schema.String, | |
| 44 | + value: LocalWorkspacePolicyStateSchema, | |
| 45 | + }), | |
| 46 | +}); | |
| 47 | + | |
| 48 | +export type LocalWorkspaceSourceState = typeof LocalWorkspaceSourceStateSchema.Type; | |
| 49 | +export type LocalWorkspacePolicyState = typeof LocalWorkspacePolicyStateSchema.Type; | |
| 50 | +export type LocalWorkspaceState = typeof LocalWorkspaceStateSchema.Type; | |
| 51 | + | |
| 52 | +const decodeLocalWorkspaceState = Schema.decodeUnknownSync(LocalWorkspaceStateSchema); | |
| 53 | + | |
| 54 | +const provideNodeFileSystem = <A, E, R>( | |
| 55 | + effect: Effect.Effect<A, E, R | FileSystem.FileSystem>, | |
| 56 | +): Effect.Effect<A, E, Exclude<R, FileSystem.FileSystem>> => | |
| 57 | + effect.pipe(Effect.provide(NodeFileSystem.layer)) as Effect.Effect< | |
| 58 | + A, | |
| 59 | + E, | |
| 60 | + Exclude<R, FileSystem.FileSystem> | |
| 61 | + >; | |
| 62 | + | |
| 63 | +const mapFileSystemError = (path: string, action: string) => (cause: unknown) => | |
| 64 | + new LocalFileSystemError({ | |
| 65 | + message: `Failed to ${action} ${path}: ${unknownLocalErrorDetails(cause)}`, | |
| 66 | + action, | |
| 67 | + path, | |
| 68 | + details: unknownLocalErrorDetails(cause), | |
| 69 | + }); | |
| 70 | + | |
| 71 | +const defaultLocalWorkspaceState = (): LocalWorkspaceState => ({ | |
| 72 | + version: 1, | |
| 73 | + sources: {}, | |
| 74 | + policies: {}, | |
| 75 | +}); | |
| 76 | + | |
| 77 | +export const localWorkspaceStatePath = ( | |
| 78 | + context: ResolvedLocalWorkspaceContext, | |
| 79 | +): string => join(context.stateDirectory, WORKSPACE_STATE_BASENAME); | |
| 80 | + | |
| 81 | +export const loadLocalWorkspaceState = ( | |
| 82 | + context: ResolvedLocalWorkspaceContext, | |
| 83 | +): Effect.Effect<LocalWorkspaceState, LocalFileSystemError | LocalWorkspaceStateDecodeError> => | |
| 84 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 85 | + const fs = yield* FileSystem.FileSystem; | |
| 86 | + const path = localWorkspaceStatePath(context); | |
| 87 | + const exists = yield* fs.exists(path).pipe( | |
| 88 | + Effect.mapError(mapFileSystemError(path, "check workspace state path")), | |
| 89 | + ); | |
| 90 | + if (!exists) { | |
| 91 | + return defaultLocalWorkspaceState(); | |
| 92 | + } | |
| 93 | + | |
| 94 | + const content = yield* fs.readFileString(path, "utf8").pipe( | |
| 95 | + Effect.mapError(mapFileSystemError(path, "read workspace state")), | |
| 96 | + ); | |
| 97 | + return yield* Effect.try({ | |
| 98 | + try: () => decodeLocalWorkspaceState(JSON.parse(content) as unknown), | |
| 99 | + catch: (cause) => { | |
| 100 | + return new LocalWorkspaceStateDecodeError({ | |
| 101 | + message: `Invalid local workspace state at ${path}: ${unknownLocalErrorDetails(cause)}`, | |
| 102 | + path, | |
| 103 | + details: unknownLocalErrorDetails(cause), | |
| 104 | + }); | |
| 105 | + }, | |
| 106 | + }); | |
| 107 | + })); | |
| 108 | + | |
| 109 | +export const writeLocalWorkspaceState = (input: { | |
| 110 | + context: ResolvedLocalWorkspaceContext; | |
| 111 | + state: LocalWorkspaceState; | |
| 112 | +}): Effect.Effect<void, LocalFileSystemError> => | |
| 113 | + provideNodeFileSystem(Effect.gen(function* () { | |
| 114 | + const fs = yield* FileSystem.FileSystem; | |
| 115 | + yield* fs.makeDirectory(input.context.stateDirectory, { recursive: true }).pipe( | |
| 116 | + Effect.mapError(mapFileSystemError(input.context.stateDirectory, "create state directory")), | |
| 117 | + ); | |
| 118 | + yield* fs.writeFileString( | |
| 119 | + localWorkspaceStatePath(input.context), | |
| 120 | + `${JSON.stringify(input.state, null, 2)}\n`, | |
| 121 | + ).pipe( | |
| 122 | + Effect.mapError(mapFileSystemError(localWorkspaceStatePath(input.context), "write workspace state")), | |
| 123 | + ); | |
| 124 | + })); | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + type LoadedLocalExecutorConfig, | |
| 5 | + type ResolvedLocalWorkspaceContext, | |
| 6 | +} from "./local-config"; | |
| 7 | +import { | |
| 8 | + loadLocalWorkspaceState, | |
| 9 | + writeLocalWorkspaceState, | |
| 10 | + type LocalWorkspaceState, | |
| 11 | +} from "./local-workspace-state"; | |
| 12 | + | |
| 13 | +const trimOrNull = (value: string | null | undefined): string | null => { | |
| 14 | + if (value == null) { | |
| 15 | + return null; | |
| 16 | + } | |
| 17 | + const trimmed = value.trim(); | |
| 18 | + return trimmed.length > 0 ? trimmed : null; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +export const derivePolicyConfigKey = ( | |
| 22 | + policy: { | |
| 23 | + resourcePattern: string; | |
| 24 | + effect: "allow" | "deny"; | |
| 25 | + approvalMode: "auto" | "required"; | |
| 26 | + }, | |
| 27 | + used: Set<string>, | |
| 28 | +): string => { | |
| 29 | + const base = | |
| 30 | + trimOrNull(policy.resourcePattern) | |
| 31 | + ?? `${policy.effect}-${policy.approvalMode}`; | |
| 32 | + let candidate = base; | |
| 33 | + let counter = 2; | |
| 34 | + while (used.has(candidate)) { | |
| 35 | + candidate = `${base}-${counter}`; | |
| 36 | + counter += 1; | |
| 37 | + } | |
| 38 | + used.add(candidate); | |
| 39 | + return candidate; | |
| 40 | +}; | |
| 41 | + | |
| 42 | +const pruneLocalWorkspaceState = (input: { | |
| 43 | + context: ResolvedLocalWorkspaceContext; | |
| 44 | + loadedConfig: LoadedLocalExecutorConfig; | |
| 45 | +}): Effect.Effect<LocalWorkspaceState, Error, never> => | |
| 46 | + Effect.gen(function* () { | |
| 47 | + const currentState = yield* loadLocalWorkspaceState(input.context); | |
| 48 | + | |
| 49 | + const configuredSourceIds = new Set( | |
| 50 | + Object.keys(input.loadedConfig.config?.sources ?? {}), | |
| 51 | + ); | |
| 52 | + const configuredPolicyKeys = new Set( | |
| 53 | + Object.keys(input.loadedConfig.config?.policies ?? {}), | |
| 54 | + ); | |
| 55 | + | |
| 56 | + const nextState: LocalWorkspaceState = { | |
| 57 | + ...currentState, | |
| 58 | + sources: Object.fromEntries( | |
| 59 | + Object.entries(currentState.sources).filter(([sourceId]) => | |
| 60 | + configuredSourceIds.has(sourceId) | |
| 61 | + ), | |
| 62 | + ), | |
| 63 | + policies: Object.fromEntries( | |
| 64 | + Object.entries(currentState.policies).filter(([policyKey]) => | |
| 65 | + configuredPolicyKeys.has(policyKey) | |
| 66 | + ), | |
| 67 | + ), | |
| 68 | + }; | |
| 69 | + | |
| 70 | + if (JSON.stringify(nextState) === JSON.stringify(currentState)) { | |
| 71 | + return currentState; | |
| 72 | + } | |
| 73 | + | |
| 74 | + yield* writeLocalWorkspaceState({ | |
| 75 | + context: input.context, | |
| 76 | + state: nextState, | |
| 77 | + }); | |
| 78 | + | |
| 79 | + return nextState; | |
| 80 | + }); | |
| 81 | + | |
| 82 | +export const synchronizeLocalWorkspaceState = (input: { | |
| 83 | + context: ResolvedLocalWorkspaceContext; | |
| 84 | + loadedConfig: LoadedLocalExecutorConfig; | |
| 85 | +}): Effect.Effect<LoadedLocalExecutorConfig["config"], Error, never> => | |
| 86 | + Effect.gen(function* () { | |
| 87 | + yield* pruneLocalWorkspaceState({ | |
| 88 | + context: input.context, | |
| 89 | + loadedConfig: input.loadedConfig, | |
| 90 | + }); | |
| 91 | + | |
| 92 | + return input.loadedConfig.config; | |
| 93 | + }); | |
| @@ -2,7 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | ControlPlaneBadRequestError, |
| 3 | 3 | ControlPlaneNotFoundError, |
| 4 | 4 | ControlPlaneStorageError, |
| 5 | −} from "#api"; | |
| 5 | +} from "../api/errors"; | |
| 6 | 6 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | |
| @@ -1,21 +1,14 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | ControlPlaneBadRequestError, |
| 3 | − ControlPlaneNotFoundError, | |
| 4 | 3 | ControlPlaneStorageError, |
| 5 | −} from "#api"; | |
| 4 | +} from "../api/errors"; | |
| 6 | 5 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | −import type { Organization } from "#schema"; | |
| 8 | 6 | import * as Effect from "effect/Effect"; |
| 9 | −import * as Option from "effect/Option"; | |
| 10 | 7 | |
| 11 | 8 | import { |
| 12 | 9 | asOperationErrors, |
| 13 | − operationErrors, | |
| 14 | 10 | type OperationErrorsLike, |
| 15 | 11 | } from "./operation-errors"; |
| 16 | −import type { ControlPlaneStoreShape } from "./store"; | |
| 17 | −import { slugify } from "./slug"; | |
| 18 | − | |
| 19 | 12 | export type Mutable<T> = { -readonly [K in keyof T]: T[K] }; |
| 20 | 13 | |
| 21 | 14 | const isUniqueViolation = (error: ControlPlanePersistenceError): boolean => |
| @@ -59,51 +52,3 @@ export const parseJsonString = ( · 37 unmodified lines | ||
| 59 | 52 | ); |
| 60 | 53 | }, |
| 61 | 54 | }); |
| 62 | − | |
| 63 | −export const ensureUniqueOrganizationSlug = ( | |
| 64 | − store: ControlPlaneStoreShape, | |
| 65 | − baseName: string, | |
| 66 | − operation = operationErrors("organizations.create"), | |
| 67 | −): Effect.Effect<string, ControlPlaneStorageError> => | |
| 68 | − Effect.gen(function* () { | |
| 69 | − const normalized = slugify(baseName); | |
| 70 | − const seed = normalized.length > 0 ? normalized : "item"; | |
| 71 | − | |
| 72 | − let counter = 0; | |
| 73 | − while (true) { | |
| 74 | − const candidate = counter === 0 ? seed : `${seed}-${counter + 1}`; | |
| 75 | − | |
| 76 | − const existing = yield* operation.child("slug_lookup").mapStorage( | |
| 77 | − store.organizations.getBySlug(candidate as Organization["slug"]), | |
| 78 | − ); | |
| 79 | − | |
| 80 | − if (Option.isNone(existing)) { | |
| 81 | − return candidate; | |
| 82 | − } | |
| 83 | − | |
| 84 | − counter += 1; | |
| 85 | − } | |
| 86 | − }); | |
| 87 | − | |
| 88 | −export const ensureOrganizationExists = ( | |
| 89 | − store: ControlPlaneStoreShape, | |
| 90 | − operation: OperationErrorsLike, | |
| 91 | − organizationId: Organization["id"], | |
| 92 | −): Effect.Effect<Organization, ControlPlaneNotFoundError | ControlPlaneStorageError> => | |
| 93 | − Effect.gen(function* () { | |
| 94 | − const errors = asOperationErrors(operation); | |
| 95 | − const organization = yield* errors.child("organization_lookup").mapStorage( | |
| 96 | − store.organizations.getById(organizationId), | |
| 97 | − ); | |
| 98 | − | |
| 99 | − if (Option.isNone(organization)) { | |
| 100 | − return yield* Effect.fail( | |
| 101 | − errors.notFound( | |
| 102 | − "Organization not found", | |
| 103 | − `organizationId=${organizationId}`, | |
| 104 | − ), | |
| 105 | − ); | |
| 106 | − } | |
| 107 | − | |
| 108 | − return organization.value; | |
| 109 | − }); | |
| @@ -1,434 +0,0 @@ | ||
| 1 | −import type { | |
| 2 | − CreateMembershipPayload, | |
| 3 | − UpdateMembershipPayload, | |
| 4 | −} from "../api/memberships/api"; | |
| 5 | −import type { | |
| 6 | − CreateOrganizationPayload, | |
| 7 | − UpdateOrganizationPayload, | |
| 8 | −} from "../api/organizations/api"; | |
| 9 | −import type { | |
| 10 | − CreateWorkspacePayload, | |
| 11 | − UpdateWorkspacePayload, | |
| 12 | −} from "../api/workspaces/api"; | |
| 13 | −import { | |
| 14 | − OrganizationIdSchema, | |
| 15 | − OrganizationMemberIdSchema, | |
| 16 | − WorkspaceIdSchema, | |
| 17 | − type AccountId, | |
| 18 | − type Organization, | |
| 19 | − type OrganizationId, | |
| 20 | − type OrganizationMembership, | |
| 21 | − type Workspace, | |
| 22 | − type WorkspaceId, | |
| 23 | −} from "#schema"; | |
| 24 | −import * as Effect from "effect/Effect"; | |
| 25 | −import * as Option from "effect/Option"; | |
| 26 | − | |
| 27 | −import { | |
| 28 | − ensureOrganizationExists, | |
| 29 | − ensureUniqueOrganizationSlug, | |
| 30 | − mapPersistenceError, | |
| 31 | − type Mutable, | |
| 32 | −} from "./operations-shared"; | |
| 33 | −import { | |
| 34 | − operationErrors, | |
| 35 | −} from "./operation-errors"; | |
| 36 | −import { | |
| 37 | − ControlPlaneStore, | |
| 38 | − type ControlPlaneStoreShape, | |
| 39 | −} from "./store"; | |
| 40 | − | |
| 41 | −const organizationOps = { | |
| 42 | − list: operationErrors("organizations.list"), | |
| 43 | − create: operationErrors("organizations.create"), | |
| 44 | − get: operationErrors("organizations.get"), | |
| 45 | − update: operationErrors("organizations.update"), | |
| 46 | − remove: operationErrors("organizations.remove"), | |
| 47 | −} as const; | |
| 48 | − | |
| 49 | −const membershipOps = { | |
| 50 | − list: operationErrors("memberships.list"), | |
| 51 | − create: operationErrors("memberships.create"), | |
| 52 | − update: operationErrors("memberships.update"), | |
| 53 | − remove: operationErrors("memberships.remove"), | |
| 54 | −} as const; | |
| 55 | − | |
| 56 | −const workspaceOps = { | |
| 57 | − list: operationErrors("workspaces.list"), | |
| 58 | − create: operationErrors("workspaces.create"), | |
| 59 | − get: operationErrors("workspaces.get"), | |
| 60 | − update: operationErrors("workspaces.update"), | |
| 61 | − remove: operationErrors("workspaces.remove"), | |
| 62 | −} as const; | |
| 63 | − | |
| 64 | −const listOrganizationsWithStore = ( | |
| 65 | − store: ControlPlaneStoreShape, | |
| 66 | − input: { accountId: AccountId }, | |
| 67 | −) => | |
| 68 | − Effect.gen(function* () { | |
| 69 | − const memberships = yield* organizationOps.list.child("memberships").mapStorage( | |
| 70 | − store.organizationMemberships.listByAccountId(input.accountId), | |
| 71 | − ); | |
| 72 | − | |
| 73 | − const activeOrganizationIds = Array.from( | |
| 74 | − new Set( | |
| 75 | − memberships | |
| 76 | − .filter((membership) => membership.status === "active") | |
| 77 | − .map((membership) => membership.organizationId), | |
| 78 | − ), | |
| 79 | − ); | |
| 80 | − | |
| 81 | − const organizations = yield* Effect.forEach(activeOrganizationIds, (organizationId) => | |
| 82 | − organizationOps.list.child("organization").mapStorage( | |
| 83 | − store.organizations.getById(organizationId), | |
| 84 | − ).pipe( | |
| 85 | − Effect.map((result) => (Option.isSome(result) ? result.value : null)), | |
| 86 | − )); | |
| 87 | − | |
| 88 | − return organizations.filter((organization): organization is Organization => organization !== null); | |
| 89 | − }); | |
| 90 | − | |
| 91 | −export const listOrganizations = (input: { accountId: AccountId }) => | |
| 92 | − Effect.flatMap(ControlPlaneStore, (store) => listOrganizationsWithStore(store, input)); | |
| 93 | − | |
| 94 | −export const createOrganization = (input: { | |
| 95 | − payload: CreateOrganizationPayload; | |
| 96 | − createdByAccountId?: Organization["createdByAccountId"]; | |
| 97 | −}) => | |
| 98 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 99 | − Effect.gen(function* () { | |
| 100 | − const name = input.payload.name; | |
| 101 | − const now = Date.now(); | |
| 102 | − | |
| 103 | − const slug = input.payload.slug | |
| 104 | − ? input.payload.slug | |
| 105 | − : yield* ensureUniqueOrganizationSlug(store, name, organizationOps.create); | |
| 106 | − | |
| 107 | − const organization: Organization = { | |
| 108 | − id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`), | |
| 109 | − slug, | |
| 110 | − name, | |
| 111 | − status: "active", | |
| 112 | − createdByAccountId: input.createdByAccountId ?? null, | |
| 113 | − createdAt: now, | |
| 114 | − updatedAt: now, | |
| 115 | − }; | |
| 116 | − | |
| 117 | − const ownerMembership: OrganizationMembership | null = input.createdByAccountId | |
| 118 | − ? { | |
| 119 | − id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`), | |
| 120 | − organizationId: organization.id, | |
| 121 | − accountId: input.createdByAccountId, | |
| 122 | − role: "owner", | |
| 123 | − status: "active", | |
| 124 | − billable: true, | |
| 125 | − invitedByAccountId: null, | |
| 126 | − joinedAt: now, | |
| 127 | − createdAt: now, | |
| 128 | − updatedAt: now, | |
| 129 | − } | |
| 130 | − : null; | |
| 131 | − | |
| 132 | − yield* mapPersistenceError( | |
| 133 | − organizationOps.create, | |
| 134 | − store.organizations.insertWithOwnerMembership(organization, ownerMembership), | |
| 135 | − ); | |
| 136 | − | |
| 137 | − return organization; | |
| 138 | − })); | |
| 139 | − | |
| 140 | −export const getOrganization = (input: { | |
| 141 | − organizationId: OrganizationId; | |
| 142 | − accountId: AccountId; | |
| 143 | −}) => | |
| 144 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 145 | − Effect.gen(function* () { | |
| 146 | − const membership = yield* organizationOps.get.child("membership").mapStorage( | |
| 147 | − store.organizationMemberships.getByOrganizationAndAccount( | |
| 148 | − input.organizationId, | |
| 149 | − input.accountId, | |
| 150 | − ), | |
| 151 | − ); | |
| 152 | − | |
| 153 | − if (Option.isNone(membership) || membership.value.status !== "active") { | |
| 154 | − return yield* Effect.fail( | |
| 155 | − organizationOps.get.notFound( | |
| 156 | − "Organization not found", | |
| 157 | − `organizationId=${input.organizationId}`, | |
| 158 | − ), | |
| 159 | − ); | |
| 160 | − } | |
| 161 | − | |
| 162 | − const existing = yield* organizationOps.get.mapStorage( | |
| 163 | − store.organizations.getById(input.organizationId), | |
| 164 | − ); | |
| 165 | − | |
| 166 | − if (Option.isNone(existing)) { | |
| 167 | − return yield* Effect.fail( | |
| 168 | − organizationOps.get.notFound( | |
| 169 | − "Organization not found", | |
| 170 | − `organizationId=${input.organizationId}`, | |
| 171 | − ), | |
| 172 | − ); | |
| 173 | − } | |
| 174 | − | |
| 175 | − return existing.value; | |
| 176 | − })); | |
| 177 | − | |
| 178 | −export const updateOrganization = (input: { | |
| 179 | − organizationId: OrganizationId; | |
| 180 | − payload: UpdateOrganizationPayload; | |
| 181 | −}) => | |
| 182 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 183 | − Effect.gen(function* () { | |
| 184 | − const patch: Partial<Omit<Mutable<Organization>, "id" | "createdAt">> = { | |
| 185 | − updatedAt: Date.now(), | |
| 186 | − }; | |
| 187 | − | |
| 188 | − if (input.payload.name !== undefined) { | |
| 189 | − patch.name = input.payload.name; | |
| 190 | − } | |
| 191 | − if (input.payload.status !== undefined) { | |
| 192 | − patch.status = input.payload.status; | |
| 193 | − } | |
| 194 | − | |
| 195 | − const updated = yield* mapPersistenceError( | |
| 196 | − organizationOps.update, | |
| 197 | − store.organizations.update(input.organizationId, patch), | |
| 198 | − ); | |
| 199 | − | |
| 200 | − if (Option.isNone(updated)) { | |
| 201 | − return yield* Effect.fail( | |
| 202 | − organizationOps.update.notFound( | |
| 203 | − "Organization not found", | |
| 204 | − `organizationId=${input.organizationId}`, | |
| 205 | − ), | |
| 206 | − ); | |
| 207 | − } | |
| 208 | − | |
| 209 | − return updated.value; | |
| 210 | − })); | |
| 211 | − | |
| 212 | −export const removeOrganization = (input: { organizationId: OrganizationId }) => | |
| 213 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 214 | − organizationOps.remove.mapStorage( | |
| 215 | − store.organizations.removeTreeById(input.organizationId), | |
| 216 | − ).pipe(Effect.map((removed) => ({ removed }))) | |
| 217 | − ); | |
| 218 | − | |
| 219 | −export const listMemberships = (organizationId: OrganizationId) => | |
| 220 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 221 | − Effect.gen(function* () { | |
| 222 | − yield* ensureOrganizationExists(store, membershipOps.list, organizationId); | |
| 223 | − | |
| 224 | − return yield* membershipOps.list.mapStorage( | |
| 225 | − store.organizationMemberships.listByOrganizationId(organizationId), | |
| 226 | − ); | |
| 227 | − })); | |
| 228 | − | |
| 229 | −export const createMembership = (input: { | |
| 230 | − organizationId: OrganizationId; | |
| 231 | − payload: CreateMembershipPayload; | |
| 232 | −}) => | |
| 233 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 234 | − Effect.gen(function* () { | |
| 235 | − yield* ensureOrganizationExists(store, membershipOps.create, input.organizationId); | |
| 236 | − | |
| 237 | − const now = Date.now(); | |
| 238 | − const membership: OrganizationMembership = { | |
| 239 | − id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`), | |
| 240 | − organizationId: input.organizationId, | |
| 241 | − accountId: input.payload.accountId, | |
| 242 | − role: input.payload.role, | |
| 243 | − status: input.payload.status ?? "active", | |
| 244 | − billable: input.payload.billable ?? true, | |
| 245 | − invitedByAccountId: input.payload.invitedByAccountId ?? null, | |
| 246 | − joinedAt: (input.payload.status ?? "active") === "active" ? now : null, | |
| 247 | − createdAt: now, | |
| 248 | − updatedAt: now, | |
| 249 | − }; | |
| 250 | − | |
| 251 | − yield* mapPersistenceError( | |
| 252 | − membershipOps.create, | |
| 253 | − store.organizationMemberships.upsert(membership), | |
| 254 | − ); | |
| 255 | − | |
| 256 | − const stored = yield* membershipOps.create.mapStorage( | |
| 257 | − store.organizationMemberships.getByOrganizationAndAccount( | |
| 258 | − input.organizationId, | |
| 259 | − input.payload.accountId, | |
| 260 | − ), | |
| 261 | − ); | |
| 262 | − if (Option.isNone(stored)) { | |
| 263 | − return yield* Effect.fail( | |
| 264 | − membershipOps.create.badRequest( | |
| 265 | − "Membership was not persisted", | |
| 266 | − `organizationId=${input.organizationId} accountId=${input.payload.accountId}`, | |
| 267 | − ), | |
| 268 | − ); | |
| 269 | − } | |
| 270 | − | |
| 271 | − return stored.value; | |
| 272 | − })); | |
| 273 | − | |
| 274 | −export const updateMembership = (input: { | |
| 275 | − organizationId: OrganizationId; | |
| 276 | − accountId: AccountId; | |
| 277 | − payload: UpdateMembershipPayload; | |
| 278 | −}) => | |
| 279 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 280 | − Effect.gen(function* () { | |
| 281 | − yield* ensureOrganizationExists(store, membershipOps.update, input.organizationId); | |
| 282 | − | |
| 283 | − const existing = yield* membershipOps.update.mapStorage( | |
| 284 | − store.organizationMemberships.getByOrganizationAndAccount( | |
| 285 | − input.organizationId, | |
| 286 | − input.accountId, | |
| 287 | − ), | |
| 288 | − ); | |
| 289 | − | |
| 290 | − if (Option.isNone(existing)) { | |
| 291 | − return yield* Effect.fail( | |
| 292 | − membershipOps.update.badRequest( | |
| 293 | − "Membership not found", | |
| 294 | − `organizationId=${input.organizationId} accountId=${input.accountId}`, | |
| 295 | − ), | |
| 296 | − ); | |
| 297 | − } | |
| 298 | − | |
| 299 | − const current = existing.value; | |
| 300 | − const now = Date.now(); | |
| 301 | − const next: OrganizationMembership = { | |
| 302 | − ...current, | |
| 303 | − role: input.payload.role ?? current.role, | |
| 304 | − status: input.payload.status ?? current.status, | |
| 305 | − billable: input.payload.billable ?? current.billable, | |
| 306 | − joinedAt: | |
| 307 | − (input.payload.status ?? current.status) === "active" | |
| 308 | − ? (current.joinedAt ?? now) | |
| 309 | − : current.joinedAt, | |
| 310 | − updatedAt: now, | |
| 311 | − }; | |
| 312 | − | |
| 313 | − yield* mapPersistenceError( | |
| 314 | − membershipOps.update, | |
| 315 | − store.organizationMemberships.upsert(next), | |
| 316 | − ); | |
| 317 | − | |
| 318 | − return next; | |
| 319 | − })); | |
| 320 | − | |
| 321 | −export const removeMembership = (input: { | |
| 322 | − organizationId: OrganizationId; | |
| 323 | − accountId: AccountId; | |
| 324 | −}) => | |
| 325 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 326 | − Effect.gen(function* () { | |
| 327 | − yield* ensureOrganizationExists(store, membershipOps.remove, input.organizationId); | |
| 328 | − | |
| 329 | − const removed = yield* membershipOps.remove.mapStorage( | |
| 330 | − store.organizationMemberships.removeByOrganizationAndAccount( | |
| 331 | − input.organizationId, | |
| 332 | − input.accountId, | |
| 333 | − ), | |
| 334 | − ); | |
| 335 | − | |
| 336 | − return { removed }; | |
| 337 | − })); | |
| 338 | − | |
| 339 | −export const listWorkspaces = (organizationId: OrganizationId) => | |
| 340 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 341 | − Effect.gen(function* () { | |
| 342 | − yield* ensureOrganizationExists(store, workspaceOps.list, organizationId); | |
| 343 | − | |
| 344 | − return yield* workspaceOps.list.mapStorage( | |
| 345 | − store.workspaces.listByOrganizationId(organizationId), | |
| 346 | − ); | |
| 347 | − })); | |
| 348 | − | |
| 349 | −export const createWorkspace = (input: { | |
| 350 | − organizationId: OrganizationId; | |
| 351 | − payload: CreateWorkspacePayload; | |
| 352 | − createdByAccountId?: Workspace["createdByAccountId"]; | |
| 353 | −}) => | |
| 354 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 355 | − Effect.gen(function* () { | |
| 356 | − yield* ensureOrganizationExists(store, workspaceOps.create, input.organizationId); | |
| 357 | − const name = input.payload.name; | |
| 358 | − const now = Date.now(); | |
| 359 | − | |
| 360 | − const workspace: Workspace = { | |
| 361 | − id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`), | |
| 362 | − organizationId: input.organizationId, | |
| 363 | − name, | |
| 364 | − createdByAccountId: input.createdByAccountId ?? null, | |
| 365 | − createdAt: now, | |
| 366 | − updatedAt: now, | |
| 367 | − }; | |
| 368 | − | |
| 369 | − yield* mapPersistenceError( | |
| 370 | − workspaceOps.create, | |
| 371 | − store.workspaces.insert(workspace), | |
| 372 | − ); | |
| 373 | − | |
| 374 | − return workspace; | |
| 375 | − })); | |
| 376 | − | |
| 377 | −export const getWorkspace = (workspaceId: WorkspaceId) => | |
| 378 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 379 | − Effect.gen(function* () { | |
| 380 | − const existing = yield* workspaceOps.get.mapStorage( | |
| 381 | − store.workspaces.getById(workspaceId), | |
| 382 | − ); | |
| 383 | − | |
| 384 | − if (Option.isNone(existing)) { | |
| 385 | − return yield* Effect.fail( | |
| 386 | − workspaceOps.get.notFound( | |
| 387 | − "Workspace not found", | |
| 388 | − `workspaceId=${workspaceId}`, | |
| 389 | − ), | |
| 390 | − ); | |
| 391 | − } | |
| 392 | − | |
| 393 | − return existing.value; | |
| 394 | − })); | |
| 395 | − | |
| 396 | −export const updateWorkspace = (input: { | |
| 397 | − workspaceId: WorkspaceId; | |
| 398 | − payload: UpdateWorkspacePayload; | |
| 399 | −}) => | |
| 400 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 401 | − Effect.gen(function* () { | |
| 402 | − const patch: Partial< | |
| 403 | − Omit<Mutable<Workspace>, "id" | "organizationId" | "createdAt"> | |
| 404 | − > = { | |
| 405 | − updatedAt: Date.now(), | |
| 406 | − }; | |
| 407 | − | |
| 408 | − if (input.payload.name !== undefined) { | |
| 409 | − patch.name = input.payload.name; | |
| 410 | − } | |
| 411 | − | |
| 412 | − const updated = yield* mapPersistenceError( | |
| 413 | − workspaceOps.update, | |
| 414 | − store.workspaces.update(input.workspaceId, patch), | |
| 415 | − ); | |
| 416 | − | |
| 417 | − if (Option.isNone(updated)) { | |
| 418 | − return yield* Effect.fail( | |
| 419 | − workspaceOps.update.notFound( | |
| 420 | − "Workspace not found", | |
| 421 | − `workspaceId=${input.workspaceId}`, | |
| 422 | − ), | |
| 423 | − ); | |
| 424 | − } | |
| 425 | − | |
| 426 | − return updated.value; | |
| 427 | − })); | |
| 428 | − | |
| 429 | −export const removeWorkspace = (input: { workspaceId: WorkspaceId }) => | |
| 430 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 431 | − workspaceOps.remove.mapStorage( | |
| 432 | − store.workspaces.removeById(input.workspaceId), | |
| 433 | − ).pipe(Effect.map((removed) => ({ removed }))) | |
| 434 | − ); | |
| @@ -1,27 +1,37 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | + | |
| 1 | 3 | import type { |
| 2 | 4 | CreatePolicyPayload, |
| 3 | 5 | UpdatePolicyPayload, |
| 4 | 6 | } from "../api/policies/api"; |
| 5 | 7 | import { |
| 6 | 8 | PolicyIdSchema, |
| 7 | − type OrganizationId, | |
| 8 | − type Policy, | |
| 9 | + type LocalExecutorConfig, | |
| 10 | + type LocalWorkspacePolicy, | |
| 9 | 11 | type PolicyId, |
| 10 | 12 | type WorkspaceId, |
| 11 | 13 | } from "#schema"; |
| 12 | 14 | import * as Effect from "effect/Effect"; |
| 13 | −import * as Option from "effect/Option"; | |
| 14 | 15 | |
| 15 | 16 | import { |
| 16 | − mapPersistenceError, | |
| 17 | − parseJsonString, | |
| 18 | − type Mutable, | |
| 19 | −} from "./operations-shared"; | |
| 17 | + loadLocalExecutorConfig, | |
| 18 | + writeProjectLocalExecutorConfig, | |
| 19 | +} from "./local-config"; | |
| 20 | +import { | |
| 21 | + requireRuntimeLocalWorkspace, | |
| 22 | +} from "./local-runtime-context"; | |
| 23 | +import { | |
| 24 | + loadLocalWorkspaceState, | |
| 25 | + type LocalWorkspaceState, | |
| 26 | + writeLocalWorkspaceState, | |
| 27 | +} from "./local-workspace-state"; | |
| 28 | +import { | |
| 29 | + derivePolicyConfigKey, | |
| 30 | +} from "./local-workspace-sync"; | |
| 20 | 31 | import { |
| 21 | 32 | type OperationErrors, |
| 22 | 33 | operationErrors, |
| 23 | 34 | } from "./operation-errors"; |
| 24 | −import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; | |
| 25 | 35 | |
| 26 | 36 | const policyOps = { |
| 27 | 37 | list: operationErrors("policies.list"), |
| @@ -31,336 +41,317 @@ const policyOps = { · 3 unmodified lines | ||
| 31 | 41 | remove: operationErrors("policies.remove"), |
| 32 | 42 | } as const; |
| 33 | 43 | |
| 34 | −type PolicyScopeContext = { | |
| 35 | − scopeType: Policy["scopeType"]; | |
| 36 | − organizationId: Policy["organizationId"]; | |
| 37 | − workspaceId: Policy["workspaceId"]; | |
| 38 | −}; | |
| 44 | +const cloneJson = <T>(value: T): T => | |
| 45 | + JSON.parse(JSON.stringify(value)) as T; | |
| 39 | 46 | |
| 40 | −const loadWorkspacePolicyContext = ( | |
| 41 | − store: ControlPlaneStoreShape, | |
| 42 | − operation: OperationErrors, | |
| 43 | − workspaceId: WorkspaceId, | |
| 44 | −) => | |
| 47 | +const localPolicyIdForKey = (input: { | |
| 48 | + workspaceRoot: string; | |
| 49 | + key: string; | |
| 50 | +}): PolicyId => | |
| 51 | + PolicyIdSchema.make( | |
| 52 | + `pol_local_${createHash("sha256").update(`${input.workspaceRoot}:${input.key}`).digest("hex").slice(0, 16)}`, | |
| 53 | + ); | |
| 54 | + | |
| 55 | +const toLocalWorkspacePolicy = (input: { | |
| 56 | + workspaceId: WorkspaceId; | |
| 57 | + workspaceRoot: string; | |
| 58 | + key: string; | |
| 59 | + policyConfig: NonNullable<LocalExecutorConfig["policies"]>[string]; | |
| 60 | + state: LocalWorkspaceState["policies"][string] | undefined; | |
| 61 | +}): LocalWorkspacePolicy => ({ | |
| 62 | + id: input.state?.id ?? localPolicyIdForKey({ | |
| 63 | + workspaceRoot: input.workspaceRoot, | |
| 64 | + key: input.key, | |
| 65 | + }), | |
| 66 | + key: input.key, | |
| 67 | + workspaceId: input.workspaceId, | |
| 68 | + resourcePattern: input.policyConfig.match.trim(), | |
| 69 | + effect: input.policyConfig.action, | |
| 70 | + approvalMode: input.policyConfig.approval === "manual" ? "required" : "auto", | |
| 71 | + priority: input.policyConfig.priority ?? 0, | |
| 72 | + enabled: input.policyConfig.enabled ?? true, | |
| 73 | + createdAt: input.state?.createdAt ?? Date.now(), | |
| 74 | + updatedAt: input.state?.updatedAt ?? Date.now(), | |
| 75 | +}); | |
| 76 | + | |
| 77 | +export const loadRuntimeLocalWorkspacePolicies = (workspaceId: WorkspaceId) => | |
| 45 | 78 | Effect.gen(function* () { |
| 46 | − const workspace = yield* operation.child("workspace").mapStorage( | |
| 47 | − store.workspaces.getById(workspaceId), | |
| 48 | − ); | |
| 49 | − if (Option.isNone(workspace)) { | |
| 50 | − return yield* Effect.fail( | |
| 51 | − operation.notFound( | |
| 52 | − "Workspace not found", | |
| 53 | − `workspaceId=${workspaceId}`, | |
| 54 | − ), | |
| 55 | − ); | |
| 56 | − } | |
| 79 | + const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId); | |
| 80 | + const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context); | |
| 81 | + const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); | |
| 82 | + | |
| 83 | + const policies = Object.entries(loadedConfig.config?.policies ?? {}).map(([key, policyConfig]) => | |
| 84 | + toLocalWorkspacePolicy({ | |
| 85 | + workspaceId, | |
| 86 | + workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | |
| 87 | + key, | |
| 88 | + policyConfig, | |
| 89 | + state: workspaceState.policies[key], | |
| 90 | + })); | |
| 57 | 91 | |
| 58 | 92 | return { |
| 59 | − scopeType: "workspace", | |
| 60 | − organizationId: workspace.value.organizationId, | |
| 61 | − workspaceId, | |
| 62 | − } satisfies PolicyScopeContext; | |
| 93 | + runtimeLocalWorkspace, | |
| 94 | + loadedConfig, | |
| 95 | + workspaceState, | |
| 96 | + policies, | |
| 97 | + }; | |
| 63 | 98 | }); |
| 64 | 99 | |
| 65 | −const loadOrganizationPolicyContext = ( | |
| 66 | − store: ControlPlaneStoreShape, | |
| 100 | +const writeLocalPolicyFiles = (input: { | |
| 101 | + operation: OperationErrors; | |
| 102 | + context: Parameters<typeof writeProjectLocalExecutorConfig>[0]["context"]; | |
| 103 | + projectConfig: LocalExecutorConfig; | |
| 104 | + workspaceState: LocalWorkspaceState; | |
| 105 | +}) => | |
| 106 | + Effect.all([ | |
| 107 | + writeProjectLocalExecutorConfig({ | |
| 108 | + context: input.context, | |
| 109 | + config: input.projectConfig, | |
| 110 | + }), | |
| 111 | + writeLocalWorkspaceState({ | |
| 112 | + context: input.context, | |
| 113 | + state: input.workspaceState, | |
| 114 | + }), | |
| 115 | + ], { discard: true }).pipe( | |
| 116 | + Effect.mapError((cause) => | |
| 117 | + input.operation.unknownStorage( | |
| 118 | + cause, | |
| 119 | + "Failed writing local workspace policy files", | |
| 120 | + ), | |
| 121 | + ), | |
| 122 | + ); | |
| 123 | + | |
| 124 | +const loadWorkspacePolicyContext = ( | |
| 67 | 125 | operation: OperationErrors, |
| 68 | − organizationId: OrganizationId, | |
| 126 | + workspaceId: WorkspaceId, | |
| 69 | 127 | ) => |
| 128 | + requireRuntimeLocalWorkspace(workspaceId).pipe( | |
| 129 | + Effect.mapError((cause) => | |
| 130 | + operation.notFound( | |
| 131 | + "Workspace not found", | |
| 132 | + cause instanceof Error ? cause.message : String(cause), | |
| 133 | + ), | |
| 134 | + ), | |
| 135 | + ); | |
| 136 | + | |
| 137 | +export const listPolicies = (workspaceId: WorkspaceId) => | |
| 70 | 138 | Effect.gen(function* () { |
| 71 | − const organization = yield* operation.child("organization").mapStorage( | |
| 72 | − store.organizations.getById(organizationId), | |
| 73 | − ); | |
| 74 | − if (Option.isNone(organization)) { | |
| 75 | − return yield* Effect.fail( | |
| 76 | − operation.notFound( | |
| 77 | − "Organization not found", | |
| 78 | − `organizationId=${organizationId}`, | |
| 139 | + yield* loadWorkspacePolicyContext(policyOps.list, workspaceId); | |
| 140 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(workspaceId).pipe( | |
| 141 | + Effect.mapError((cause) => | |
| 142 | + policyOps.list.unknownStorage( | |
| 143 | + cause, | |
| 144 | + "Failed loading local workspace policies", | |
| 79 | 145 | ), |
| 80 | − ); | |
| 81 | − } | |
| 82 | − | |
| 83 | − return { | |
| 84 | − scopeType: "organization", | |
| 85 | − organizationId, | |
| 86 | − workspaceId: null, | |
| 87 | − } satisfies PolicyScopeContext; | |
| 146 | + ), | |
| 147 | + ); | |
| 148 | + return localWorkspace.policies; | |
| 88 | 149 | }); |
| 89 | 150 | |
| 90 | −const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean => | |
| 91 | − policy.scopeType === scope.scopeType | |
| 92 | − && policy.organizationId === scope.organizationId | |
| 93 | − && policy.workspaceId === scope.workspaceId; | |
| 94 | − | |
| 95 | −const createScopedPolicy = (input: { | |
| 96 | − scope: PolicyScopeContext; | |
| 151 | +export const createPolicy = (input: { | |
| 152 | + workspaceId: WorkspaceId; | |
| 97 | 153 | payload: CreatePolicyPayload; |
| 98 | 154 | }) => |
| 99 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 100 | − Effect.gen(function* () { | |
| 101 | − const now = Date.now(); | |
| 102 | − const operation = policyOps.create; | |
| 103 | − | |
| 104 | − const policy: Policy = { | |
| 105 | − id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`), | |
| 106 | − scopeType: input.scope.scopeType, | |
| 107 | − organizationId: input.scope.organizationId, | |
| 108 | − workspaceId: input.scope.workspaceId, | |
| 109 | − targetAccountId: input.payload.targetAccountId ?? null, | |
| 110 | − clientId: input.payload.clientId ?? null, | |
| 111 | − resourceType: input.payload.resourceType ?? "tool_path", | |
| 155 | + Effect.gen(function* () { | |
| 156 | + const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.create, input.workspaceId); | |
| 157 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( | |
| 158 | + Effect.mapError((cause) => | |
| 159 | + policyOps.create.unknownStorage( | |
| 160 | + cause, | |
| 161 | + "Failed loading local workspace policies", | |
| 162 | + ), | |
| 163 | + ), | |
| 164 | + ); | |
| 165 | + const now = Date.now(); | |
| 166 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 167 | + const policies = { ...(projectConfig.policies ?? {}) }; | |
| 168 | + const key = derivePolicyConfigKey( | |
| 169 | + { | |
| 112 | 170 | resourcePattern: input.payload.resourcePattern ?? "*", |
| 113 | − matchType: input.payload.matchType ?? "glob", | |
| 114 | 171 | effect: input.payload.effect ?? "allow", |
| 115 | 172 | approvalMode: input.payload.approvalMode ?? "auto", |
| 116 | − argumentConditionsJson: input.payload.argumentConditionsJson ?? null, | |
| 117 | − priority: input.payload.priority ?? 0, | |
| 118 | − enabled: input.payload.enabled ?? true, | |
| 119 | − createdAt: now, | |
| 120 | − updatedAt: now, | |
| 121 | − }; | |
| 122 | − | |
| 123 | − if (policy.argumentConditionsJson !== null) { | |
| 124 | − yield* parseJsonString( | |
| 125 | − operation, | |
| 126 | − "argumentConditionsJson", | |
| 127 | − policy.argumentConditionsJson, | |
| 128 | − ); | |
| 129 | − } | |
| 130 | − | |
| 131 | − yield* mapPersistenceError( | |
| 132 | − operation, | |
| 133 | − store.policies.insert(policy), | |
| 134 | − ); | |
| 135 | − | |
| 136 | − return policy; | |
| 137 | − })); | |
| 138 | − | |
| 139 | −const getScopedPolicy = (input: { | |
| 140 | − scope: PolicyScopeContext; | |
| 141 | − policyId: PolicyId; | |
| 142 | −}) => | |
| 143 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 144 | − Effect.gen(function* () { | |
| 145 | − const existing = yield* policyOps.get.mapStorage( | |
| 146 | − store.policies.getById(input.policyId), | |
| 147 | − ); | |
| 148 | − | |
| 149 | − if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) { | |
| 150 | − return yield* Effect.fail( | |
| 151 | − policyOps.get.notFound( | |
| 152 | − "Policy not found", | |
| 153 | − `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`, | |
| 154 | − ), | |
| 155 | − ); | |
| 156 | − } | |
| 157 | − | |
| 158 | − return existing.value; | |
| 159 | − })); | |
| 160 | − | |
| 161 | −const updateScopedPolicy = (input: { | |
| 162 | − scope: PolicyScopeContext; | |
| 163 | − policyId: PolicyId; | |
| 164 | − payload: UpdatePolicyPayload; | |
| 165 | −}) => | |
| 166 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 167 | − Effect.gen(function* () { | |
| 168 | − const existing = yield* policyOps.update.mapStorage( | |
| 169 | − store.policies.getById(input.policyId), | |
| 170 | − ); | |
| 171 | − if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) { | |
| 172 | − return yield* Effect.fail( | |
| 173 | − policyOps.update.notFound( | |
| 174 | − "Policy not found", | |
| 175 | − `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`, | |
| 176 | − ), | |
| 177 | − ); | |
| 178 | − } | |
| 179 | − | |
| 180 | − const patch: Partial< | |
| 181 | − Omit<Mutable<Policy>, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt"> | |
| 182 | − > = { | |
| 183 | − updatedAt: Date.now(), | |
| 184 | − }; | |
| 185 | − | |
| 186 | − if (input.payload.targetAccountId !== undefined) { | |
| 187 | − patch.targetAccountId = input.payload.targetAccountId; | |
| 188 | − } | |
| 189 | − if (input.payload.clientId !== undefined) { | |
| 190 | − patch.clientId = input.payload.clientId; | |
| 191 | − } | |
| 192 | − if (input.payload.resourceType !== undefined) { | |
| 193 | − patch.resourceType = input.payload.resourceType; | |
| 194 | − } | |
| 195 | − if (input.payload.resourcePattern !== undefined) { | |
| 196 | − patch.resourcePattern = input.payload.resourcePattern; | |
| 197 | − } | |
| 198 | − if (input.payload.matchType !== undefined) { | |
| 199 | − patch.matchType = input.payload.matchType; | |
| 200 | − } | |
| 201 | − if (input.payload.effect !== undefined) { | |
| 202 | − patch.effect = input.payload.effect; | |
| 203 | − } | |
| 204 | − if (input.payload.approvalMode !== undefined) { | |
| 205 | − patch.approvalMode = input.payload.approvalMode; | |
| 206 | − } | |
| 207 | − if (input.payload.argumentConditionsJson !== undefined) { | |
| 208 | − if (input.payload.argumentConditionsJson !== null) { | |
| 209 | − yield* parseJsonString( | |
| 210 | − policyOps.update, | |
| 211 | − "argumentConditionsJson", | |
| 212 | − input.payload.argumentConditionsJson, | |
| 213 | − ); | |
| 214 | − } | |
| 215 | − patch.argumentConditionsJson = input.payload.argumentConditionsJson; | |
| 216 | − } | |
| 217 | − if (input.payload.priority !== undefined) { | |
| 218 | − patch.priority = input.payload.priority; | |
| 219 | − } | |
| 220 | − if (input.payload.enabled !== undefined) { | |
| 221 | − patch.enabled = input.payload.enabled; | |
| 222 | − } | |
| 223 | − | |
| 224 | − const updated = yield* mapPersistenceError( | |
| 225 | − policyOps.update, | |
| 226 | − store.policies.update(input.policyId, patch), | |
| 227 | − ); | |
| 228 | − if (Option.isNone(updated)) { | |
| 229 | − return yield* Effect.fail( | |
| 230 | − policyOps.update.notFound( | |
| 231 | − "Policy not found", | |
| 232 | − `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`, | |
| 233 | − ), | |
| 234 | − ); | |
| 235 | − } | |
| 236 | − | |
| 237 | − return updated.value; | |
| 238 | − })); | |
| 239 | − | |
| 240 | −const removeScopedPolicy = (input: { | |
| 241 | − scope: PolicyScopeContext; | |
| 242 | − policyId: PolicyId; | |
| 243 | −}) => | |
| 244 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 245 | − Effect.gen(function* () { | |
| 246 | − const existing = yield* policyOps.remove.mapStorage( | |
| 247 | − store.policies.getById(input.policyId), | |
| 248 | − ); | |
| 249 | − if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) { | |
| 250 | − return { removed: false }; | |
| 251 | − } | |
| 252 | − | |
| 253 | − const removed = yield* policyOps.remove.mapStorage( | |
| 254 | − store.policies.removeById(input.policyId), | |
| 255 | − ); | |
| 256 | − | |
| 257 | − return { removed }; | |
| 258 | − })); | |
| 259 | − | |
| 260 | −export const listOrganizationPolicies = (organizationId: OrganizationId) => | |
| 261 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 262 | − Effect.gen(function* () { | |
| 263 | − yield* loadOrganizationPolicyContext(store, policyOps.list, organizationId); | |
| 264 | − return yield* policyOps.list.mapStorage( | |
| 265 | − store.policies.listByOrganizationId(organizationId), | |
| 266 | − ); | |
| 267 | − })); | |
| 268 | − | |
| 269 | −export const listPolicies = (workspaceId: WorkspaceId) => | |
| 270 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 271 | − Effect.gen(function* () { | |
| 272 | − yield* loadWorkspacePolicyContext(store, policyOps.list, workspaceId); | |
| 273 | − return yield* policyOps.list.mapStorage( | |
| 274 | − store.policies.listByWorkspaceId(workspaceId), | |
| 275 | − ); | |
| 276 | − })); | |
| 277 | − | |
| 278 | −export const createOrganizationPolicy = (input: { | |
| 279 | − organizationId: OrganizationId; | |
| 280 | − payload: CreatePolicyPayload; | |
| 281 | −}) => | |
| 282 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 283 | − Effect.flatMap( | |
| 284 | − loadOrganizationPolicyContext(store, policyOps.create, input.organizationId), | |
| 285 | − (scope) => createScopedPolicy({ scope, payload: input.payload }), | |
| 286 | − )); | |
| 287 | − | |
| 288 | −export const createPolicy = (input: { | |
| 289 | − workspaceId: WorkspaceId; | |
| 290 | − payload: CreatePolicyPayload; | |
| 291 | −}) => | |
| 292 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 293 | − Effect.flatMap( | |
| 294 | − loadWorkspacePolicyContext(store, policyOps.create, input.workspaceId), | |
| 295 | − (scope) => createScopedPolicy({ scope, payload: input.payload }), | |
| 296 | − )); | |
| 173 | + }, | |
| 174 | + new Set(Object.keys(policies)), | |
| 175 | + ); | |
| 297 | 176 | |
| 298 | −export const getOrganizationPolicy = (input: { | |
| 299 | − organizationId: OrganizationId; | |
| 300 | − policyId: PolicyId; | |
| 301 | −}) => | |
| 302 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 303 | − Effect.flatMap( | |
| 304 | − loadOrganizationPolicyContext(store, policyOps.get, input.organizationId), | |
| 305 | − (scope) => getScopedPolicy({ scope, policyId: input.policyId }), | |
| 306 | − )); | |
| 177 | + policies[key] = { | |
| 178 | + match: input.payload.resourcePattern ?? "*", | |
| 179 | + action: input.payload.effect ?? "allow", | |
| 180 | + approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto", | |
| 181 | + ...(input.payload.enabled === false ? { enabled: false } : {}), | |
| 182 | + ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}), | |
| 183 | + }; | |
| 184 | + | |
| 185 | + const existingState = localWorkspace.workspaceState.policies[key]; | |
| 186 | + const workspaceState: LocalWorkspaceState = { | |
| 187 | + ...localWorkspace.workspaceState, | |
| 188 | + policies: { | |
| 189 | + ...localWorkspace.workspaceState.policies, | |
| 190 | + [key]: { | |
| 191 | + id: existingState?.id ?? localPolicyIdForKey({ | |
| 192 | + workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | |
| 193 | + key, | |
| 194 | + }), | |
| 195 | + createdAt: existingState?.createdAt ?? now, | |
| 196 | + updatedAt: now, | |
| 197 | + }, | |
| 198 | + }, | |
| 199 | + }; | |
| 200 | + | |
| 201 | + yield* writeLocalPolicyFiles({ | |
| 202 | + operation: policyOps.create, | |
| 203 | + context: runtimeLocalWorkspace.context, | |
| 204 | + projectConfig: { | |
| 205 | + ...projectConfig, | |
| 206 | + policies, | |
| 207 | + }, | |
| 208 | + workspaceState, | |
| 209 | + }); | |
| 210 | + | |
| 211 | + return toLocalWorkspacePolicy({ | |
| 212 | + workspaceId: input.workspaceId, | |
| 213 | + workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | |
| 214 | + key, | |
| 215 | + policyConfig: policies[key]!, | |
| 216 | + state: workspaceState.policies[key], | |
| 217 | + }); | |
| 218 | + }); | |
| 307 | 219 | |
| 308 | 220 | export const getPolicy = (input: { |
| 309 | 221 | workspaceId: WorkspaceId; |
| 310 | 222 | policyId: PolicyId; |
| 311 | 223 | }) => |
| 312 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 313 | − Effect.flatMap( | |
| 314 | − loadWorkspacePolicyContext(store, policyOps.get, input.workspaceId), | |
| 315 | − (scope) => getScopedPolicy({ scope, policyId: input.policyId }), | |
| 316 | − )); | |
| 317 | − | |
| 318 | −export const updateOrganizationPolicy = (input: { | |
| 319 | − organizationId: OrganizationId; | |
| 320 | − policyId: PolicyId; | |
| 321 | − payload: UpdatePolicyPayload; | |
| 322 | −}) => | |
| 323 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 324 | − Effect.flatMap( | |
| 325 | − loadOrganizationPolicyContext(store, policyOps.update, input.organizationId), | |
| 326 | − (scope) => updateScopedPolicy({ | |
| 327 | − scope, | |
| 328 | − policyId: input.policyId, | |
| 329 | − payload: input.payload, | |
| 330 | − }), | |
| 331 | − )); | |
| 224 | + Effect.gen(function* () { | |
| 225 | + yield* loadWorkspacePolicyContext(policyOps.get, input.workspaceId); | |
| 226 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( | |
| 227 | + Effect.mapError((cause) => | |
| 228 | + policyOps.get.unknownStorage( | |
| 229 | + cause, | |
| 230 | + "Failed loading local workspace policies", | |
| 231 | + ), | |
| 232 | + ), | |
| 233 | + ); | |
| 234 | + const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; | |
| 235 | + if (policy === null) { | |
| 236 | + return yield* Effect.fail( | |
| 237 | + policyOps.get.notFound( | |
| 238 | + "Policy not found", | |
| 239 | + `workspaceId=${input.workspaceId} policyId=${input.policyId}`, | |
| 240 | + ), | |
| 241 | + ); | |
| 242 | + } | |
| 243 | + return policy; | |
| 244 | + }); | |
| 332 | 245 | |
| 333 | 246 | export const updatePolicy = (input: { |
| 334 | 247 | workspaceId: WorkspaceId; |
| 335 | 248 | policyId: PolicyId; |
| 336 | 249 | payload: UpdatePolicyPayload; |
| 337 | 250 | }) => |
| 338 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 339 | − Effect.flatMap( | |
| 340 | − loadWorkspacePolicyContext(store, policyOps.update, input.workspaceId), | |
| 341 | − (scope) => updateScopedPolicy({ | |
| 342 | − scope, | |
| 343 | − policyId: input.policyId, | |
| 344 | − payload: input.payload, | |
| 345 | − }), | |
| 346 | − )); | |
| 251 | + Effect.gen(function* () { | |
| 252 | + const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.update, input.workspaceId); | |
| 253 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( | |
| 254 | + Effect.mapError((cause) => | |
| 255 | + policyOps.update.unknownStorage( | |
| 256 | + cause, | |
| 257 | + "Failed loading local workspace policies", | |
| 258 | + ), | |
| 259 | + ), | |
| 260 | + ); | |
| 261 | + const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; | |
| 262 | + if (existing === null) { | |
| 263 | + return yield* Effect.fail( | |
| 264 | + policyOps.update.notFound( | |
| 265 | + "Policy not found", | |
| 266 | + `workspaceId=${input.workspaceId} policyId=${input.policyId}`, | |
| 267 | + ), | |
| 268 | + ); | |
| 269 | + } | |
| 347 | 270 | |
| 348 | −export const removeOrganizationPolicy = (input: { | |
| 349 | − organizationId: OrganizationId; | |
| 350 | − policyId: PolicyId; | |
| 351 | −}) => | |
| 352 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 353 | − Effect.flatMap( | |
| 354 | − loadOrganizationPolicyContext(store, policyOps.remove, input.organizationId), | |
| 355 | − (scope) => removeScopedPolicy({ scope, policyId: input.policyId }), | |
| 356 | − )); | |
| 271 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 272 | + const policies = { ...(projectConfig.policies ?? {}) }; | |
| 273 | + const existingConfig = policies[existing.key]!; | |
| 274 | + | |
| 275 | + policies[existing.key] = { | |
| 276 | + ...existingConfig, | |
| 277 | + ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}), | |
| 278 | + ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}), | |
| 279 | + ...(input.payload.approvalMode !== undefined | |
| 280 | + ? { approval: input.payload.approvalMode === "required" ? "manual" : "auto" } | |
| 281 | + : {}), | |
| 282 | + ...(input.payload.enabled !== undefined ? { enabled: input.payload.enabled } : {}), | |
| 283 | + ...(input.payload.priority !== undefined ? { priority: input.payload.priority } : {}), | |
| 284 | + }; | |
| 285 | + | |
| 286 | + const updatedAt = Date.now(); | |
| 287 | + const existingState = localWorkspace.workspaceState.policies[existing.key]; | |
| 288 | + const workspaceState: LocalWorkspaceState = { | |
| 289 | + ...localWorkspace.workspaceState, | |
| 290 | + policies: { | |
| 291 | + ...localWorkspace.workspaceState.policies, | |
| 292 | + [existing.key]: { | |
| 293 | + id: existing.id, | |
| 294 | + createdAt: existingState?.createdAt ?? existing.createdAt, | |
| 295 | + updatedAt, | |
| 296 | + }, | |
| 297 | + }, | |
| 298 | + }; | |
| 299 | + | |
| 300 | + yield* writeLocalPolicyFiles({ | |
| 301 | + operation: policyOps.update, | |
| 302 | + context: runtimeLocalWorkspace.context, | |
| 303 | + projectConfig: { | |
| 304 | + ...projectConfig, | |
| 305 | + policies, | |
| 306 | + }, | |
| 307 | + workspaceState, | |
| 308 | + }); | |
| 309 | + | |
| 310 | + return toLocalWorkspacePolicy({ | |
| 311 | + workspaceId: input.workspaceId, | |
| 312 | + workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | |
| 313 | + key: existing.key, | |
| 314 | + policyConfig: policies[existing.key]!, | |
| 315 | + state: workspaceState.policies[existing.key], | |
| 316 | + }); | |
| 317 | + }); | |
| 357 | 318 | |
| 358 | 319 | export const removePolicy = (input: { |
| 359 | 320 | workspaceId: WorkspaceId; |
| 360 | 321 | policyId: PolicyId; |
| 361 | 322 | }) => |
| 362 | − Effect.flatMap(ControlPlaneStore, (store) => | |
| 363 | − Effect.flatMap( | |
| 364 | − loadWorkspacePolicyContext(store, policyOps.remove, input.workspaceId), | |
| 365 | − (scope) => removeScopedPolicy({ scope, policyId: input.policyId }), | |
| 366 | − )); | |
| 323 | + Effect.gen(function* () { | |
| 324 | + const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.remove, input.workspaceId); | |
| 325 | + const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( | |
| 326 | + Effect.mapError((cause) => | |
| 327 | + policyOps.remove.unknownStorage( | |
| 328 | + cause, | |
| 329 | + "Failed loading local workspace policies", | |
| 330 | + ), | |
| 331 | + ), | |
| 332 | + ); | |
| 333 | + const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; | |
| 334 | + if (existing === null) { | |
| 335 | + return { removed: false }; | |
| 336 | + } | |
| 337 | + | |
| 338 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 339 | + const policies = { ...(projectConfig.policies ?? {}) }; | |
| 340 | + delete policies[existing.key]; | |
| 341 | + | |
| 342 | + const { [existing.key]: _removedPolicy, ...remainingPolicies } = localWorkspace.workspaceState.policies; | |
| 343 | + yield* writeLocalPolicyFiles({ | |
| 344 | + operation: policyOps.remove, | |
| 345 | + context: runtimeLocalWorkspace.context, | |
| 346 | + projectConfig: { | |
| 347 | + ...projectConfig, | |
| 348 | + policies, | |
| 349 | + }, | |
| 350 | + workspaceState: { | |
| 351 | + ...localWorkspace.workspaceState, | |
| 352 | + policies: remainingPolicies, | |
| 353 | + }, | |
| 354 | + }); | |
| 355 | + | |
| 356 | + return { removed: true }; | |
| 357 | + }); | |
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { spawn } from "node:child_process"; |
| 3 | +import { promises as fs } from "node:fs"; | |
| 4 | +import { lstatSync, realpathSync } from "node:fs"; | |
| 5 | +import { isAbsolute } from "node:path"; | |
| 3 | 6 | |
| 4 | 7 | import type { SqlControlPlaneRows } from "#persistence"; |
| 5 | 8 | import { |
| 9 | + type LocalConfigSecretProvider, | |
| 10 | + type LocalExecutorConfig, | |
| 6 | 11 | type SecretMaterialPurpose, |
| 7 | 12 | SecretMaterialIdSchema, |
| 8 | 13 | type SecretRef, |
| @@ -10,6 +15,9 @@ import { · 1 unmodified line | ||
| 10 | 15 | import * as Effect from "effect/Effect"; |
| 11 | 16 | import * as Option from "effect/Option"; |
| 12 | 17 | |
| 18 | +import { resolveConfigRelativePath } from "./local-config"; | |
| 19 | +import { fromConfigSecretProviderId } from "./local-config-secrets"; | |
| 20 | + | |
| 13 | 21 | export const ENV_SECRET_PROVIDER_ID = "env"; |
| 14 | 22 | export const PARAMS_SECRET_PROVIDER_ID = "params"; |
| 15 | 23 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; |
| @@ -43,6 +51,8 @@ type SecretMaterialProviderRuntime = { · 27 unmodified lines | ||
| 43 | 51 | env: NodeJS.ProcessEnv; |
| 44 | 52 | dangerouslyAllowEnvSecrets: boolean; |
| 45 | 53 | keychainServiceName: string; |
| 54 | + localConfig: LocalExecutorConfig | null; | |
| 55 | + workspaceRoot: string | null; | |
| 46 | 56 | }; |
| 47 | 57 | |
| 48 | 58 | type SecretMaterialProvider = { |
| @@ -125,6 +135,7 @@ const runCommand = (input: { · 76 unmodified lines | ||
| 125 | 135 | command: string; |
| 126 | 136 | args: ReadonlyArray<string>; |
| 127 | 137 | stdin?: string; |
| 138 | + env?: NodeJS.ProcessEnv; | |
| 128 | 139 | operation: string; |
| 129 | 140 | }): Effect.Effect<SpawnResult, Error, never> => |
| 130 | 141 | Effect.tryPromise({ |
| @@ -132,6 +143,7 @@ const runCommand = (input: { · 1 unmodified line | ||
| 132 | 143 | new Promise<SpawnResult>((resolve, reject) => { |
| 133 | 144 | const child = spawn(input.command, [...input.args], { |
| 134 | 145 | stdio: "pipe", |
| 146 | + env: input.env, | |
| 135 | 147 | }); |
| 136 | 148 | |
| 137 | 149 | let stdout = ""; |
| @@ -474,17 +486,169 @@ const createSecretMaterialProviderRuntime = (input: { · 336 unmodified lines | ||
| 474 | 486 | rows: SqlControlPlaneRows; |
| 475 | 487 | dangerouslyAllowEnvSecrets?: boolean; |
| 476 | 488 | keychainServiceName?: string; |
| 489 | + localConfig?: LocalExecutorConfig | null; | |
| 490 | + workspaceRoot?: string | null; | |
| 477 | 491 | }): SecretMaterialProviderRuntime => ({ |
| 478 | 492 | rows: input.rows, |
| 479 | 493 | env: process.env, |
| 480 | 494 | dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets), |
| 481 | 495 | keychainServiceName: resolveKeychainServiceName(input.keychainServiceName), |
| 496 | + localConfig: input.localConfig ?? null, | |
| 497 | + workspaceRoot: input.workspaceRoot ?? null, | |
| 482 | 498 | }); |
| 483 | 499 | |
| 500 | +const isRegularFilePath = (path: string, allowSymlink: boolean): boolean => { | |
| 501 | + const stat = lstatSync(path); | |
| 502 | + if (stat.isSymbolicLink()) { | |
| 503 | + if (!allowSymlink) { | |
| 504 | + return false; | |
| 505 | + } | |
| 506 | + const targetStat = lstatSync(realpathSync(path)); | |
| 507 | + return targetStat.isFile(); | |
| 508 | + } | |
| 509 | + return stat.isFile(); | |
| 510 | +}; | |
| 511 | + | |
| 512 | +const ensureTrustedDir = (path: string, trustedDirs: readonly string[] | undefined): boolean => { | |
| 513 | + if (!trustedDirs || trustedDirs.length === 0) { | |
| 514 | + return true; | |
| 515 | + } | |
| 516 | + | |
| 517 | + const real = realpathSync(path); | |
| 518 | + return trustedDirs.some((dir) => { | |
| 519 | + const trusted = realpathSync(dir); | |
| 520 | + return real === trusted || real.startsWith(`${trusted}/`); | |
| 521 | + }); | |
| 522 | +}; | |
| 523 | + | |
| 524 | +const readFileSecretValue = (input: { | |
| 525 | + provider: Extract<LocalConfigSecretProvider, { source: "file" }>; | |
| 526 | + ref: SecretRef; | |
| 527 | + workspaceRoot: string; | |
| 528 | +}): Effect.Effect<string, Error, never> => | |
| 529 | + Effect.tryPromise({ | |
| 530 | + try: async () => { | |
| 531 | + const resolvedPath = resolveConfigRelativePath({ | |
| 532 | + path: input.provider.path, | |
| 533 | + workspaceRoot: input.workspaceRoot, | |
| 534 | + }); | |
| 535 | + const raw = await fs.readFile(resolvedPath, "utf8"); | |
| 536 | + const mode = input.provider.mode ?? "singleValue"; | |
| 537 | + if (mode === "singleValue") { | |
| 538 | + return raw.trim(); | |
| 539 | + } | |
| 540 | + | |
| 541 | + const parsed = JSON.parse(raw) as unknown; | |
| 542 | + if (input.ref.handle.startsWith("/")) { | |
| 543 | + const segments = input.ref.handle | |
| 544 | + .split("/") | |
| 545 | + .slice(1) | |
| 546 | + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); | |
| 547 | + let current: unknown = parsed; | |
| 548 | + for (const segment of segments) { | |
| 549 | + if (typeof current !== "object" || current === null || !(segment in current)) { | |
| 550 | + throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`); | |
| 551 | + } | |
| 552 | + current = (current as Record<string, unknown>)[segment]; | |
| 553 | + } | |
| 554 | + if (typeof current !== "string" || current.trim().length === 0) { | |
| 555 | + throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`); | |
| 556 | + } | |
| 557 | + return current; | |
| 558 | + } | |
| 559 | + | |
| 560 | + if (typeof parsed !== "object" || parsed === null) { | |
| 561 | + throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`); | |
| 562 | + } | |
| 563 | + const value = (parsed as Record<string, unknown>)[input.ref.handle]; | |
| 564 | + if (typeof value !== "string" || value.trim().length === 0) { | |
| 565 | + throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`); | |
| 566 | + } | |
| 567 | + return value; | |
| 568 | + }, | |
| 569 | + catch: (cause) => | |
| 570 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 571 | + }); | |
| 572 | + | |
| 573 | +const resolveConfiguredSecretProvider = (input: { | |
| 574 | + ref: SecretRef; | |
| 575 | + runtime: SecretMaterialProviderRuntime; | |
| 576 | +}): Effect.Effect<string, Error, never> => { | |
| 577 | + const providerAlias = fromConfigSecretProviderId(input.ref.providerId); | |
| 578 | + if (providerAlias === null) { | |
| 579 | + return Effect.fail(new Error(`Unsupported secret provider: ${input.ref.providerId}`)); | |
| 580 | + } | |
| 581 | + | |
| 582 | + const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias]; | |
| 583 | + if (!provider) { | |
| 584 | + return Effect.fail( | |
| 585 | + new Error(`Config secret provider "${providerAlias}" is not configured`), | |
| 586 | + ); | |
| 587 | + } | |
| 588 | + if (input.runtime.workspaceRoot === null) { | |
| 589 | + return Effect.fail( | |
| 590 | + new Error(`Config secret provider "${providerAlias}" requires a workspace root`), | |
| 591 | + ); | |
| 592 | + } | |
| 593 | + | |
| 594 | + if (provider.source === "env") { | |
| 595 | + const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]); | |
| 596 | + return value === null | |
| 597 | + ? Effect.fail(new Error(`Environment variable ${input.ref.handle} is not set`)) | |
| 598 | + : Effect.succeed(value); | |
| 599 | + } | |
| 600 | + | |
| 601 | + if (provider.source === "file") { | |
| 602 | + return readFileSecretValue({ | |
| 603 | + provider, | |
| 604 | + ref: input.ref, | |
| 605 | + workspaceRoot: input.runtime.workspaceRoot, | |
| 606 | + }); | |
| 607 | + } | |
| 608 | + | |
| 609 | + const command = provider.command.trim(); | |
| 610 | + if (!isAbsolute(command)) { | |
| 611 | + return Effect.fail( | |
| 612 | + new Error(`Exec secret provider command must be absolute: ${command}`), | |
| 613 | + ); | |
| 614 | + } | |
| 615 | + if (!isRegularFilePath(command, provider.allowSymlinkCommand ?? false)) { | |
| 616 | + return Effect.fail( | |
| 617 | + new Error(`Exec secret provider command is not an allowed regular file: ${command}`), | |
| 618 | + ); | |
| 619 | + } | |
| 620 | + if (!ensureTrustedDir(command, provider.trustedDirs)) { | |
| 621 | + return Effect.fail( | |
| 622 | + new Error(`Exec secret provider command is outside trustedDirs: ${command}`), | |
| 623 | + ); | |
| 624 | + } | |
| 625 | + | |
| 626 | + return runCommand({ | |
| 627 | + command, | |
| 628 | + args: [...(provider.args ?? []), input.ref.handle], | |
| 629 | + env: { | |
| 630 | + ...input.runtime.env, | |
| 631 | + ...(provider.env ?? {}), | |
| 632 | + }, | |
| 633 | + operation: `config-secret.get:${providerAlias}`, | |
| 634 | + }).pipe( | |
| 635 | + Effect.flatMap((result) => | |
| 636 | + ensureCommandSuccess({ | |
| 637 | + result, | |
| 638 | + operation: `config-secret.get:${providerAlias}`, | |
| 639 | + message: "Failed resolving configured exec secret", | |
| 640 | + }), | |
| 641 | + ), | |
| 642 | + Effect.map((result) => result.stdout.trimEnd()), | |
| 643 | + ); | |
| 644 | +}; | |
| 645 | + | |
| 484 | 646 | export const createDefaultSecretMaterialResolver = (input: { |
| 485 | 647 | rows: SqlControlPlaneRows; |
| 486 | 648 | dangerouslyAllowEnvSecrets?: boolean; |
| 487 | 649 | keychainServiceName?: string; |
| 650 | + localConfig?: LocalExecutorConfig | null; | |
| 651 | + workspaceRoot?: string | null; | |
| 488 | 652 | }): ResolveSecretMaterial => { |
| 489 | 653 | const providers = createSecretMaterialProviderRegistry(); |
| 490 | 654 | const runtime = createSecretMaterialProviderRuntime(input); |
| @@ -494,7 +658,20 @@ export const createDefaultSecretMaterialResolver = (input: { · 3 unmodified lines | ||
| 494 | 658 | const provider = yield* getSecretMaterialProvider({ |
| 495 | 659 | providers, |
| 496 | 660 | providerId: ref.providerId, |
| 497 | − }); | |
| 661 | + }).pipe( | |
| 662 | + Effect.catchAll(() => | |
| 663 | + fromConfigSecretProviderId(ref.providerId) !== null | |
| 664 | + ? Effect.succeed(null) | |
| 665 | + : Effect.fail(new Error(`Unsupported secret provider: ${ref.providerId}`)), | |
| 666 | + ), | |
| 667 | + ); | |
| 668 | + | |
| 669 | + if (provider === null) { | |
| 670 | + return yield* resolveConfiguredSecretProvider({ | |
| 671 | + ref, | |
| 672 | + runtime, | |
| 673 | + }); | |
| 674 | + } | |
| 498 | 675 | |
| 499 | 676 | return yield* provider.resolve({ |
| 500 | 677 | ref, |
| @@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines | ||
| 187 | 187 | }); |
| 188 | 188 | }); |
| 189 | 189 | |
| 190 | −const materializationFromMcpManifestEntries = (input: { | |
| 190 | +export const materializationFromMcpManifestEntries = (input: { | |
| 191 | 191 | recipeRevisionId: SourceRecipeRevisionId; |
| 192 | 192 | endpoint: string; |
| 193 | 193 | manifestEntries: readonly McpToolManifestEntry[]; |
| @@ -1,4 +1,7 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 1 | 2 | import { createServer } from "node:http"; |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 2 | 5 | |
| 3 | 6 | import { describe, expect, it } from "vitest"; |
| 4 | 7 | import * as Schema from "effect/Schema"; |
| @@ -26,6 +29,17 @@ import { · 21 unmodified lines | ||
| 26 | 29 | createTerminalSourceAuthSessionPatch, |
| 27 | 30 | } from "./source-auth-service"; |
| 28 | 31 | import { createLiveExecutionManager } from "./live-execution"; |
| 32 | +import { | |
| 33 | + loadLocalExecutorConfig, | |
| 34 | + resolveLocalWorkspaceContext, | |
| 35 | +} from "./local-config"; | |
| 36 | +import { | |
| 37 | + readLocalSourceArtifact, | |
| 38 | +} from "./local-source-artifacts"; | |
| 39 | +import { | |
| 40 | + RuntimeLocalWorkspaceService, | |
| 41 | + type RuntimeLocalWorkspaceState, | |
| 42 | +} from "./local-runtime-context"; | |
| 29 | 43 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; |
| 30 | 44 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| 31 | 45 | |
| @@ -163,6 +177,29 @@ const makePersistence = async (): Promise<SqlControlPlanePersistence> => · 131 unmodified lines | ||
| 163 | 177 | }), |
| 164 | 178 | ); |
| 165 | 179 | |
| 180 | +const makeRuntimeLocalWorkspaceState = async ( | |
| 181 | + workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, | |
| 182 | + accountId: ReturnType<typeof AccountIdSchema.make>, | |
| 183 | +): Promise<RuntimeLocalWorkspaceState> => | |
| 184 | + Effect.runPromise( | |
| 185 | + Effect.gen(function* () { | |
| 186 | + const workspaceRoot = mkdtempSync( | |
| 187 | + join(tmpdir(), "executor-source-auth-service-"), | |
| 188 | + ); | |
| 189 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 190 | + const loadedConfig = yield* loadLocalExecutorConfig(context); | |
| 191 | + | |
| 192 | + return { | |
| 193 | + context, | |
| 194 | + installation: { | |
| 195 | + workspaceId, | |
| 196 | + accountId, | |
| 197 | + }, | |
| 198 | + loadedConfig, | |
| 199 | + } satisfies RuntimeLocalWorkspaceState; | |
| 200 | + }), | |
| 201 | + ); | |
| 202 | + | |
| 166 | 203 | describe("source-auth-service", () => { |
| 167 | 204 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 168 | 205 | |
| @@ -225,36 +262,20 @@ describe("source-auth-service", () => { · 56 unmodified lines | ||
| 225 | 262 | |
| 226 | 263 | const persistence = await makePersistence(); |
| 227 | 264 | try { |
| 228 | − const now = Date.now(); | |
| 229 | 265 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth"); |
| 230 | 266 | const accountId = AccountIdSchema.make("acc_google_oauth"); |
| 231 | − | |
| 232 | − await Effect.runPromise( | |
| 233 | − persistence.rows.organizations.insert({ | |
| 234 | − id: "org_google_oauth" as any, | |
| 235 | − slug: "google-oauth", | |
| 236 | − name: "Google OAuth", | |
| 237 | − status: "active", | |
| 238 | − createdByAccountId: accountId, | |
| 239 | − createdAt: now, | |
| 240 | − updatedAt: now, | |
| 241 | − }), | |
| 242 | − ); | |
| 243 | − await Effect.runPromise( | |
| 244 | − persistence.rows.workspaces.insert({ | |
| 245 | − id: workspaceId, | |
| 246 | − organizationId: "org_google_oauth" as any, | |
| 247 | − name: "Workspace", | |
| 248 | − createdByAccountId: accountId, | |
| 249 | − createdAt: now, | |
| 250 | − updatedAt: now, | |
| 251 | − }), | |
| 267 | + const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState( | |
| 268 | + workspaceId, | |
| 269 | + accountId, | |
| 252 | 270 | ); |
| 253 | 271 | |
| 254 | 272 | const service = createRuntimeSourceAuthService({ |
| 255 | 273 | rows: persistence.rows, |
| 256 | 274 | liveExecutionManager: createLiveExecutionManager(), |
| 257 | 275 | getLocalServerBaseUrl: () => "http://localhost:8788", |
| 276 | + localConfig: runtimeLocalWorkspace.loadedConfig.config, | |
| 277 | + workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | |
| 278 | + localWorkspaceState: runtimeLocalWorkspace, | |
| 258 | 279 | }); |
| 259 | 280 | |
| 260 | 281 | await withGoogleAuthTestServer({ |
| @@ -290,7 +311,12 @@ describe("source-auth-service", () => { · 29 unmodified lines | ||
| 290 | 311 | clientId: "google-test-client", |
| 291 | 312 | clientSecret: "google-test-secret", |
| 292 | 313 | }, |
| 293 | − }), | |
| 314 | + }).pipe( | |
| 315 | + Effect.provideService( | |
| 316 | + RuntimeLocalWorkspaceService, | |
| 317 | + runtimeLocalWorkspace, | |
| 318 | + ), | |
| 319 | + ), | |
| 294 | 320 | ); |
| 295 | 321 | |
| 296 | 322 | expect(addResult.kind).toBe("oauth_required"); |
| @@ -461,22 +487,14 @@ describe("source-auth-service", () => { · 164 unmodified lines | ||
| 461 | 487 | expect(Option.isNone(oldSecret)).toBe(true); |
| 462 | 488 | } |
| 463 | 489 | |
| 464 | − const recipeOperations = await Effect.runPromise( | |
| 465 | − persistence.rows.sources.getByWorkspaceAndId( | |
| 466 | − workspaceId, | |
| 467 | − connectedSource.id, | |
| 468 | − ), | |
| 469 | − ); | |
| 470 | − expect(Option.isSome(recipeOperations)).toBe(true); | |
| 471 | − const storedSource = Option.getOrNull(recipeOperations)!; | |
| 472 | − const materializedOperations = await Effect.runPromise( | |
| 473 | − persistence.rows.sourceRecipeOperations.listByRevisionId( | |
| 474 | − storedSource.recipeRevisionId, | |
| 475 | − ), | |
| 476 | − ); | |
| 477 | − expect(materializedOperations.length).toBeGreaterThan(0); | |
| 490 | + const localArtifact = await Effect.runPromise(readLocalSourceArtifact({ | |
| 491 | + context: runtimeLocalWorkspace.context, | |
| 492 | + sourceId: connectedSource.id, | |
| 493 | + })); | |
| 494 | + expect(localArtifact).not.toBeNull(); | |
| 495 | + expect(localArtifact?.operations.length).toBeGreaterThan(0); | |
| 478 | 496 | expect( |
| 479 | − materializedOperations.some( | |
| 497 | + localArtifact?.operations.some( | |
| 480 | 498 | (operation) => operation.toolId === "spreadsheets.values.get", |
| 481 | 499 | ), |
| 482 | 500 | ).toBe(true); |
| @@ -495,36 +513,20 @@ describe("source-auth-service", () => { · 12 unmodified lines | ||
| 495 | 513 | |
| 496 | 514 | const persistence = await makePersistence(); |
| 497 | 515 | try { |
| 498 | − const now = Date.now(); | |
| 499 | 516 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web"); |
| 500 | 517 | const accountId = AccountIdSchema.make("acc_google_oauth_web"); |
| 501 | − | |
| 502 | − await Effect.runPromise( | |
| 503 | − persistence.rows.organizations.insert({ | |
| 504 | − id: "org_google_oauth_web" as any, | |
| 505 | − slug: "google-oauth-web", | |
| 506 | − name: "Google OAuth Web", | |
| 507 | − status: "active", | |
| 508 | − createdByAccountId: accountId, | |
| 509 | − createdAt: now, | |
| 510 | − updatedAt: now, | |
| 511 | − }), | |
| 512 | − ); | |
| 513 | − await Effect.runPromise( | |
| 514 | − persistence.rows.workspaces.insert({ | |
| 515 | − id: workspaceId, | |
| 516 | − organizationId: "org_google_oauth_web" as any, | |
| 517 | − name: "Workspace", | |
| 518 | − createdByAccountId: accountId, | |
| 519 | − createdAt: now, | |
| 520 | − updatedAt: now, | |
| 521 | − }), | |
| 518 | + const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState( | |
| 519 | + workspaceId, | |
| 520 | + accountId, | |
| 522 | 521 | ); |
| 523 | 522 | |
| 524 | 523 | const service = createRuntimeSourceAuthService({ |
| 525 | 524 | rows: persistence.rows, |
| 526 | 525 | liveExecutionManager: createLiveExecutionManager(), |
| 527 | 526 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", |
| 527 | + localConfig: runtimeLocalWorkspace.loadedConfig.config, | |
| 528 | + workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | |
| 529 | + localWorkspaceState: runtimeLocalWorkspace, | |
| 528 | 530 | }); |
| 529 | 531 | |
| 530 | 532 | await withGoogleAuthTestServer({ |
| @@ -565,6 +567,11 @@ describe("source-auth-service", () => { · 34 unmodified lines | ||
| 565 | 567 | { |
| 566 | 568 | baseUrl: "https://app.executor.dev", |
| 567 | 569 | }, |
| 570 | + ).pipe( | |
| 571 | + Effect.provideService( | |
| 572 | + RuntimeLocalWorkspaceService, | |
| 573 | + runtimeLocalWorkspace, | |
| 574 | + ), | |
| 568 | 575 | ), |
| 569 | 576 | ); |
| 570 | 577 | |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | AccountId, |
| 12 | 12 | type CredentialSlot, |
| 13 | 13 | ExecutionIdSchema, |
| 14 | + type LocalExecutorConfig, | |
| 14 | 15 | McpSourceAuthSessionDataJsonSchema, |
| 15 | 16 | type McpSourceAuthSessionData, |
| 16 | 17 | OAuth2PkceSourceAuthSessionDataJsonSchema, |
| @@ -43,6 +44,10 @@ import { · 26 unmodified lines | ||
| 43 | 44 | LiveExecutionManagerService, |
| 44 | 45 | type LiveExecutionManager, |
| 45 | 46 | } from "./live-execution"; |
| 47 | +import { | |
| 48 | + RuntimeLocalWorkspaceService, | |
| 49 | + type RuntimeLocalWorkspaceState, | |
| 50 | +} from "./local-runtime-context"; | |
| 46 | 51 | import { |
| 47 | 52 | exchangeMcpOAuthAuthorizationCode, |
| 48 | 53 | startMcpOAuthAuthorization, |
| @@ -1634,13 +1639,35 @@ export const createRuntimeSourceAuthService = (input: { · 1585 unmodified lines | ||
| 1634 | 1639 | rows: SqlControlPlaneRows; |
| 1635 | 1640 | liveExecutionManager: LiveExecutionManager; |
| 1636 | 1641 | getLocalServerBaseUrl?: () => string | undefined; |
| 1642 | + localConfig?: LocalExecutorConfig | null; | |
| 1643 | + workspaceRoot?: string | null; | |
| 1644 | + localWorkspaceState?: RuntimeLocalWorkspaceState; | |
| 1637 | 1645 | }) => { |
| 1638 | 1646 | const resolveSecretMaterial = createDefaultSecretMaterialResolver({ |
| 1639 | 1647 | rows: input.rows, |
| 1648 | + localConfig: input.localConfig, | |
| 1649 | + workspaceRoot: input.workspaceRoot, | |
| 1640 | 1650 | }); |
| 1641 | 1651 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ |
| 1642 | 1652 | rows: input.rows, |
| 1643 | 1653 | }); |
| 1654 | + const mirrorLocalSourceResult = ( | |
| 1655 | + result: ExecutorSourceAddResult, | |
| 1656 | + ): Effect.Effect<ExecutorSourceAddResult, Error, never> => | |
| 1657 | + Effect.succeed(result); | |
| 1658 | + const mirrorLocalMcpSourceResult = ( | |
| 1659 | + result: McpSourceConnectResult, | |
| 1660 | + ): Effect.Effect<McpSourceConnectResult, Error, never> => | |
| 1661 | + Effect.succeed(result); | |
| 1662 | + const provideLocalWorkspace = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 1663 | + input.localWorkspaceState | |
| 1664 | + ? effect.pipe( | |
| 1665 | + Effect.provideService( | |
| 1666 | + RuntimeLocalWorkspaceService, | |
| 1667 | + input.localWorkspaceState, | |
| 1668 | + ), | |
| 1669 | + ) | |
| 1670 | + : effect; | |
| 1644 | 1671 | |
| 1645 | 1672 | return { |
| 1646 | 1673 | getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, |
| @@ -1652,68 +1679,78 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines | ||
| 1652 | 1679 | }), |
| 1653 | 1680 | |
| 1654 | 1681 | getSourceById: ({ workspaceId, sourceId, actorAccountId }) => |
| 1655 | − loadSourceById(input.rows, { | |
| 1656 | − workspaceId, | |
| 1657 | − sourceId, | |
| 1658 | − actorAccountId, | |
| 1659 | − }), | |
| 1682 | + provideLocalWorkspace( | |
| 1683 | + loadSourceById(input.rows, { | |
| 1684 | + workspaceId, | |
| 1685 | + sourceId, | |
| 1686 | + actorAccountId, | |
| 1687 | + }), | |
| 1688 | + ), | |
| 1660 | 1689 | |
| 1661 | 1690 | addExecutorSource: (sourceInput, options = undefined) => |
| 1662 | − sourceInput.kind === "google_discovery" | |
| 1663 | − ? addExecutorGoogleDiscoverySource({ | |
| 1664 | − rows: input.rows, | |
| 1665 | − sourceInput, | |
| 1666 | − storeSecretMaterial, | |
| 1667 | − resolveSecretMaterial, | |
| 1668 | − getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1669 | − baseUrl: options?.baseUrl, | |
| 1670 | − }) | |
| 1671 | − : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | |
| 1672 | − ? addExecutorHttpSource({ | |
| 1673 | − rows: input.rows, | |
| 1674 | − sourceInput: sourceInput as Extract< | |
| 1675 | − ExecutorAddSourceInput, | |
| 1676 | − { kind: "openapi" | "graphql" } | |
| 1677 | − >, | |
| 1678 | − storeSecretMaterial, | |
| 1679 | − resolveSecretMaterial, | |
| 1680 | − getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1681 | − baseUrl: options?.baseUrl, | |
| 1682 | − }) | |
| 1683 | − : connectMcpSourceInternal({ | |
| 1684 | − rows: input.rows, | |
| 1685 | − getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1686 | − workspaceId: sourceInput.workspaceId, | |
| 1687 | − actorAccountId: sourceInput.actorAccountId, | |
| 1688 | − executionId: sourceInput.executionId, | |
| 1689 | − interactionId: sourceInput.interactionId, | |
| 1690 | − endpoint: sourceInput.endpoint, | |
| 1691 | − name: sourceInput.name, | |
| 1692 | − namespace: sourceInput.namespace, | |
| 1693 | − mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation, | |
| 1694 | − baseUrl: options?.baseUrl, | |
| 1695 | − resolveSecretMaterial, | |
| 1696 | − }), | |
| 1691 | + provideLocalWorkspace( | |
| 1692 | + (sourceInput.kind === "google_discovery" | |
| 1693 | + ? addExecutorGoogleDiscoverySource({ | |
| 1694 | + rows: input.rows, | |
| 1695 | + sourceInput, | |
| 1696 | + storeSecretMaterial, | |
| 1697 | + resolveSecretMaterial, | |
| 1698 | + getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1699 | + baseUrl: options?.baseUrl, | |
| 1700 | + }) | |
| 1701 | + : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | |
| 1702 | + ? addExecutorHttpSource({ | |
| 1703 | + rows: input.rows, | |
| 1704 | + sourceInput: sourceInput as Extract< | |
| 1705 | + ExecutorAddSourceInput, | |
| 1706 | + { kind: "openapi" | "graphql" } | |
| 1707 | + >, | |
| 1708 | + storeSecretMaterial, | |
| 1709 | + resolveSecretMaterial, | |
| 1710 | + getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1711 | + baseUrl: options?.baseUrl, | |
| 1712 | + }) | |
| 1713 | + : connectMcpSourceInternal({ | |
| 1714 | + rows: input.rows, | |
| 1715 | + getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1716 | + workspaceId: sourceInput.workspaceId, | |
| 1717 | + actorAccountId: sourceInput.actorAccountId, | |
| 1718 | + executionId: sourceInput.executionId, | |
| 1719 | + interactionId: sourceInput.interactionId, | |
| 1720 | + endpoint: sourceInput.endpoint, | |
| 1721 | + name: sourceInput.name, | |
| 1722 | + namespace: sourceInput.namespace, | |
| 1723 | + mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation, | |
| 1724 | + baseUrl: options?.baseUrl, | |
| 1725 | + resolveSecretMaterial, | |
| 1726 | + })).pipe( | |
| 1727 | + Effect.flatMap(mirrorLocalSourceResult), | |
| 1728 | + ), | |
| 1729 | + ), | |
| 1697 | 1730 | |
| 1698 | 1731 | connectMcpSource: (sourceInput) => |
| 1699 | − connectMcpSourceInternal({ | |
| 1700 | − rows: input.rows, | |
| 1701 | − getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1702 | − workspaceId: sourceInput.workspaceId, | |
| 1703 | − actorAccountId: sourceInput.actorAccountId, | |
| 1704 | − sourceId: sourceInput.sourceId, | |
| 1705 | − executionId: null, | |
| 1706 | − interactionId: null, | |
| 1707 | − endpoint: sourceInput.endpoint, | |
| 1708 | − name: sourceInput.name, | |
| 1709 | − namespace: sourceInput.namespace, | |
| 1710 | − enabled: sourceInput.enabled, | |
| 1711 | − transport: sourceInput.transport, | |
| 1712 | − queryParams: sourceInput.queryParams, | |
| 1713 | − headers: sourceInput.headers, | |
| 1714 | − baseUrl: sourceInput.baseUrl, | |
| 1715 | − resolveSecretMaterial, | |
| 1716 | − }), | |
| 1732 | + provideLocalWorkspace( | |
| 1733 | + connectMcpSourceInternal({ | |
| 1734 | + rows: input.rows, | |
| 1735 | + getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1736 | + workspaceId: sourceInput.workspaceId, | |
| 1737 | + actorAccountId: sourceInput.actorAccountId, | |
| 1738 | + sourceId: sourceInput.sourceId, | |
| 1739 | + executionId: null, | |
| 1740 | + interactionId: null, | |
| 1741 | + endpoint: sourceInput.endpoint, | |
| 1742 | + name: sourceInput.name, | |
| 1743 | + namespace: sourceInput.namespace, | |
| 1744 | + enabled: sourceInput.enabled, | |
| 1745 | + transport: sourceInput.transport, | |
| 1746 | + queryParams: sourceInput.queryParams, | |
| 1747 | + headers: sourceInput.headers, | |
| 1748 | + baseUrl: sourceInput.baseUrl, | |
| 1749 | + resolveSecretMaterial, | |
| 1750 | + }).pipe( | |
| 1751 | + Effect.flatMap(mirrorLocalMcpSourceResult), | |
| 1752 | + ), | |
| 1753 | + ), | |
| 1717 | 1754 | |
| 1718 | 1755 | startSourceOAuthSession: (oauthInput) => |
| 1719 | 1756 | Effect.gen(function* () { |
| @@ -1781,7 +1818,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines | ||
| 1781 | 1818 | error, |
| 1782 | 1819 | errorDescription, |
| 1783 | 1820 | }) => |
| 1784 | − Effect.gen(function* () { | |
| 1821 | + provideLocalWorkspace(Effect.gen(function* () { | |
| 1785 | 1822 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 1786 | 1823 | if (Option.isNone(sessionOption)) { |
| 1787 | 1824 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); |
| @@ -1890,7 +1927,7 @@ export const createRuntimeSourceAuthService = (input: { · 102 unmodified lines | ||
| 1890 | 1927 | sessionId: session.id, |
| 1891 | 1928 | auth, |
| 1892 | 1929 | } satisfies CompleteSourceOAuthSessionResult; |
| 1893 | − }), | |
| 1930 | + })), | |
| 1894 | 1931 | |
| 1895 | 1932 | completeSourceCredentialSetup: ({ |
| 1896 | 1933 | workspaceId, |
| @@ -1901,7 +1938,7 @@ export const createRuntimeSourceAuthService = (input: { · 4 unmodified lines | ||
| 1901 | 1938 | error, |
| 1902 | 1939 | errorDescription, |
| 1903 | 1940 | }) => |
| 1904 | − Effect.gen(function* () { | |
| 1941 | + provideLocalWorkspace(Effect.gen(function* () { | |
| 1905 | 1942 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 1906 | 1943 | if (Option.isNone(sessionOption)) { |
| 1907 | 1944 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); |
| @@ -2164,7 +2201,7 @@ export const createRuntimeSourceAuthService = (input: { · 256 unmodified lines | ||
| 2164 | 2201 | }); |
| 2165 | 2202 | |
| 2166 | 2203 | return connectedSource; |
| 2167 | − }), | |
| 2204 | + })), | |
| 2168 | 2205 | } satisfies RuntimeSourceAuthServiceShape; |
| 2169 | 2206 | }; |
| 2170 | 2207 | |
| @@ -2176,6 +2213,9 @@ export class RuntimeSourceAuthServiceTag extends Context.Tag( · 5 unmodified lines | ||
| 2176 | 2213 | |
| 2177 | 2214 | export const RuntimeSourceAuthServiceLive = (input: { |
| 2178 | 2215 | getLocalServerBaseUrl?: () => string | undefined; |
| 2216 | + localConfig?: LocalExecutorConfig | null; | |
| 2217 | + workspaceRoot?: string | null; | |
| 2218 | + localWorkspaceState?: RuntimeLocalWorkspaceState; | |
| 2179 | 2219 | } = {}) => |
| 2180 | 2220 | Layer.effect( |
| 2181 | 2221 | RuntimeSourceAuthServiceTag, |
| @@ -2187,6 +2227,9 @@ export const RuntimeSourceAuthServiceLive = (input: { · 5 unmodified lines | ||
| 2187 | 2227 | rows, |
| 2188 | 2228 | liveExecutionManager, |
| 2189 | 2229 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 2230 | + localConfig: input.localConfig, | |
| 2231 | + workspaceRoot: input.workspaceRoot, | |
| 2232 | + localWorkspaceState: input.localWorkspaceState, | |
| 2190 | 2233 | }); |
| 2191 | 2234 | }), |
| 2192 | 2235 | ); |
| @@ -1,11 +1,14 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 1 | 5 | import { describe, expect, it } from "vitest"; |
| 2 | 6 | import * as Effect from "effect/Effect"; |
| 3 | 7 | |
| 4 | 8 | import { |
| 5 | 9 | AccountIdSchema, |
| 6 | − OrganizationIdSchema, | |
| 10 | + type LocalConfigSource, | |
| 7 | 11 | SourceIdSchema, |
| 8 | − SourceRecipeIdSchema, | |
| 9 | 12 | SourceRecipeRevisionIdSchema, |
| 10 | 13 | SourceRecipeSchemaBundleIdSchema, |
| 11 | 14 | WorkspaceIdSchema, |
| @@ -17,11 +20,27 @@ import { · 5 unmodified lines | ||
| 17 | 20 | type SqlControlPlanePersistence, |
| 18 | 21 | } from "#persistence"; |
| 19 | 22 | |
| 23 | +import { | |
| 24 | + loadLocalExecutorConfig, | |
| 25 | + resolveLocalWorkspaceContext, | |
| 26 | + writeProjectLocalExecutorConfig, | |
| 27 | +} from "./local-config"; | |
| 28 | +import { | |
| 29 | + buildLocalSourceArtifact, | |
| 30 | + writeLocalSourceArtifact, | |
| 31 | +} from "./local-source-artifacts"; | |
| 20 | 32 | import { |
| 21 | 33 | getSourceInspection, |
| 22 | 34 | getSourceInspectionSchemaBundle, |
| 23 | 35 | getSourceInspectionToolDetail, |
| 24 | 36 | } from "./source-inspection"; |
| 37 | +import { | |
| 38 | + RuntimeLocalWorkspaceService, | |
| 39 | + type RuntimeLocalWorkspaceState, | |
| 40 | +} from "./local-runtime-context"; | |
| 41 | +import { | |
| 42 | + writeLocalWorkspaceState, | |
| 43 | +} from "./local-workspace-state"; | |
| 25 | 44 | import { ControlPlaneStore } from "./store"; |
| 26 | 45 | |
| 27 | 46 | const makePersistence = () => |
| @@ -31,30 +50,28 @@ const makePersistence = () => · 3 unmodified lines | ||
| 31 | 50 | }), |
| 32 | 51 | ); |
| 33 | 52 | |
| 34 | −const seedWorkspace = async (input: { | |
| 35 | − persistence: SqlControlPlanePersistence; | |
| 36 | − workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | |
| 37 | − organizationId: ReturnType<typeof OrganizationIdSchema.make>; | |
| 38 | − accountId: ReturnType<typeof AccountIdSchema.make>; | |
| 39 | −}) => { | |
| 40 | − await Effect.runPromise(input.persistence.rows.organizations.insert({ | |
| 41 | − id: input.organizationId, | |
| 42 | − slug: `org-${input.organizationId}`, | |
| 43 | − name: `Org ${input.organizationId}`, | |
| 44 | − status: "active", | |
| 45 | − createdByAccountId: input.accountId, | |
| 46 | − createdAt: 1000, | |
| 47 | − updatedAt: 1000, | |
| 48 | − })); | |
| 49 | − await Effect.runPromise(input.persistence.rows.workspaces.insert({ | |
| 50 | − id: input.workspaceId, | |
| 51 | − organizationId: input.organizationId, | |
| 52 | − name: `Workspace ${input.workspaceId}`, | |
| 53 | − createdByAccountId: input.accountId, | |
| 54 | − createdAt: 1000, | |
| 55 | − updatedAt: 1000, | |
| 56 | − })); | |
| 57 | −}; | |
| 53 | +const makeRuntimeLocalWorkspaceState = ( | |
| 54 | + workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, | |
| 55 | + accountId: ReturnType<typeof AccountIdSchema.make>, | |
| 56 | +) => | |
| 57 | + Effect.runPromise( | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const workspaceRoot = mkdtempSync( | |
| 60 | + join(tmpdir(), "executor-source-inspection-"), | |
| 61 | + ); | |
| 62 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 63 | + const loadedConfig = yield* loadLocalExecutorConfig(context); | |
| 64 | + | |
| 65 | + return { | |
| 66 | + context, | |
| 67 | + installation: { | |
| 68 | + workspaceId, | |
| 69 | + accountId, | |
| 70 | + }, | |
| 71 | + loadedConfig, | |
| 72 | + } satisfies RuntimeLocalWorkspaceState; | |
| 73 | + }), | |
| 74 | + ); | |
| 58 | 75 | |
| 59 | 76 | const makeSource = (input: { |
| 60 | 77 | workspaceId: Source["workspaceId"]; |
| @@ -133,16 +150,39 @@ const makeOperation = ( · 72 unmodified lines | ||
| 133 | 150 | ...overrides, |
| 134 | 151 | }); |
| 135 | 152 | |
| 153 | +const configSourceFromSource = (source: Source): LocalConfigSource => ({ | |
| 154 | + kind: "openapi", | |
| 155 | + ...(source.name !== source.id ? { name: source.name } : {}), | |
| 156 | + ...(source.namespace && source.namespace !== source.id | |
| 157 | + ? { namespace: source.namespace } | |
| 158 | + : {}), | |
| 159 | + connection: { | |
| 160 | + endpoint: source.endpoint, | |
| 161 | + }, | |
| 162 | + binding: { | |
| 163 | + specUrl: | |
| 164 | + typeof source.binding.specUrl === "string" | |
| 165 | + ? source.binding.specUrl | |
| 166 | + : "https://example.com/openapi.json", | |
| 167 | + defaultHeaders: | |
| 168 | + (source.binding.defaultHeaders as Record<string, string> | null | undefined) | |
| 169 | + ?? null, | |
| 170 | + }, | |
| 171 | +}); | |
| 172 | + | |
| 136 | 173 | describe("source-inspection", () => { |
| 137 | 174 | it("returns a lightweight inspection bundle and loads rich detail on demand", async () => { |
| 138 | 175 | const persistence = await makePersistence(); |
| 139 | 176 | try { |
| 140 | 177 | const workspaceId = WorkspaceIdSchema.make("ws_source_inspection"); |
| 141 | − const organizationId = OrganizationIdSchema.make("org_source_inspection"); | |
| 142 | 178 | const accountId = AccountIdSchema.make("acc_source_inspection"); |
| 143 | 179 | const sourceId = SourceIdSchema.make("src_source_inspection"); |
| 144 | − const recipeId = SourceRecipeIdSchema.make("src_recipe_inspection"); | |
| 145 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_inspection"); | |
| 180 | + const recipeRevisionId = | |
| 181 | + SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"); | |
| 182 | + const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState( | |
| 183 | + workspaceId, | |
| 184 | + accountId, | |
| 185 | + ); | |
| 146 | 186 | const hugeDocument = JSON.stringify({ |
| 147 | 187 | openapi: "3.0.3", |
| 148 | 188 | info: { |
| @@ -162,102 +202,82 @@ describe("source-inspection", () => { · 13 unmodified lines | ||
| 162 | 202 | ), |
| 163 | 203 | }); |
| 164 | 204 | |
| 165 | − await seedWorkspace({ | |
| 166 | − persistence, | |
| 205 | + const source = makeSource({ | |
| 167 | 206 | workspaceId, |
| 168 | − organizationId, | |
| 169 | − accountId, | |
| 207 | + sourceId, | |
| 170 | 208 | }); |
| 171 | − await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | |
| 172 | − id: recipeId, | |
| 173 | − kind: "http_api", | |
| 174 | − adapterKey: "openapi", | |
| 175 | − providerKey: "generic_http", | |
| 176 | − name: "Cloudflare API", | |
| 177 | − summary: null, | |
| 178 | − visibility: "workspace", | |
| 179 | − latestRevisionId: recipeRevisionId, | |
| 180 | − createdAt: 1000, | |
| 181 | − updatedAt: 1000, | |
| 209 | + await Effect.runPromise(writeProjectLocalExecutorConfig({ | |
| 210 | + context: runtimeLocalWorkspace.context, | |
| 211 | + config: { | |
| 212 | + sources: { | |
| 213 | + [sourceId]: configSourceFromSource(source), | |
| 214 | + }, | |
| 215 | + }, | |
| 182 | 216 | })); |
| 183 | − await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({ | |
| 184 | − id: recipeRevisionId, | |
| 185 | − recipeId, | |
| 186 | − revisionNumber: 1, | |
| 187 | − sourceConfigJson: JSON.stringify({ | |
| 188 | − kind: "openapi", | |
| 189 | − endpoint: "https://api.cloudflare.com/client/v4", | |
| 190 | − specUrl: "https://example.com/openapi.json", | |
| 191 | − }), | |
| 192 | − manifestJson: JSON.stringify({ | |
| 193 | − sourceHash: "manifest_hash", | |
| 217 | + await Effect.runPromise(writeLocalSourceArtifact({ | |
| 218 | + context: runtimeLocalWorkspace.context, | |
| 219 | + sourceId, | |
| 220 | + artifact: buildLocalSourceArtifact({ | |
| 221 | + source: { | |
| 222 | + ...source, | |
| 223 | + sourceHash: "manifest_hash", | |
| 224 | + }, | |
| 225 | + materialization: { | |
| 226 | + manifestJson: JSON.stringify({ | |
| 227 | + sourceHash: "manifest_hash", | |
| 228 | + }), | |
| 229 | + manifestHash: "manifest_hash", | |
| 230 | + sourceHash: "manifest_hash", | |
| 231 | + documents: [{ | |
| 232 | + id: "src_recipe_doc_inspection", | |
| 233 | + recipeRevisionId, | |
| 234 | + documentKind: "openapi", | |
| 235 | + documentKey: "https://example.com/openapi.json", | |
| 236 | + contentText: hugeDocument, | |
| 237 | + contentHash: "doc_hash", | |
| 238 | + fetchedAt: 1000, | |
| 239 | + createdAt: 1000, | |
| 240 | + updatedAt: 1000, | |
| 241 | + }], | |
| 242 | + schemaBundles: [{ | |
| 243 | + id: SourceRecipeSchemaBundleIdSchema.make( | |
| 244 | + "src_recipe_bundle_inspection", | |
| 245 | + ), | |
| 246 | + recipeRevisionId, | |
| 247 | + bundleKind: "json_schema_ref_map", | |
| 248 | + refsJson: JSON.stringify({ | |
| 249 | + "#/components/schemas/Pagination": { | |
| 250 | + type: "object", | |
| 251 | + properties: { | |
| 252 | + page: { type: "number" }, | |
| 253 | + }, | |
| 254 | + }, | |
| 255 | + }), | |
| 256 | + contentHash: "bundle_hash", | |
| 257 | + createdAt: 1000, | |
| 258 | + updatedAt: 1000, | |
| 259 | + }], | |
| 260 | + operations: [makeOperation({ | |
| 261 | + recipeRevisionId, | |
| 262 | + })], | |
| 263 | + }, | |
| 194 | 264 | }), |
| 195 | − manifestHash: "manifest_hash", | |
| 196 | − materializationHash: "materialization_hash", | |
| 197 | − createdAt: 1000, | |
| 198 | − updatedAt: 1000, | |
| 199 | 265 | })); |
| 200 | − await Effect.runPromise(persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 201 | − recipeRevisionId, | |
| 202 | − documents: [{ | |
| 203 | − id: "src_recipe_doc_inspection", | |
| 204 | − recipeRevisionId, | |
| 205 | − documentKind: "openapi", | |
| 206 | − documentKey: "https://example.com/openapi.json", | |
| 207 | − contentText: hugeDocument, | |
| 208 | − contentHash: "doc_hash", | |
| 209 | − fetchedAt: 1000, | |
| 210 | − createdAt: 1000, | |
| 211 | − updatedAt: 1000, | |
| 212 | − }], | |
| 213 | − })); | |
| 214 | − await Effect.runPromise(persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | |
| 215 | − recipeRevisionId, | |
| 216 | − bundles: [{ | |
| 217 | − id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_inspection"), | |
| 218 | − recipeRevisionId, | |
| 219 | − bundleKind: "json_schema_ref_map", | |
| 220 | − refsJson: JSON.stringify({ | |
| 221 | − "#/components/schemas/Pagination": { | |
| 222 | − type: "object", | |
| 223 | − properties: { | |
| 224 | − page: { type: "number" }, | |
| 225 | − }, | |
| 226 | − }, | |
| 227 | − }), | |
| 228 | − contentHash: "bundle_hash", | |
| 229 | − createdAt: 1000, | |
| 230 | − updatedAt: 1000, | |
| 231 | − }], | |
| 232 | − })); | |
| 233 | − await Effect.runPromise(persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 234 | − recipeRevisionId, | |
| 235 | − operations: [makeOperation()], | |
| 236 | − })); | |
| 237 | − await Effect.runPromise(persistence.rows.sources.insert({ | |
| 238 | − id: sourceId, | |
| 239 | − workspaceId, | |
| 240 | − recipeId, | |
| 241 | − recipeRevisionId, | |
| 242 | − name: "Cloudflare API", | |
| 243 | − kind: "openapi", | |
| 244 | − endpoint: "https://api.cloudflare.com/client/v4", | |
| 245 | − status: "connected", | |
| 246 | − enabled: true, | |
| 247 | − namespace: "cloudflare.api", | |
| 248 | − importAuthPolicy: "reuse_runtime", | |
| 249 | − bindingConfigJson: JSON.stringify({ | |
| 250 | − adapterKey: "openapi", | |
| 266 | + await Effect.runPromise(writeLocalWorkspaceState({ | |
| 267 | + context: runtimeLocalWorkspace.context, | |
| 268 | + state: { | |
| 251 | 269 | version: 1, |
| 252 | − payload: { | |
| 253 | − specUrl: "https://example.com/openapi.json", | |
| 254 | − defaultHeaders: null, | |
| 270 | + sources: { | |
| 271 | + [sourceId]: { | |
| 272 | + status: "connected", | |
| 273 | + lastError: null, | |
| 274 | + sourceHash: "manifest_hash", | |
| 275 | + createdAt: 1000, | |
| 276 | + updatedAt: 1000, | |
| 277 | + }, | |
| 255 | 278 | }, |
| 256 | − }), | |
| 257 | − sourceHash: null, | |
| 258 | − lastError: null, | |
| 259 | − createdAt: 1000, | |
| 260 | − updatedAt: 1000, | |
| 279 | + policies: {}, | |
| 280 | + }, | |
| 261 | 281 | })); |
| 262 | 282 | |
| 263 | 283 | const inspection = await Effect.runPromise( |
| @@ -266,6 +286,10 @@ describe("source-inspection", () => { · 2 unmodified lines | ||
| 266 | 286 | sourceId, |
| 267 | 287 | }).pipe( |
| 268 | 288 | Effect.provideService(ControlPlaneStore, persistence.rows), |
| 289 | + Effect.provideService( | |
| 290 | + RuntimeLocalWorkspaceService, | |
| 291 | + runtimeLocalWorkspace, | |
| 292 | + ), | |
| 269 | 293 | ), |
| 270 | 294 | ); |
| 271 | 295 | |
| @@ -282,6 +306,10 @@ describe("source-inspection", () => { · 10 unmodified lines | ||
| 282 | 306 | toolPath: "cloudflare.api.zones.listZones", |
| 283 | 307 | }).pipe( |
| 284 | 308 | Effect.provideService(ControlPlaneStore, persistence.rows), |
| 309 | + Effect.provideService( | |
| 310 | + RuntimeLocalWorkspaceService, | |
| 311 | + runtimeLocalWorkspace, | |
| 312 | + ), | |
| 285 | 313 | ), |
| 286 | 314 | ); |
| 287 | 315 | |
| @@ -300,6 +328,10 @@ describe("source-inspection", () => { · 12 unmodified lines | ||
| 300 | 328 | schemaBundleId: "src_recipe_bundle_inspection", |
| 301 | 329 | }).pipe( |
| 302 | 330 | Effect.provideService(ControlPlaneStore, persistence.rows), |
| 331 | + Effect.provideService( | |
| 332 | + RuntimeLocalWorkspaceService, | |
| 333 | + runtimeLocalWorkspace, | |
| 334 | + ), | |
| 303 | 335 | ), |
| 304 | 336 | ); |
| 305 | 337 | |
| @@ -16,7 +16,7 @@ import { SourceRecipeSchemaBundleIdSchema } from "#schema"; · 15 unmodified lines | ||
| 16 | 16 | import { |
| 17 | 17 | ControlPlaneNotFoundError, |
| 18 | 18 | ControlPlaneStorageError, |
| 19 | −} from "#api"; | |
| 19 | +} from "../api/errors"; | |
| 20 | 20 | import * as Effect from "effect/Effect"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | |
| @@ -26,6 +26,7 @@ import { · 3 unmodified lines | ||
| 26 | 26 | getSourceAdapterForOperation, |
| 27 | 27 | } from "./source-adapters"; |
| 28 | 28 | import { |
| 29 | + loadSourceWithRecipe, | |
| 29 | 30 | recipeToolMetadata, |
| 30 | 31 | recipeToolPath, |
| 31 | 32 | } from "./source-recipes-runtime"; |
| @@ -110,41 +111,29 @@ const loadSourceRecipeRecord = (input: { · 78 unmodified lines | ||
| 110 | 111 | }) => |
| 111 | 112 | Effect.flatMap(ControlPlaneStore, (store) => |
| 112 | 113 | Effect.gen(function* () { |
| 113 | − const sourceRecord = yield* sourceInspectOps.bundle.child("record").mapStorage( | |
| 114 | − store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId), | |
| 115 | − ); | |
| 116 | − if (Option.isNone(sourceRecord)) { | |
| 117 | − return yield* Effect.fail( | |
| 118 | − sourceInspectOps.bundle.notFound( | |
| 119 | − "Source not found", | |
| 120 | − `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 121 | − ), | |
| 122 | − ); | |
| 123 | − } | |
| 124 | − | |
| 125 | − const source = yield* loadSourceById(store, { | |
| 114 | + const recipe = yield* loadSourceWithRecipe({ | |
| 115 | + rows: store, | |
| 126 | 116 | workspaceId: input.workspaceId, |
| 127 | 117 | sourceId: input.sourceId, |
| 128 | 118 | }).pipe( |
| 129 | 119 | Effect.mapError((cause) => |
| 130 | − sourceInspectOps.bundle.unknownStorage( | |
| 131 | − cause, | |
| 132 | − "Failed loading source", | |
| 133 | − ), | |
| 120 | + cause instanceof Error && cause.message.startsWith("Source not found:") | |
| 121 | + ? sourceInspectOps.bundle.notFound( | |
| 122 | + "Source not found", | |
| 123 | + `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 124 | + ) | |
| 125 | + : sourceInspectOps.bundle.unknownStorage( | |
| 126 | + cause, | |
| 127 | + "Failed loading source recipe", | |
| 128 | + ), | |
| 134 | 129 | ), |
| 135 | 130 | ); |
| 136 | − const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage( | |
| 137 | − store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 138 | − ); | |
| 139 | − const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage( | |
| 140 | − store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 141 | − ); | |
| 142 | 131 | |
| 143 | 132 | return { |
| 144 | − source, | |
| 145 | − operations, | |
| 146 | − schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id | |
| 147 | − ?? schemaBundles[0]?.id | |
| 133 | + source: recipe.source, | |
| 134 | + operations: recipe.operations, | |
| 135 | + schemaBundleId: recipe.schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id | |
| 136 | + ?? recipe.schemaBundles[0]?.id | |
| 148 | 137 | ?? null, |
| 149 | 138 | }; |
| 150 | 139 | }), |
| @@ -403,27 +392,27 @@ export const getSourceInspectionSchemaBundle = (input: { · 252 unmodified lines | ||
| 403 | 392 | }) => |
| 404 | 393 | Effect.flatMap(ControlPlaneStore, (store) => |
| 405 | 394 | Effect.gen(function* () { |
| 406 | − const sourceRecord = yield* sourceInspectOps.tool.child("bundle_record").mapStorage( | |
| 407 | − store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId), | |
| 408 | − ); | |
| 409 | − if (Option.isNone(sourceRecord)) { | |
| 410 | − return yield* Effect.fail( | |
| 411 | − sourceInspectOps.tool.notFound( | |
| 412 | − "Source not found", | |
| 413 | − `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 414 | − ), | |
| 415 | − ); | |
| 416 | − } | |
| 417 | − | |
| 418 | − const schemaBundle = yield* sourceInspectOps.tool.child("schema_bundle").mapStorage( | |
| 419 | − store.sourceRecipeSchemaBundles.getById( | |
| 420 | − SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId), | |
| 395 | + const recipe = yield* loadSourceWithRecipe({ | |
| 396 | + rows: store, | |
| 397 | + workspaceId: input.workspaceId, | |
| 398 | + sourceId: input.sourceId, | |
| 399 | + }).pipe( | |
| 400 | + Effect.mapError((cause) => | |
| 401 | + cause instanceof Error && cause.message.startsWith("Source not found:") | |
| 402 | + ? sourceInspectOps.tool.notFound( | |
| 403 | + "Source not found", | |
| 404 | + `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 405 | + ) | |
| 406 | + : sourceInspectOps.tool.unknownStorage( | |
| 407 | + cause, | |
| 408 | + "Failed loading source recipe", | |
| 409 | + ), | |
| 421 | 410 | ), |
| 422 | 411 | ); |
| 423 | − if ( | |
| 424 | − Option.isNone(schemaBundle) | |
| 425 | − || schemaBundle.value.recipeRevisionId !== sourceRecord.value.recipeRevisionId | |
| 426 | − ) { | |
| 412 | + const schemaBundle = recipe.schemaBundles.find( | |
| 413 | + (candidate) => candidate.id === SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId), | |
| 414 | + ); | |
| 415 | + if (!schemaBundle) { | |
| 427 | 416 | return yield* Effect.fail( |
| 428 | 417 | sourceInspectOps.tool.notFound( |
| 429 | 418 | "Schema bundle not found", |
| @@ -433,10 +422,10 @@ export const getSourceInspectionSchemaBundle = (input: { · 3 unmodified lines | ||
| 433 | 422 | } |
| 434 | 423 | |
| 435 | 424 | return { |
| 436 | − id: schemaBundle.value.id, | |
| 437 | − kind: schemaBundle.value.bundleKind, | |
| 438 | − hash: schemaBundle.value.contentHash, | |
| 439 | − refsJson: schemaBundle.value.refsJson, | |
| 425 | + id: schemaBundle.id, | |
| 426 | + kind: schemaBundle.bundleKind, | |
| 427 | + hash: schemaBundle.contentHash, | |
| 428 | + refsJson: schemaBundle.refsJson, | |
| 440 | 429 | } satisfies SourceInspectionSchemaBundle; |
| 441 | 430 | }), |
| 442 | 431 | ).pipe( |
| @@ -2,18 +2,33 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line | ||
| 2 | 2 | import type { |
| 3 | 3 | AccountId, |
| 4 | 4 | Source, |
| 5 | + SourceStatus, | |
| 5 | 6 | } from "#schema"; |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | |
| 9 | +import { | |
| 10 | + buildLocalSourceArtifact, | |
| 11 | + writeLocalSourceArtifact, | |
| 12 | +} from "./local-source-artifacts"; | |
| 13 | +import { | |
| 14 | + requireRuntimeLocalWorkspace, | |
| 15 | +} from "./local-runtime-context"; | |
| 16 | +import { | |
| 17 | + loadLocalWorkspaceState, | |
| 18 | + writeLocalWorkspaceState, | |
| 19 | + type LocalWorkspaceState, | |
| 20 | +} from "./local-workspace-state"; | |
| 8 | 21 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| 9 | 22 | import { |
| 10 | 23 | getSourceAdapterForSource, |
| 11 | 24 | } from "./source-adapters"; |
| 12 | −import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp"; | |
| 25 | +import { | |
| 26 | + materializationFromMcpManifestEntries, | |
| 27 | + persistMcpRecipeRevisionFromManifestEntries, | |
| 28 | +} from "./source-adapters/mcp"; | |
| 13 | 29 | import type { |
| 14 | 30 | ResolveSecretMaterial as ResolveSourceSecretMaterial, |
| 15 | 31 | } from "./secret-material-providers"; |
| 16 | −import { persistRecipeMaterialization } from "./source-recipe-support"; | |
| 17 | 32 | |
| 18 | 33 | const shouldIndexSource = (source: Source): boolean => |
| 19 | 34 | source.enabled |
| @@ -27,7 +42,28 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines | ||
| 27 | 42 | resolveSecretMaterial: ResolveSourceSecretMaterial; |
| 28 | 43 | }): Effect.Effect<void, Error, never> => |
| 29 | 44 | Effect.gen(function* () { |
| 45 | + const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId); | |
| 46 | + | |
| 30 | 47 | if (!shouldIndexSource(input.source)) { |
| 48 | + const state = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); | |
| 49 | + const existingSourceState = state.sources[input.source.id]; | |
| 50 | + const nextState: LocalWorkspaceState = { | |
| 51 | + ...state, | |
| 52 | + sources: { | |
| 53 | + ...state.sources, | |
| 54 | + [input.source.id]: { | |
| 55 | + status: (input.source.enabled ? input.source.status : "draft") as SourceStatus, | |
| 56 | + lastError: null, | |
| 57 | + sourceHash: input.source.sourceHash, | |
| 58 | + createdAt: existingSourceState?.createdAt ?? input.source.createdAt, | |
| 59 | + updatedAt: Date.now(), | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + }; | |
| 63 | + yield* writeLocalWorkspaceState({ | |
| 64 | + context: runtimeLocalWorkspace.context, | |
| 65 | + state: nextState, | |
| 66 | + }); | |
| 31 | 67 | return; |
| 32 | 68 | } |
| 33 | 69 | |
| @@ -44,10 +80,33 @@ export const syncSourceMaterialization = (input: { · 10 unmodified lines | ||
| 44 | 80 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 45 | 81 | }), |
| 46 | 82 | }); |
| 47 | − yield* persistRecipeMaterialization({ | |
| 48 | − rows: input.rows, | |
| 49 | − source: input.source, | |
| 50 | − materialization, | |
| 83 | + yield* writeLocalSourceArtifact({ | |
| 84 | + context: runtimeLocalWorkspace.context, | |
| 85 | + sourceId: input.source.id, | |
| 86 | + artifact: buildLocalSourceArtifact({ | |
| 87 | + source: input.source, | |
| 88 | + materialization, | |
| 89 | + }), | |
| 90 | + }); | |
| 91 | + | |
| 92 | + const state = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); | |
| 93 | + const existingSourceState = state.sources[input.source.id]; | |
| 94 | + const nextState: LocalWorkspaceState = { | |
| 95 | + ...state, | |
| 96 | + sources: { | |
| 97 | + ...state.sources, | |
| 98 | + [input.source.id]: { | |
| 99 | + status: "connected", | |
| 100 | + lastError: null, | |
| 101 | + sourceHash: materialization.sourceHash, | |
| 102 | + createdAt: existingSourceState?.createdAt ?? input.source.createdAt, | |
| 103 | + updatedAt: Date.now(), | |
| 104 | + }, | |
| 105 | + }, | |
| 106 | + }; | |
| 107 | + yield* writeLocalWorkspaceState({ | |
| 108 | + context: runtimeLocalWorkspace.context, | |
| 109 | + state: nextState, | |
| 51 | 110 | }); |
| 52 | 111 | }); |
| 53 | 112 | |
| @@ -58,4 +117,20 @@ export const persistMcpRecipeMaterializationFromManifest = (input: { · 4 unmodified lines | ||
| 58 | 117 | typeof persistMcpRecipeRevisionFromManifestEntries |
| 59 | 118 | >[0]["manifestEntries"]; |
| 60 | 119 | }): Effect.Effect<void, Error, never> => |
| 61 | − persistMcpRecipeRevisionFromManifestEntries(input); | |
| 120 | + Effect.gen(function* () { | |
| 121 | + const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId); | |
| 122 | + const materialization = materializationFromMcpManifestEntries({ | |
| 123 | + recipeRevisionId: "src_recipe_rev_materialization" as never, | |
| 124 | + endpoint: input.source.endpoint, | |
| 125 | + manifestEntries: input.manifestEntries, | |
| 126 | + }); | |
| 127 | + | |
| 128 | + yield* writeLocalSourceArtifact({ | |
| 129 | + context: runtimeLocalWorkspace.context, | |
| 130 | + sourceId: input.source.id, | |
| 131 | + artifact: buildLocalSourceArtifact({ | |
| 132 | + source: input.source, | |
| 133 | + materialization, | |
| 134 | + }), | |
| 135 | + }); | |
| 136 | + }); | |
| @@ -1,3 +1,7 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 1 | 5 | import { describe, expect, it } from "vitest"; |
| 2 | 6 | import * as Effect from "effect/Effect"; |
| 3 | 7 | import { |
| @@ -8,8 +12,8 @@ import { · 4 unmodified lines | ||
| 8 | 12 | |
| 9 | 13 | import { |
| 10 | 14 | AccountIdSchema, |
| 11 | − OrganizationIdSchema, | |
| 12 | 15 | SourceIdSchema, |
| 16 | + type LocalConfigSource, | |
| 13 | 17 | SourceRecipeIdSchema, |
| 14 | 18 | SourceRecipeRevisionIdSchema, |
| 15 | 19 | WorkspaceIdSchema, |
| @@ -21,6 +25,22 @@ import { · 5 unmodified lines | ||
| 21 | 25 | type SqlControlPlanePersistence, |
| 22 | 26 | } from "#persistence"; |
| 23 | 27 | |
| 28 | +import { | |
| 29 | + loadLocalExecutorConfig, | |
| 30 | + resolveLocalWorkspaceContext, | |
| 31 | + writeProjectLocalExecutorConfig, | |
| 32 | +} from "./local-config"; | |
| 33 | +import { | |
| 34 | + buildLocalSourceArtifact, | |
| 35 | + writeLocalSourceArtifact, | |
| 36 | +} from "./local-source-artifacts"; | |
| 37 | +import { | |
| 38 | + RuntimeLocalWorkspaceService, | |
| 39 | + type RuntimeLocalWorkspaceState, | |
| 40 | +} from "./local-runtime-context"; | |
| 41 | +import { | |
| 42 | + writeLocalWorkspaceState, | |
| 43 | +} from "./local-workspace-state"; | |
| 24 | 44 | import { |
| 25 | 45 | expandRecipeTools, |
| 26 | 46 | loadSourceWithRecipe, |
| @@ -38,6 +58,36 @@ const makePersistence = () => · 11 unmodified lines | ||
| 38 | 58 | }), |
| 39 | 59 | ); |
| 40 | 60 | |
| 61 | +const makeRuntimeLocalWorkspaceState = ( | |
| 62 | + workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, | |
| 63 | +) => | |
| 64 | + Effect.runPromise( | |
| 65 | + Effect.gen(function* () { | |
| 66 | + const workspaceRoot = mkdtempSync( | |
| 67 | + join(tmpdir(), "executor-source-recipes-runtime-"), | |
| 68 | + ); | |
| 69 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 70 | + const loadedConfig = yield* loadLocalExecutorConfig(context); | |
| 71 | + | |
| 72 | + return { | |
| 73 | + context, | |
| 74 | + installation: { | |
| 75 | + workspaceId, | |
| 76 | + accountId: AccountIdSchema.make("acc_local_source_recipes"), | |
| 77 | + }, | |
| 78 | + loadedConfig, | |
| 79 | + } satisfies RuntimeLocalWorkspaceState; | |
| 80 | + }), | |
| 81 | + ); | |
| 82 | + | |
| 83 | +const withRuntimeLocalWorkspace = <A, E>( | |
| 84 | + effect: Effect.Effect<A, E, never>, | |
| 85 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | |
| 86 | +) => | |
| 87 | + effect.pipe( | |
| 88 | + Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | |
| 89 | + ); | |
| 90 | + | |
| 41 | 91 | const openApiBindingConfigJson = (specUrl: string): string => |
| 42 | 92 | JSON.stringify({ |
| 43 | 93 | adapterKey: "openapi", |
| @@ -221,29 +271,128 @@ const makeMcpOperation = ( · 177 unmodified lines | ||
| 221 | 271 | ...overrides, |
| 222 | 272 | }); |
| 223 | 273 | |
| 224 | −const seedWorkspace = async (input: { | |
| 225 | − persistence: SqlControlPlanePersistence; | |
| 226 | − workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | |
| 227 | − organizationId: ReturnType<typeof OrganizationIdSchema.make>; | |
| 228 | − accountId: ReturnType<typeof AccountIdSchema.make>; | |
| 274 | +const configSourceFromSource = (source: Source): LocalConfigSource => { | |
| 275 | + switch (source.kind) { | |
| 276 | + case "openapi": | |
| 277 | + return { | |
| 278 | + kind: "openapi" as const, | |
| 279 | + ...(source.name !== source.id ? { name: source.name } : {}), | |
| 280 | + ...(source.namespace && source.namespace !== source.id | |
| 281 | + ? { namespace: source.namespace } | |
| 282 | + : {}), | |
| 283 | + ...(source.enabled ? {} : { enabled: false }), | |
| 284 | + connection: { | |
| 285 | + endpoint: source.endpoint, | |
| 286 | + }, | |
| 287 | + binding: { | |
| 288 | + specUrl: | |
| 289 | + typeof source.binding.specUrl === "string" | |
| 290 | + ? source.binding.specUrl | |
| 291 | + : "https://example.com/openapi.json", | |
| 292 | + defaultHeaders: | |
| 293 | + (source.binding.defaultHeaders as Record<string, string> | null | undefined) | |
| 294 | + ?? null, | |
| 295 | + }, | |
| 296 | + }; | |
| 297 | + case "graphql": | |
| 298 | + return { | |
| 299 | + kind: "graphql" as const, | |
| 300 | + ...(source.name !== source.id ? { name: source.name } : {}), | |
| 301 | + ...(source.namespace && source.namespace !== source.id | |
| 302 | + ? { namespace: source.namespace } | |
| 303 | + : {}), | |
| 304 | + ...(source.enabled ? {} : { enabled: false }), | |
| 305 | + connection: { | |
| 306 | + endpoint: source.endpoint, | |
| 307 | + }, | |
| 308 | + binding: { | |
| 309 | + defaultHeaders: | |
| 310 | + (source.binding.defaultHeaders as Record<string, string> | null | undefined) | |
| 311 | + ?? null, | |
| 312 | + }, | |
| 313 | + }; | |
| 314 | + case "mcp": | |
| 315 | + return { | |
| 316 | + kind: "mcp" as const, | |
| 317 | + ...(source.name !== source.id ? { name: source.name } : {}), | |
| 318 | + ...(source.namespace && source.namespace !== source.id | |
| 319 | + ? { namespace: source.namespace } | |
| 320 | + : {}), | |
| 321 | + ...(source.enabled ? {} : { enabled: false }), | |
| 322 | + connection: { | |
| 323 | + endpoint: source.endpoint, | |
| 324 | + }, | |
| 325 | + binding: { | |
| 326 | + transport: | |
| 327 | + (source.binding.transport as "auto" | "streamable-http" | "sse" | null | undefined) | |
| 328 | + ?? null, | |
| 329 | + queryParams: | |
| 330 | + (source.binding.queryParams as Record<string, string> | null | undefined) | |
| 331 | + ?? null, | |
| 332 | + headers: | |
| 333 | + (source.binding.headers as Record<string, string> | null | undefined) | |
| 334 | + ?? null, | |
| 335 | + }, | |
| 336 | + }; | |
| 337 | + default: | |
| 338 | + throw new Error(`Unsupported source kind in test fixture: ${source.kind}`); | |
| 339 | + } | |
| 340 | +}; | |
| 341 | + | |
| 342 | +const seedLocalWorkspaceSources = async (input: { | |
| 343 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState; | |
| 344 | + fixtures: ReadonlyArray<{ | |
| 345 | + source: Source; | |
| 346 | + materialization: { | |
| 347 | + manifestJson: string | null; | |
| 348 | + manifestHash: string | null; | |
| 349 | + sourceHash: string | null; | |
| 350 | + documents: ReadonlyArray<any>; | |
| 351 | + schemaBundles: ReadonlyArray<any>; | |
| 352 | + operations: ReadonlyArray<StoredSourceRecipeOperationRecord>; | |
| 353 | + }; | |
| 354 | + }>; | |
| 229 | 355 | }) => { |
| 230 | − const now = 1000; | |
| 231 | − await Effect.runPromise(input.persistence.rows.organizations.insert({ | |
| 232 | − id: input.organizationId, | |
| 233 | − slug: `org-${input.organizationId}`, | |
| 234 | − name: `Org ${input.organizationId}`, | |
| 235 | − status: "active", | |
| 236 | − createdByAccountId: input.accountId, | |
| 237 | − createdAt: now, | |
| 238 | − updatedAt: now, | |
| 356 | + await Effect.runPromise(writeProjectLocalExecutorConfig({ | |
| 357 | + context: input.runtimeLocalWorkspace.context, | |
| 358 | + config: { | |
| 359 | + sources: Object.fromEntries( | |
| 360 | + input.fixtures.map(({ source }) => [source.id, configSourceFromSource(source)]), | |
| 361 | + ), | |
| 362 | + }, | |
| 239 | 363 | })); |
| 240 | − await Effect.runPromise(input.persistence.rows.workspaces.insert({ | |
| 241 | − id: input.workspaceId, | |
| 242 | − organizationId: input.organizationId, | |
| 243 | − name: `Workspace ${input.workspaceId}`, | |
| 244 | − createdByAccountId: input.accountId, | |
| 245 | − createdAt: now, | |
| 246 | − updatedAt: now, | |
| 364 | + | |
| 365 | + await Promise.all( | |
| 366 | + input.fixtures.map(({ source, materialization }) => | |
| 367 | + Effect.runPromise(writeLocalSourceArtifact({ | |
| 368 | + context: input.runtimeLocalWorkspace.context, | |
| 369 | + sourceId: source.id, | |
| 370 | + artifact: buildLocalSourceArtifact({ | |
| 371 | + source, | |
| 372 | + materialization, | |
| 373 | + }), | |
| 374 | + })), | |
| 375 | + ), | |
| 376 | + ); | |
| 377 | + | |
| 378 | + await Effect.runPromise(writeLocalWorkspaceState({ | |
| 379 | + context: input.runtimeLocalWorkspace.context, | |
| 380 | + state: { | |
| 381 | + version: 1, | |
| 382 | + sources: Object.fromEntries( | |
| 383 | + input.fixtures.map(({ source }) => [ | |
| 384 | + source.id, | |
| 385 | + { | |
| 386 | + status: source.status, | |
| 387 | + lastError: source.lastError, | |
| 388 | + sourceHash: source.sourceHash, | |
| 389 | + createdAt: source.createdAt, | |
| 390 | + updatedAt: source.updatedAt, | |
| 391 | + }, | |
| 392 | + ]), | |
| 393 | + ), | |
| 394 | + policies: {}, | |
| 395 | + }, | |
| 247 | 396 | })); |
| 248 | 397 | }; |
| 249 | 398 | |
| @@ -432,10 +581,8 @@ describe("source-recipes-runtime", () => { · 182 unmodified lines | ||
| 432 | 581 | const persistence = await makePersistence(); |
| 433 | 582 | try { |
| 434 | 583 | const workspaceId = WorkspaceIdSchema.make("ws_shared_revision"); |
| 435 | − const organizationId = OrganizationIdSchema.make("org_shared_revision"); | |
| 436 | − const accountId = AccountIdSchema.make("acc_shared_revision"); | |
| 437 | − const recipeId = SourceRecipeIdSchema.make("src_recipe_shared_revision"); | |
| 438 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_shared_revision"); | |
| 584 | + const runtimeLocalWorkspace = | |
| 585 | + await makeRuntimeLocalWorkspaceState(workspaceId); | |
| 439 | 586 | const openApiDocument = JSON.stringify({ |
| 440 | 587 | openapi: "3.0.3", |
| 441 | 588 | info: { |
| @@ -475,172 +622,115 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines | ||
| 475 | 622 | definition, |
| 476 | 623 | }); |
| 477 | 624 | |
| 478 | − await seedWorkspace({ | |
| 479 | − persistence, | |
| 480 | − workspaceId, | |
| 481 | − organizationId, | |
| 482 | − accountId, | |
| 625 | + await seedLocalWorkspaceSources({ | |
| 626 | + runtimeLocalWorkspace, | |
| 627 | + fixtures: ["GitHub One", "GitHub Two"].map((name, index) => ({ | |
| 628 | + source: makeSource({ | |
| 629 | + id: SourceIdSchema.make(`src_shared_revision_${index}`), | |
| 630 | + workspaceId, | |
| 631 | + name, | |
| 632 | + sourceHash: manifest.sourceHash, | |
| 633 | + createdAt: 1000 + index, | |
| 634 | + updatedAt: 1000 + index, | |
| 635 | + }), | |
| 636 | + materialization: { | |
| 637 | + manifestJson: JSON.stringify(manifest), | |
| 638 | + manifestHash: manifest.sourceHash, | |
| 639 | + sourceHash: manifest.sourceHash, | |
| 640 | + documents: [{ | |
| 641 | + id: `src_recipe_doc_shared_revision_${index}`, | |
| 642 | + recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"), | |
| 643 | + documentKind: "openapi", | |
| 644 | + documentKey: "https://api.github.com/openapi.json", | |
| 645 | + contentText: openApiDocument, | |
| 646 | + contentHash: manifest.sourceHash, | |
| 647 | + fetchedAt: 1000, | |
| 648 | + createdAt: 1000, | |
| 649 | + updatedAt: 1000, | |
| 650 | + }], | |
| 651 | + schemaBundles: [], | |
| 652 | + operations: [makeOperation({ | |
| 653 | + id: `src_recipe_op_shared_revision_${index}`, | |
| 654 | + recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"), | |
| 655 | + operationKey: definition.toolId, | |
| 656 | + toolId: definition.toolId, | |
| 657 | + title: definition.name, | |
| 658 | + description: definition.description, | |
| 659 | + searchText: `${definition.toolId} ${definition.name}`.toLowerCase(), | |
| 660 | + inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 661 | + outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 662 | + providerDataJson: presentation.providerDataJson, | |
| 663 | + })], | |
| 664 | + }, | |
| 665 | + })), | |
| 483 | 666 | }); |
| 484 | − await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | |
| 485 | − id: recipeId, | |
| 486 | − kind: "http_api", | |
| 487 | − adapterKey: "openapi", | |
| 488 | − providerKey: "generic_http", | |
| 489 | − name: "GitHub", | |
| 490 | − summary: null, | |
| 491 | − visibility: "workspace", | |
| 492 | − latestRevisionId: recipeRevisionId, | |
| 493 | − createdAt: 1000, | |
| 494 | − updatedAt: 1000, | |
| 495 | − })); | |
| 496 | − await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({ | |
| 497 | − id: recipeRevisionId, | |
| 498 | − recipeId, | |
| 499 | − revisionNumber: 1, | |
| 500 | − sourceConfigJson: JSON.stringify({ | |
| 501 | − kind: "openapi", | |
| 502 | − endpoint: "https://api.github.com", | |
| 503 | − specUrl: "https://api.github.com/openapi.json", | |
| 504 | − defaultHeaders: null, | |
| 505 | − }), | |
| 506 | − manifestJson: JSON.stringify(manifest), | |
| 507 | − manifestHash: manifest.sourceHash, | |
| 508 | − materializationHash: manifest.sourceHash, | |
| 509 | − createdAt: 1000, | |
| 510 | − updatedAt: 1000, | |
| 511 | − })); | |
| 512 | − await Effect.runPromise(persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 513 | − recipeRevisionId, | |
| 514 | − documents: [{ | |
| 515 | − id: "src_recipe_doc_shared_revision", | |
| 516 | − recipeRevisionId, | |
| 517 | − documentKind: "openapi", | |
| 518 | − documentKey: "https://api.github.com/openapi.json", | |
| 519 | − contentText: openApiDocument, | |
| 520 | − contentHash: manifest.sourceHash, | |
| 521 | − fetchedAt: 1000, | |
| 522 | − createdAt: 1000, | |
| 523 | − updatedAt: 1000, | |
| 524 | − }], | |
| 525 | − })); | |
| 526 | − await Effect.runPromise(persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 527 | − recipeRevisionId, | |
| 528 | − operations: [makeOperation({ | |
| 529 | − id: "src_recipe_op_shared_revision", | |
| 530 | − recipeRevisionId, | |
| 531 | − operationKey: definition.toolId, | |
| 532 | − toolId: definition.toolId, | |
| 533 | − title: definition.name, | |
| 534 | − description: definition.description, | |
| 535 | − searchText: `${definition.toolId} ${definition.name}`.toLowerCase(), | |
| 536 | − inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 537 | − outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 538 | − providerDataJson: presentation.providerDataJson, | |
| 539 | − })], | |
| 540 | − })); | |
| 541 | 667 | |
| 542 | − for (const [index, name] of ["GitHub One", "GitHub Two"].entries()) { | |
| 543 | − await Effect.runPromise(persistence.rows.sources.insert({ | |
| 544 | − id: SourceIdSchema.make(`src_shared_revision_${index}`), | |
| 545 | − workspaceId, | |
| 546 | − recipeId, | |
| 547 | − recipeRevisionId, | |
| 548 | − name, | |
| 549 | − kind: "openapi", | |
| 550 | − endpoint: "https://api.github.com", | |
| 551 | − status: "connected", | |
| 552 | − enabled: true, | |
| 553 | − namespace: "github", | |
| 554 | − importAuthPolicy: "reuse_runtime", | |
| 555 | − bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 556 | − sourceHash: manifest.sourceHash, | |
| 557 | − lastError: null, | |
| 558 | − createdAt: 1000 + index, | |
| 559 | − updatedAt: 1000 + index, | |
| 560 | − })); | |
| 561 | − } | |
| 562 | − | |
| 563 | − const recipes = await Effect.runPromise(loadWorkspaceSourceRecipes({ | |
| 564 | − rows: persistence.rows, | |
| 565 | − workspaceId, | |
| 566 | − })); | |
| 668 | + const recipes = await Effect.runPromise( | |
| 669 | + withRuntimeLocalWorkspace( | |
| 670 | + loadWorkspaceSourceRecipes({ | |
| 671 | + rows: persistence.rows, | |
| 672 | + workspaceId, | |
| 673 | + }), | |
| 674 | + runtimeLocalWorkspace, | |
| 675 | + ), | |
| 676 | + ); | |
| 567 | 677 | |
| 568 | 678 | expect(recipes).toHaveLength(2); |
| 569 | 679 | expect(recipes[0]?.documents).toHaveLength(1); |
| 570 | 680 | expect(recipes[0]?.operations).toHaveLength(1); |
| 571 | − expect(recipes[0]?.documents).toBe(recipes[1]?.documents); | |
| 572 | − expect(recipes[0]?.operations).toBe(recipes[1]?.operations); | |
| 681 | + expect(recipes[0]?.documents[0]?.contentText).toBe( | |
| 682 | + recipes[1]?.documents[0]?.contentText, | |
| 683 | + ); | |
| 684 | + expect(recipes[0]?.operations[0]?.providerDataJson).toBe( | |
| 685 | + recipes[1]?.operations[0]?.providerDataJson, | |
| 686 | + ); | |
| 573 | 687 | } finally { |
| 574 | 688 | await persistence.close(); |
| 575 | 689 | } |
| 576 | − }); | |
| 690 | + }, 60_000); | |
| 577 | 691 | |
| 578 | 692 | it("loads sources with empty recipe documents and operations", async () => { |
| 579 | 693 | const persistence = await makePersistence(); |
| 580 | 694 | try { |
| 581 | 695 | const workspaceId = WorkspaceIdSchema.make("ws_empty_recipe_rows"); |
| 582 | − const organizationId = OrganizationIdSchema.make("org_empty_recipe_rows"); | |
| 583 | − const accountId = AccountIdSchema.make("acc_empty_recipe_rows"); | |
| 584 | 696 | const sourceId = SourceIdSchema.make("src_empty_recipe_rows"); |
| 585 | − const recipeId = SourceRecipeIdSchema.make("src_recipe_empty_recipe_rows"); | |
| 586 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_empty_recipe_rows"); | |
| 587 | − | |
| 588 | − await seedWorkspace({ | |
| 589 | − persistence, | |
| 590 | − workspaceId, | |
| 591 | − organizationId, | |
| 592 | − accountId, | |
| 697 | + const runtimeLocalWorkspace = | |
| 698 | + await makeRuntimeLocalWorkspaceState(workspaceId); | |
| 699 | + | |
| 700 | + await seedLocalWorkspaceSources({ | |
| 701 | + runtimeLocalWorkspace, | |
| 702 | + fixtures: [{ | |
| 703 | + source: makeSource({ | |
| 704 | + id: sourceId, | |
| 705 | + workspaceId, | |
| 706 | + name: "GraphQL Demo", | |
| 707 | + kind: "graphql", | |
| 708 | + endpoint: "https://example.com/graphql", | |
| 709 | + namespace: "graphql", | |
| 710 | + binding: { | |
| 711 | + defaultHeaders: null, | |
| 712 | + }, | |
| 713 | + }), | |
| 714 | + materialization: { | |
| 715 | + manifestJson: null, | |
| 716 | + manifestHash: null, | |
| 717 | + sourceHash: null, | |
| 718 | + documents: [], | |
| 719 | + schemaBundles: [], | |
| 720 | + operations: [], | |
| 721 | + }, | |
| 722 | + }], | |
| 593 | 723 | }); |
| 594 | − await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | |
| 595 | − id: recipeId, | |
| 596 | − kind: "http_api", | |
| 597 | − adapterKey: "graphql", | |
| 598 | − providerKey: "generic_graphql", | |
| 599 | − name: "GraphQL Demo", | |
| 600 | − summary: null, | |
| 601 | − visibility: "workspace", | |
| 602 | − latestRevisionId: recipeRevisionId, | |
| 603 | − createdAt: 1000, | |
| 604 | − updatedAt: 1000, | |
| 605 | − })); | |
| 606 | − await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({ | |
| 607 | − id: recipeRevisionId, | |
| 608 | − recipeId, | |
| 609 | − revisionNumber: 1, | |
| 610 | − sourceConfigJson: JSON.stringify({ | |
| 611 | − kind: "graphql", | |
| 612 | − endpoint: "https://example.com/graphql", | |
| 613 | − defaultHeaders: null, | |
| 614 | − }), | |
| 615 | − manifestJson: null, | |
| 616 | − manifestHash: null, | |
| 617 | − materializationHash: null, | |
| 618 | − createdAt: 1000, | |
| 619 | − updatedAt: 1000, | |
| 620 | − })); | |
| 621 | − await Effect.runPromise(persistence.rows.sources.insert({ | |
| 622 | − id: sourceId, | |
| 623 | − workspaceId, | |
| 624 | − recipeId, | |
| 625 | − recipeRevisionId, | |
| 626 | − name: "GraphQL Demo", | |
| 627 | − kind: "graphql", | |
| 628 | − endpoint: "https://example.com/graphql", | |
| 629 | − status: "connected", | |
| 630 | − enabled: true, | |
| 631 | − namespace: "graphql", | |
| 632 | − importAuthPolicy: "reuse_runtime", | |
| 633 | − bindingConfigJson: graphqlBindingConfigJson(), | |
| 634 | − sourceHash: null, | |
| 635 | − lastError: null, | |
| 636 | − createdAt: 1000, | |
| 637 | − updatedAt: 1000, | |
| 638 | − })); | |
| 639 | 724 | |
| 640 | − const recipes = await Effect.runPromise(loadWorkspaceSourceRecipes({ | |
| 641 | − rows: persistence.rows, | |
| 642 | − workspaceId, | |
| 643 | − })); | |
| 725 | + const recipes = await Effect.runPromise( | |
| 726 | + withRuntimeLocalWorkspace( | |
| 727 | + loadWorkspaceSourceRecipes({ | |
| 728 | + rows: persistence.rows, | |
| 729 | + workspaceId, | |
| 730 | + }), | |
| 731 | + runtimeLocalWorkspace, | |
| 732 | + ), | |
| 733 | + ); | |
| 644 | 734 | |
| 645 | 735 | expect(recipes).toHaveLength(1); |
| 646 | 736 | expect(recipes[0]?.documents).toEqual([]); |
| @@ -649,89 +739,117 @@ describe("source-recipes-runtime", () => { · 2 unmodified lines | ||
| 649 | 739 | } finally { |
| 650 | 740 | await persistence.close(); |
| 651 | 741 | } |
| 652 | − }); | |
| 742 | + }, 60_000); | |
| 653 | 743 | |
| 654 | 744 | it("fails clearly when loading a missing source, missing revision, or invalid manifest", async () => { |
| 655 | 745 | const persistence = await makePersistence(); |
| 656 | 746 | try { |
| 657 | − await expect(Effect.runPromise(loadSourceWithRecipe({ | |
| 658 | − rows: persistence.rows, | |
| 659 | − workspaceId: WorkspaceIdSchema.make("ws_missing_source"), | |
| 660 | − sourceId: SourceIdSchema.make("src_missing_source"), | |
| 661 | − }))).rejects.toThrow("Source not found"); | |
| 747 | + const missingWorkspaceId = WorkspaceIdSchema.make("ws_missing_source"); | |
| 748 | + const missingRuntimeLocalWorkspace = | |
| 749 | + await makeRuntimeLocalWorkspaceState(missingWorkspaceId); | |
| 750 | + | |
| 751 | + await expect( | |
| 752 | + Effect.runPromise( | |
| 753 | + withRuntimeLocalWorkspace( | |
| 754 | + loadSourceWithRecipe({ | |
| 755 | + rows: persistence.rows, | |
| 756 | + workspaceId: missingWorkspaceId, | |
| 757 | + sourceId: SourceIdSchema.make("src_missing_source"), | |
| 758 | + }), | |
| 759 | + missingRuntimeLocalWorkspace, | |
| 760 | + ), | |
| 761 | + ), | |
| 762 | + ).rejects.toThrow("Source not found"); | |
| 662 | 763 | |
| 663 | 764 | const workspaceId = WorkspaceIdSchema.make("ws_bad_recipe_runtime"); |
| 664 | − const organizationId = OrganizationIdSchema.make("org_bad_recipe_runtime"); | |
| 665 | − const accountId = AccountIdSchema.make("acc_bad_recipe_runtime"); | |
| 666 | 765 | const sourceId = SourceIdSchema.make("src_bad_recipe_runtime"); |
| 667 | − const recipeId = SourceRecipeIdSchema.make("src_recipe_bad_recipe_runtime"); | |
| 668 | − const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bad_recipe_runtime"); | |
| 669 | − | |
| 670 | − await seedWorkspace({ | |
| 671 | − persistence, | |
| 672 | − workspaceId, | |
| 673 | − organizationId, | |
| 674 | − accountId, | |
| 675 | − }); | |
| 676 | − await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | |
| 677 | − id: recipeId, | |
| 678 | − kind: "http_api", | |
| 679 | − adapterKey: "openapi", | |
| 680 | − providerKey: "generic_http", | |
| 681 | − name: "Broken GitHub", | |
| 682 | − summary: null, | |
| 683 | − visibility: "workspace", | |
| 684 | − latestRevisionId: recipeRevisionId, | |
| 685 | − createdAt: 1000, | |
| 686 | − updatedAt: 1000, | |
| 766 | + const runtimeLocalWorkspace = | |
| 767 | + await makeRuntimeLocalWorkspaceState(workspaceId); | |
| 768 | + | |
| 769 | + await Effect.runPromise(writeProjectLocalExecutorConfig({ | |
| 770 | + context: runtimeLocalWorkspace.context, | |
| 771 | + config: { | |
| 772 | + sources: { | |
| 773 | + [sourceId]: configSourceFromSource( | |
| 774 | + makeSource({ | |
| 775 | + id: sourceId, | |
| 776 | + workspaceId, | |
| 777 | + name: "Broken GitHub", | |
| 778 | + }), | |
| 779 | + ), | |
| 780 | + }, | |
| 781 | + }, | |
| 687 | 782 | })); |
| 688 | − await Effect.runPromise(persistence.rows.sources.insert({ | |
| 689 | − id: sourceId, | |
| 690 | − workspaceId, | |
| 691 | − recipeId, | |
| 692 | − recipeRevisionId, | |
| 693 | − name: "Broken GitHub", | |
| 694 | − kind: "openapi", | |
| 695 | − endpoint: "https://api.github.com", | |
| 696 | − status: "connected", | |
| 697 | − enabled: true, | |
| 698 | − namespace: "github", | |
| 699 | − importAuthPolicy: "reuse_runtime", | |
| 700 | − bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 701 | − sourceHash: null, | |
| 702 | − lastError: null, | |
| 703 | − createdAt: 1000, | |
| 704 | − updatedAt: 1000, | |
| 783 | + await Effect.runPromise(writeLocalWorkspaceState({ | |
| 784 | + context: runtimeLocalWorkspace.context, | |
| 785 | + state: { | |
| 786 | + version: 1, | |
| 787 | + sources: { | |
| 788 | + [sourceId]: { | |
| 789 | + status: "connected", | |
| 790 | + lastError: null, | |
| 791 | + sourceHash: null, | |
| 792 | + createdAt: 1000, | |
| 793 | + updatedAt: 1000, | |
| 794 | + }, | |
| 795 | + }, | |
| 796 | + policies: {}, | |
| 797 | + }, | |
| 705 | 798 | })); |
| 706 | 799 | |
| 707 | − await expect(Effect.runPromise(loadSourceWithRecipe({ | |
| 708 | − rows: persistence.rows, | |
| 800 | + await expect( | |
| 801 | + Effect.runPromise( | |
| 802 | + withRuntimeLocalWorkspace( | |
| 803 | + loadSourceWithRecipe({ | |
| 804 | + rows: persistence.rows, | |
| 805 | + workspaceId, | |
| 806 | + sourceId, | |
| 807 | + }), | |
| 808 | + runtimeLocalWorkspace, | |
| 809 | + ), | |
| 810 | + ), | |
| 811 | + ).rejects.toThrow("Recipe artifact missing"); | |
| 812 | + | |
| 813 | + const invalidManifestSource = makeSource({ | |
| 814 | + id: sourceId, | |
| 709 | 815 | workspaceId, |
| 816 | + name: "Broken GitHub", | |
| 817 | + }); | |
| 818 | + const invalidArtifact = buildLocalSourceArtifact({ | |
| 819 | + source: invalidManifestSource, | |
| 820 | + materialization: { | |
| 821 | + manifestJson: "{}", | |
| 822 | + manifestHash: "manifest_hash_invalid", | |
| 823 | + sourceHash: "manifest_hash_invalid", | |
| 824 | + documents: [], | |
| 825 | + schemaBundles: [], | |
| 826 | + operations: [], | |
| 827 | + }, | |
| 828 | + }); | |
| 829 | + await Effect.runPromise(writeLocalSourceArtifact({ | |
| 830 | + context: runtimeLocalWorkspace.context, | |
| 710 | 831 | sourceId, |
| 711 | − }))).rejects.toThrow("Recipe revision missing"); | |
| 712 | − | |
| 713 | − await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({ | |
| 714 | − id: recipeRevisionId, | |
| 715 | − recipeId, | |
| 716 | − revisionNumber: 1, | |
| 717 | − sourceConfigJson: JSON.stringify({ | |
| 718 | − kind: "openapi", | |
| 719 | − endpoint: "https://api.github.com", | |
| 720 | − specUrl: "https://api.github.com/openapi.json", | |
| 721 | − defaultHeaders: null, | |
| 722 | − }), | |
| 723 | − manifestJson: "{bad-json", | |
| 724 | − manifestHash: null, | |
| 725 | − materializationHash: null, | |
| 726 | − createdAt: 1000, | |
| 727 | − updatedAt: 1000, | |
| 832 | + artifact: { | |
| 833 | + ...invalidArtifact, | |
| 834 | + revision: { | |
| 835 | + ...invalidArtifact.revision, | |
| 836 | + manifestJson: "{bad-json", | |
| 837 | + }, | |
| 838 | + }, | |
| 728 | 839 | })); |
| 729 | 840 | |
| 730 | − await expect(Effect.runPromise(loadSourceWithRecipe({ | |
| 731 | − rows: persistence.rows, | |
| 732 | − workspaceId, | |
| 733 | − sourceId, | |
| 734 | − }))).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`); | |
| 841 | + await expect( | |
| 842 | + Effect.runPromise( | |
| 843 | + withRuntimeLocalWorkspace( | |
| 844 | + loadSourceWithRecipe({ | |
| 845 | + rows: persistence.rows, | |
| 846 | + workspaceId, | |
| 847 | + sourceId, | |
| 848 | + }), | |
| 849 | + runtimeLocalWorkspace, | |
| 850 | + ), | |
| 851 | + ), | |
| 852 | + ).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`); | |
| 735 | 853 | } finally { |
| 736 | 854 | await persistence.close(); |
| 737 | 855 | } |
| @@ -13,8 +13,14 @@ import type { · 12 unmodified lines | ||
| 13 | 13 | WorkspaceId, |
| 14 | 14 | } from "#schema"; |
| 15 | 15 | import * as Effect from "effect/Effect"; |
| 16 | −import * as Option from "effect/Option"; | |
| 17 | 16 | |
| 17 | +import { | |
| 18 | + readLocalSourceArtifact, | |
| 19 | +} from "./local-source-artifacts"; | |
| 20 | +import { LocalSourceArtifactMissingError } from "./local-errors"; | |
| 21 | +import { | |
| 22 | + requireRuntimeLocalWorkspace, | |
| 23 | +} from "./local-runtime-context"; | |
| 18 | 24 | import { namespaceFromSourceName } from "./source-names"; |
| 19 | 25 | import { |
| 20 | 26 | getSourceAdapterForOperation, |
| @@ -156,71 +162,56 @@ export const loadWorkspaceSourceRecipes = (input: { · 135 unmodified lines | ||
| 156 | 162 | actorAccountId?: AccountId | null; |
| 157 | 163 | }): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> => |
| 158 | 164 | Effect.gen(function* () { |
| 159 | − const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | |
| 165 | + const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId); | |
| 160 | 166 | const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { |
| 161 | 167 | actorAccountId: input.actorAccountId, |
| 162 | 168 | }); |
| 163 | − | |
| 164 | − const sourceById = new Map(sources.map((source) => [source.id, source])); | |
| 165 | − const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | |
| 166 | − const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))]; | |
| 167 | − | |
| 168 | − const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds); | |
| 169 | − const revisionById = new Map(revisions.map((revision) => [revision.id, revision])); | |
| 170 | − const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds); | |
| 171 | − const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds); | |
| 172 | − const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>(); | |
| 173 | − for (const document of documents) { | |
| 174 | − const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? []; | |
| 175 | − existing.push(document); | |
| 176 | − documentsByRevisionId.set(document.recipeRevisionId, existing); | |
| 177 | − } | |
| 178 | − const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | |
| 179 | − for (const schemaBundle of schemaBundles) { | |
| 180 | − const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | |
| 181 | − existing.push(schemaBundle); | |
| 182 | − schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | |
| 183 | − } | |
| 184 | − const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds); | |
| 185 | − const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); | |
| 186 | − for (const operation of operations) { | |
| 187 | − const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? []; | |
| 188 | − existing.push(operation); | |
| 189 | − operationsByRevisionId.set(operation.recipeRevisionId, existing); | |
| 190 | − } | |
| 191 | − | |
| 192 | − return yield* Effect.forEach(relevantSourceRecords, (sourceRecord) => | |
| 169 | + const localRecipes = yield* Effect.forEach(sources, (source) => | |
| 193 | 170 | Effect.gen(function* () { |
| 194 | − const source = sourceById.get(sourceRecord.id); | |
| 195 | − if (!source) { | |
| 196 | − return yield* Effect.fail( | |
| 197 | − new Error(`Projected source missing for ${sourceRecord.id}`), | |
| 198 | − ); | |
| 171 | + const artifact = yield* readLocalSourceArtifact({ | |
| 172 | + context: runtimeLocalWorkspace.context, | |
| 173 | + sourceId: source.id, | |
| 174 | + }); | |
| 175 | + if (artifact === null) { | |
| 176 | + return null; | |
| 199 | 177 | } |
| 200 | 178 | |
| 201 | − const revision = revisionById.get(sourceRecord.recipeRevisionId); | |
| 202 | − if (!revision) { | |
| 203 | − return yield* Effect.fail( | |
| 204 | − new Error(`Recipe revision missing for source ${sourceRecord.id}`), | |
| 205 | − ); | |
| 206 | − } | |
| 179 | + const sourceRecord: StoredSourceRecord = { | |
| 180 | + id: source.id, | |
| 181 | + workspaceId: source.workspaceId, | |
| 182 | + recipeId: artifact.recipeId, | |
| 183 | + recipeRevisionId: artifact.revision.id, | |
| 184 | + name: source.name, | |
| 185 | + kind: source.kind, | |
| 186 | + endpoint: source.endpoint, | |
| 187 | + status: source.status, | |
| 188 | + enabled: source.enabled, | |
| 189 | + namespace: source.namespace, | |
| 190 | + importAuthPolicy: source.importAuthPolicy, | |
| 191 | + bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source), | |
| 192 | + sourceHash: source.sourceHash, | |
| 193 | + lastError: source.lastError, | |
| 194 | + createdAt: source.createdAt, | |
| 195 | + updatedAt: source.updatedAt, | |
| 196 | + }; | |
| 207 | 197 | |
| 208 | 198 | const manifest = yield* parseManifestForRecipe({ |
| 209 | 199 | source, |
| 210 | − revision, | |
| 200 | + revision: artifact.revision, | |
| 211 | 201 | }); |
| 212 | 202 | |
| 213 | 203 | return { |
| 214 | 204 | source, |
| 215 | 205 | sourceRecord, |
| 216 | − revision, | |
| 217 | − documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 218 | − schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 219 | − operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 206 | + revision: artifact.revision, | |
| 207 | + documents: artifact.documents, | |
| 208 | + schemaBundles: artifact.schemaBundles, | |
| 209 | + operations: artifact.operations, | |
| 220 | 210 | manifest, |
| 221 | 211 | } satisfies LoadedSourceRecipe; |
| 222 | 212 | }), |
| 223 | 213 | ); |
| 214 | + return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null); | |
| 224 | 215 | }); |
| 225 | 216 | |
| 226 | 217 | export const loadSourceWithRecipe = (input: { |
| @@ -228,46 +219,57 @@ export const loadSourceWithRecipe = (input: { · 1 unmodified line | ||
| 228 | 219 | workspaceId: WorkspaceId; |
| 229 | 220 | sourceId: Source["id"]; |
| 230 | 221 | actorAccountId?: AccountId | null; |
| 231 | −}): Effect.Effect<LoadedSourceRecipe, Error, never> => | |
| 222 | +}): Effect.Effect<LoadedSourceRecipe, Error | LocalSourceArtifactMissingError, never> => | |
| 232 | 223 | Effect.gen(function* () { |
| 233 | − const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | |
| 234 | − input.workspaceId, | |
| 235 | − input.sourceId, | |
| 236 | − ); | |
| 237 | − if (Option.isNone(sourceRecord)) { | |
| 238 | − return yield* Effect.fail( | |
| 239 | − new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`), | |
| 240 | − ); | |
| 241 | − } | |
| 242 | − | |
| 224 | + const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId); | |
| 243 | 225 | const source = yield* loadSourceById(input.rows, { |
| 244 | 226 | workspaceId: input.workspaceId, |
| 245 | 227 | sourceId: input.sourceId, |
| 246 | 228 | actorAccountId: input.actorAccountId, |
| 247 | 229 | }); |
| 248 | − const revision = yield* input.rows.sourceRecipeRevisions.getById(sourceRecord.value.recipeRevisionId); | |
| 249 | − if (Option.isNone(revision)) { | |
| 230 | + const artifact = yield* readLocalSourceArtifact({ | |
| 231 | + context: runtimeLocalWorkspace.context, | |
| 232 | + sourceId: source.id, | |
| 233 | + }); | |
| 234 | + if (artifact === null) { | |
| 250 | 235 | return yield* Effect.fail( |
| 251 | − new Error(`Recipe revision missing for source ${input.sourceId}`), | |
| 236 | + new LocalSourceArtifactMissingError({ | |
| 237 | + message: `Recipe artifact missing for source ${input.sourceId}`, | |
| 238 | + sourceId: input.sourceId, | |
| 239 | + }), | |
| 252 | 240 | ); |
| 253 | 241 | } |
| 254 | − const [documents, schemaBundles, operations, manifest] = yield* Effect.all([ | |
| 255 | − input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 256 | − input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 257 | − input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 258 | − parseManifestForRecipe({ | |
| 259 | − source, | |
| 260 | − revision: revision.value, | |
| 261 | − }), | |
| 262 | − ]); | |
| 242 | + | |
| 243 | + const sourceRecord: StoredSourceRecord = { | |
| 244 | + id: source.id, | |
| 245 | + workspaceId: source.workspaceId, | |
| 246 | + recipeId: artifact.recipeId, | |
| 247 | + recipeRevisionId: artifact.revision.id, | |
| 248 | + name: source.name, | |
| 249 | + kind: source.kind, | |
| 250 | + endpoint: source.endpoint, | |
| 251 | + status: source.status, | |
| 252 | + enabled: source.enabled, | |
| 253 | + namespace: source.namespace, | |
| 254 | + importAuthPolicy: source.importAuthPolicy, | |
| 255 | + bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source), | |
| 256 | + sourceHash: source.sourceHash, | |
| 257 | + lastError: source.lastError, | |
| 258 | + createdAt: source.createdAt, | |
| 259 | + updatedAt: source.updatedAt, | |
| 260 | + }; | |
| 261 | + const manifest = yield* parseManifestForRecipe({ | |
| 262 | + source, | |
| 263 | + revision: artifact.revision, | |
| 264 | + }); | |
| 263 | 265 | |
| 264 | 266 | return { |
| 265 | 267 | source, |
| 266 | − sourceRecord: sourceRecord.value, | |
| 267 | − revision: revision.value, | |
| 268 | − documents, | |
| 269 | − schemaBundles, | |
| 270 | − operations, | |
| 268 | + sourceRecord, | |
| 269 | + revision: artifact.revision, | |
| 270 | + documents: artifact.documents, | |
| 271 | + schemaBundles: artifact.schemaBundles, | |
| 272 | + operations: artifact.operations, | |
| 271 | 273 | manifest, |
| 272 | 274 | } satisfies LoadedSourceRecipe; |
| 273 | 275 | }); |
| @@ -339,94 +341,26 @@ export const loadWorkspaceSourceRecipeToolIndex = (input: { · 65 unmodified lines | ||
| 339 | 341 | includeSchemas: boolean; |
| 340 | 342 | }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => |
| 341 | 343 | Effect.gen(function* () { |
| 342 | − const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | |
| 343 | − const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | |
| 344 | + const recipes = yield* loadWorkspaceSourceRecipes({ | |
| 345 | + rows: input.rows, | |
| 346 | + workspaceId: input.workspaceId, | |
| 344 | 347 | actorAccountId: input.actorAccountId, |
| 345 | 348 | }); |
| 346 | − const sourceById = new Map(sources.map((source) => [source.id, source])); | |
| 347 | − const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | |
| 348 | − const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))]; | |
| 349 | − const [operations, schemaBundles] = yield* Effect.all([ | |
| 350 | − input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds), | |
| 351 | − input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds), | |
| 352 | − ]); | |
| 353 | − | |
| 354 | − const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); | |
| 355 | − for (const operation of operations) { | |
| 356 | − const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? []; | |
| 357 | − existing.push(operation); | |
| 358 | − operationsByRevisionId.set(operation.recipeRevisionId, existing); | |
| 359 | − } | |
| 360 | − | |
| 361 | − const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | |
| 362 | − for (const schemaBundle of schemaBundles) { | |
| 363 | − const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | |
| 364 | − existing.push(schemaBundle); | |
| 365 | − schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | |
| 366 | − } | |
| 367 | − | |
| 368 | − const entryGroups = yield* Effect.forEach( | |
| 369 | − relevantSourceRecords, | |
| 370 | − (sourceRecord) => { | |
| 371 | − const source = sourceById.get(sourceRecord.id); | |
| 372 | − if (!source) { | |
| 373 | − return Effect.succeed<readonly LoadedSourceRecipeToolIndexEntry[]>([]); | |
| 374 | − } | |
| 375 | − | |
| 376 | − return Effect.forEach( | |
| 377 | − operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 378 | − (operation) => | |
| 379 | − Effect.gen(function* () { | |
| 380 | − const path = recipeToolPath({ | |
| 381 | − source, | |
| 382 | − operation, | |
| 383 | − }); | |
| 384 | − const searchNamespace = recipeToolSearchNamespace({ | |
| 385 | − source, | |
| 386 | − path, | |
| 387 | − operation, | |
| 388 | − }); | |
| 389 | − const schemaBundleId = primarySchemaBundleForRevision({ | |
| 390 | − source, | |
| 391 | − schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 392 | − })?.id ?? null; | |
| 393 | − const metadata = yield* recipeToolMetadata({ | |
| 394 | − source, | |
| 395 | − operation, | |
| 396 | − path, | |
| 397 | − }); | |
| 398 | − | |
| 399 | − return { | |
| 400 | − path, | |
| 401 | − searchNamespace, | |
| 402 | − searchText: [ | |
| 403 | − path, | |
| 404 | − searchNamespace, | |
| 405 | − source.name, | |
| 406 | − metadata.searchText, | |
| 407 | − ] | |
| 408 | − .filter((part) => part.length > 0) | |
| 409 | − .join(" ") | |
| 410 | − .toLowerCase(), | |
| 411 | − source, | |
| 412 | − sourceRecord, | |
| 413 | − operation, | |
| 414 | − metadata, | |
| 415 | − schemaBundleId, | |
| 416 | − descriptor: recipeToolDescriptor({ | |
| 417 | − source, | |
| 418 | − operation, | |
| 419 | − path, | |
| 420 | − schemaBundleId, | |
| 421 | − includeSchemas: input.includeSchemas, | |
| 422 | − }), | |
| 423 | − } satisfies LoadedSourceRecipeToolIndexEntry; | |
| 424 | − }), | |
| 425 | − ); | |
| 426 | − }, | |
| 427 | − ); | |
| 428 | − | |
| 429 | − return entryGroups.flat(); | |
| 349 | + const tools = yield* expandRecipeTools({ | |
| 350 | + recipes, | |
| 351 | + includeSchemas: input.includeSchemas, | |
| 352 | + }); | |
| 353 | + return tools.map((tool) => ({ | |
| 354 | + path: tool.path, | |
| 355 | + searchNamespace: tool.searchNamespace, | |
| 356 | + searchText: tool.searchText, | |
| 357 | + source: tool.source, | |
| 358 | + sourceRecord: tool.sourceRecord, | |
| 359 | + operation: tool.operation, | |
| 360 | + metadata: tool.metadata, | |
| 361 | + schemaBundleId: tool.schemaBundleId, | |
| 362 | + descriptor: tool.descriptor, | |
| 363 | + })); | |
| 430 | 364 | }); |
| 431 | 365 | |
| 432 | 366 | export const loadWorkspaceSourceRecipeToolByPath = (input: { |
| @@ -437,110 +371,27 @@ export const loadWorkspaceSourceRecipeToolByPath = (input: { · 4 unmodified lines | ||
| 437 | 371 | includeSchemas: boolean; |
| 438 | 372 | }): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> => |
| 439 | 373 | Effect.gen(function* () { |
| 440 | − const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | |
| 441 | − const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | |
| 374 | + const recipes = yield* loadWorkspaceSourceRecipes({ | |
| 375 | + rows: input.rows, | |
| 376 | + workspaceId: input.workspaceId, | |
| 442 | 377 | actorAccountId: input.actorAccountId, |
| 443 | 378 | }); |
| 444 | − const sourceById = new Map(sources.map((source) => [source.id, source])); | |
| 445 | − const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | |
| 446 | − | |
| 447 | − const candidates = relevantSourceRecords.flatMap((sourceRecord) => { | |
| 448 | − const source = sourceById.get(sourceRecord.id); | |
| 449 | − if (!source) { | |
| 450 | − return []; | |
| 451 | − } | |
| 452 | − | |
| 453 | − const namespace = source.namespace ?? namespaceFromSourceName(source.name); | |
| 454 | − if (namespace.length > 0) { | |
| 455 | − if (!input.path.startsWith(`${namespace}.`)) { | |
| 456 | − return []; | |
| 457 | − } | |
| 458 | − | |
| 459 | − return [{ | |
| 460 | − source, | |
| 461 | − sourceRecord, | |
| 462 | − toolId: input.path.slice(namespace.length + 1), | |
| 463 | − }]; | |
| 464 | − } | |
| 465 | − | |
| 466 | − return [{ | |
| 467 | − source, | |
| 468 | − sourceRecord, | |
| 469 | − toolId: input.path, | |
| 470 | − }]; | |
| 379 | + const tools = yield* expandRecipeTools({ | |
| 380 | + recipes, | |
| 381 | + includeSchemas: input.includeSchemas, | |
| 471 | 382 | }); |
| 472 | − | |
| 473 | − if (candidates.length === 0) { | |
| 474 | − return null; | |
| 475 | − } | |
| 476 | − | |
| 477 | − const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))]; | |
| 478 | − const [operations, schemaBundles] = yield* Effect.all([ | |
| 479 | − input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds), | |
| 480 | − input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds), | |
| 481 | − ]); | |
| 482 | − const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | |
| 483 | − for (const schemaBundle of schemaBundles) { | |
| 484 | − const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | |
| 485 | − existing.push(schemaBundle); | |
| 486 | − schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | |
| 487 | − } | |
| 488 | − | |
| 489 | − for (const candidate of candidates) { | |
| 490 | − const operation = operations.find((entry) => | |
| 491 | − entry.recipeRevisionId === candidate.sourceRecord.recipeRevisionId | |
| 492 | − && entry.toolId === candidate.toolId | |
| 493 | − ); | |
| 494 | − if (!operation) { | |
| 495 | − continue; | |
| 496 | − } | |
| 497 | − | |
| 498 | − const path = recipeToolPath({ | |
| 499 | − source: candidate.source, | |
| 500 | − operation, | |
| 501 | − }); | |
| 502 | − const searchNamespace = recipeToolSearchNamespace({ | |
| 503 | − source: candidate.source, | |
| 504 | − path, | |
| 505 | − operation, | |
| 506 | − }); | |
| 507 | − const schemaBundleId = primarySchemaBundleForRevision({ | |
| 508 | − source: candidate.source, | |
| 509 | − schemaBundles: | |
| 510 | − schemaBundlesByRevisionId.get(candidate.sourceRecord.recipeRevisionId) ?? [], | |
| 511 | − })?.id ?? null; | |
| 512 | − const metadata = yield* recipeToolMetadata({ | |
| 513 | − source: candidate.source, | |
| 514 | − operation, | |
| 515 | − path, | |
| 516 | − }); | |
| 517 | − | |
| 518 | − return { | |
| 519 | − path, | |
| 520 | − searchNamespace, | |
| 521 | − searchText: [ | |
| 522 | − path, | |
| 523 | − searchNamespace, | |
| 524 | − candidate.source.name, | |
| 525 | − metadata.searchText, | |
| 526 | − ] | |
| 527 | − .filter((part) => part.length > 0) | |
| 528 | − .join(" ") | |
| 529 | − .toLowerCase(), | |
| 530 | − source: candidate.source, | |
| 531 | − sourceRecord: candidate.sourceRecord, | |
| 532 | − operation, | |
| 533 | − metadata, | |
| 534 | − schemaBundleId, | |
| 535 | − descriptor: recipeToolDescriptor({ | |
| 536 | − source: candidate.source, | |
| 537 | − operation, | |
| 538 | − path, | |
| 539 | − schemaBundleId, | |
| 540 | − includeSchemas: input.includeSchemas, | |
| 541 | − }), | |
| 542 | − } satisfies LoadedSourceRecipeToolIndexEntry; | |
| 543 | − } | |
| 544 | − | |
| 545 | − return null; | |
| 383 | + const tool = tools.find((entry) => entry.path === input.path) ?? null; | |
| 384 | + return tool | |
| 385 | + ? { | |
| 386 | + path: tool.path, | |
| 387 | + searchNamespace: tool.searchNamespace, | |
| 388 | + searchText: tool.searchText, | |
| 389 | + source: tool.source, | |
| 390 | + sourceRecord: tool.sourceRecord, | |
| 391 | + operation: tool.operation, | |
| 392 | + metadata: tool.metadata, | |
| 393 | + schemaBundleId: tool.schemaBundleId, | |
| 394 | + descriptor: tool.descriptor, | |
| 395 | + } | |
| 396 | + : null; | |
| 546 | 397 | }); |
| @@ -1,10 +1,13 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 1 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 6 | |
| 3 | 7 | import { |
| 4 | 8 | AccountIdSchema, |
| 5 | 9 | decodeBuiltInAuthArtifactConfig, |
| 6 | 10 | McpSourceAuthSessionDataJsonSchema, |
| 7 | − OrganizationIdSchema, | |
| 8 | 11 | SecretMaterialIdSchema, |
| 9 | 12 | SourceAuthSessionIdSchema, |
| 10 | 13 | SourceIdSchema, |
| @@ -21,9 +24,16 @@ import { · 10 unmodified lines | ||
| 21 | 24 | type SqlControlPlanePersistence, |
| 22 | 25 | } from "../persistence"; |
| 23 | 26 | import { |
| 24 | − stableSourceRecipeId, | |
| 25 | − stableSourceRecipeRevisionId, | |
| 26 | −} from "./source-definitions"; | |
| 27 | + loadLocalExecutorConfig, | |
| 28 | + resolveLocalWorkspaceContext, | |
| 29 | +} from "./local-config"; | |
| 30 | +import { | |
| 31 | + RuntimeLocalWorkspaceService, | |
| 32 | + type RuntimeLocalWorkspaceState, | |
| 33 | +} from "./local-runtime-context"; | |
| 34 | +import { | |
| 35 | + loadLocalWorkspaceState, | |
| 36 | +} from "./local-workspace-state"; | |
| 27 | 37 | import { persistSource, removeSourceById } from "./source-store"; |
| 28 | 38 | |
| 29 | 39 | const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = |
| @@ -40,6 +50,33 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 10 unmodified lines | ||
| 40 | 50 | |
| 41 | 51 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 42 | 52 | |
| 53 | +const makeRuntimeLocalWorkspace = (input: { | |
| 54 | + workspaceId: Source["workspaceId"]; | |
| 55 | + accountId: ReturnType<typeof AccountIdSchema.make>; | |
| 56 | +}) => | |
| 57 | + Effect.gen(function* () { | |
| 58 | + const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-source-store-")); | |
| 59 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 60 | + const loadedConfig = yield* loadLocalExecutorConfig(context); | |
| 61 | + | |
| 62 | + return { | |
| 63 | + context, | |
| 64 | + installation: { | |
| 65 | + workspaceId: input.workspaceId, | |
| 66 | + accountId: input.accountId, | |
| 67 | + }, | |
| 68 | + loadedConfig, | |
| 69 | + } satisfies RuntimeLocalWorkspaceState; | |
| 70 | + }); | |
| 71 | + | |
| 72 | +const withRuntimeLocalWorkspace = <A, E>( | |
| 73 | + effect: Effect.Effect<A, E, never>, | |
| 74 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | |
| 75 | +) => | |
| 76 | + effect.pipe( | |
| 77 | + Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | |
| 78 | + ); | |
| 79 | + | |
| 43 | 80 | const makeOpenApiSource = (input: { |
| 44 | 81 | workspaceId: Source["workspaceId"]; |
| 45 | 82 | sourceId: Source["id"]; |
| @@ -78,28 +115,13 @@ describe("source-store", () => { · 32 unmodified lines | ||
| 78 | 115 | const persistence = yield* makePersistence; |
| 79 | 116 | const now = Date.now(); |
| 80 | 117 | const accountId = AccountIdSchema.make("acc_source_store"); |
| 81 | − const organizationId = OrganizationIdSchema.make("org_source_store"); | |
| 82 | 118 | const workspaceId = WorkspaceIdSchema.make("ws_source_store"); |
| 83 | − const sourceId = SourceIdSchema.make("src_source_store"); | |
| 119 | + const sourceId = SourceIdSchema.make("github"); | |
| 84 | 120 | const firstTokenId = SecretMaterialIdSchema.make("sec_source_store_first"); |
| 85 | 121 | const secondTokenId = SecretMaterialIdSchema.make("sec_source_store_second"); |
| 86 | − | |
| 87 | − yield* persistence.rows.organizations.insert({ | |
| 88 | − id: organizationId, | |
| 89 | − slug: "source-store", | |
| 90 | − name: "Source Store", | |
| 91 | − status: "active", | |
| 92 | − createdByAccountId: accountId, | |
| 93 | − createdAt: now, | |
| 94 | − updatedAt: now, | |
| 95 | − }); | |
| 96 | − yield* persistence.rows.workspaces.insert({ | |
| 97 | − id: workspaceId, | |
| 98 | − organizationId, | |
| 99 | − name: "Primary", | |
| 100 | − createdByAccountId: accountId, | |
| 101 | − createdAt: now, | |
| 102 | − updatedAt: now, | |
| 122 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({ | |
| 123 | + workspaceId, | |
| 124 | + accountId, | |
| 103 | 125 | }); |
| 104 | 126 | |
| 105 | 127 | yield* persistence.rows.secretMaterials.upsert({ |
| @@ -119,22 +141,25 @@ describe("source-store", () => { · 13 unmodified lines | ||
| 119 | 141 | updatedAt: now, |
| 120 | 142 | }); |
| 121 | 143 | |
| 122 | − yield* persistSource( | |
| 123 | − persistence.rows, | |
| 124 | − makeOpenApiSource({ | |
| 125 | − workspaceId, | |
| 126 | − sourceId, | |
| 127 | − now, | |
| 128 | − auth: { | |
| 129 | − kind: "bearer", | |
| 130 | − headerName: "Authorization", | |
| 131 | − prefix: "Bearer ", | |
| 132 | − token: { | |
| 133 | − providerId: "postgres", | |
| 134 | − handle: firstTokenId, | |
| 144 | + yield* withRuntimeLocalWorkspace( | |
| 145 | + persistSource( | |
| 146 | + persistence.rows, | |
| 147 | + makeOpenApiSource({ | |
| 148 | + workspaceId, | |
| 149 | + sourceId, | |
| 150 | + now, | |
| 151 | + auth: { | |
| 152 | + kind: "bearer", | |
| 153 | + headerName: "Authorization", | |
| 154 | + prefix: "Bearer ", | |
| 155 | + token: { | |
| 156 | + providerId: "postgres", | |
| 157 | + handle: firstTokenId, | |
| 158 | + }, | |
| 135 | 159 | }, |
| 136 | − }, | |
| 137 | − }), | |
| 160 | + }), | |
| 161 | + ), | |
| 162 | + runtimeLocalWorkspace, | |
| 138 | 163 | ); |
| 139 | 164 | |
| 140 | 165 | yield* persistence.rows.sourceAuthSessions.upsert({ |
| @@ -167,23 +192,26 @@ describe("source-store", () => { · 26 unmodified lines | ||
| 167 | 192 | updatedAt: now, |
| 168 | 193 | }); |
| 169 | 194 | |
| 170 | − yield* persistSource( | |
| 171 | − persistence.rows, | |
| 172 | − makeOpenApiSource({ | |
| 173 | − workspaceId, | |
| 174 | − sourceId, | |
| 175 | − now, | |
| 176 | − updatedAt: now + 1, | |
| 177 | − auth: { | |
| 178 | − kind: "bearer", | |
| 179 | − headerName: "Authorization", | |
| 180 | − prefix: "Bearer ", | |
| 181 | − token: { | |
| 182 | − providerId: "postgres", | |
| 183 | − handle: secondTokenId, | |
| 195 | + yield* withRuntimeLocalWorkspace( | |
| 196 | + persistSource( | |
| 197 | + persistence.rows, | |
| 198 | + makeOpenApiSource({ | |
| 199 | + workspaceId, | |
| 200 | + sourceId, | |
| 201 | + now, | |
| 202 | + updatedAt: now + 1, | |
| 203 | + auth: { | |
| 204 | + kind: "bearer", | |
| 205 | + headerName: "Authorization", | |
| 206 | + prefix: "Bearer ", | |
| 207 | + token: { | |
| 208 | + providerId: "postgres", | |
| 209 | + handle: secondTokenId, | |
| 210 | + }, | |
| 184 | 211 | }, |
| 185 | − }, | |
| 186 | − }), | |
| 212 | + }), | |
| 213 | + ), | |
| 214 | + runtimeLocalWorkspace, | |
| 187 | 215 | ); |
| 188 | 216 | |
| 189 | 217 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true); |
| @@ -196,16 +224,22 @@ describe("source-store", () => { · 6 unmodified lines | ||
| 196 | 224 | : null, |
| 197 | 225 | ).toBe(secondTokenId); |
| 198 | 226 | |
| 199 | − const removed = yield* removeSourceById(persistence.rows, { | |
| 200 | − workspaceId, | |
| 201 | − sourceId, | |
| 202 | − }); | |
| 227 | + const removed = yield* withRuntimeLocalWorkspace( | |
| 228 | + removeSourceById(persistence.rows, { | |
| 229 | + workspaceId, | |
| 230 | + sourceId, | |
| 231 | + }), | |
| 232 | + runtimeLocalWorkspace, | |
| 233 | + ); | |
| 203 | 234 | expect(removed).toBe(true); |
| 204 | 235 | |
| 205 | 236 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); |
| 206 | − expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 207 | 237 | expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 208 | 238 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 239 | + const config = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context); | |
| 240 | + expect(config.config?.sources?.[sourceId]).toBeUndefined(); | |
| 241 | + const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); | |
| 242 | + expect(workspaceState.sources[sourceId]).toBeUndefined(); | |
| 209 | 243 | }), |
| 210 | 244 | ); |
| 211 | 245 | |
| @@ -214,27 +248,12 @@ describe("source-store", () => { · 2 unmodified lines | ||
| 214 | 248 | const persistence = yield* makePersistence; |
| 215 | 249 | const now = Date.now(); |
| 216 | 250 | const accountId = AccountIdSchema.make("acc_actor_scoped"); |
| 217 | − const organizationId = OrganizationIdSchema.make("org_actor_scoped"); | |
| 218 | 251 | const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped"); |
| 219 | − const sourceId = SourceIdSchema.make("src_actor_scoped"); | |
| 252 | + const sourceId = SourceIdSchema.make("github"); | |
| 220 | 253 | const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared"); |
| 221 | − | |
| 222 | − yield* persistence.rows.organizations.insert({ | |
| 223 | − id: organizationId, | |
| 224 | − slug: "actor-scoped", | |
| 225 | − name: "Actor Scoped", | |
| 226 | − status: "active", | |
| 227 | − createdByAccountId: accountId, | |
| 228 | − createdAt: now, | |
| 229 | − updatedAt: now, | |
| 230 | − }); | |
| 231 | − yield* persistence.rows.workspaces.insert({ | |
| 232 | − id: workspaceId, | |
| 233 | − organizationId, | |
| 234 | − name: "Primary", | |
| 235 | − createdByAccountId: accountId, | |
| 236 | − createdAt: now, | |
| 237 | − updatedAt: now, | |
| 254 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({ | |
| 255 | + workspaceId, | |
| 256 | + accountId, | |
| 238 | 257 | }); |
| 239 | 258 | yield* persistence.rows.secretMaterials.upsert({ |
| 240 | 259 | id: sharedTokenId, |
| @@ -260,16 +279,22 @@ describe("source-store", () => { · 19 unmodified lines | ||
| 260 | 279 | }, |
| 261 | 280 | }); |
| 262 | 281 | |
| 263 | − yield* persistSource(persistence.rows, source); | |
| 264 | − yield* persistSource( | |
| 265 | − persistence.rows, | |
| 266 | − { | |
| 267 | − ...source, | |
| 268 | − updatedAt: now + 1, | |
| 269 | − }, | |
| 270 | − { | |
| 271 | − actorAccountId: accountId, | |
| 272 | − }, | |
| 282 | + yield* withRuntimeLocalWorkspace( | |
| 283 | + persistSource(persistence.rows, source), | |
| 284 | + runtimeLocalWorkspace, | |
| 285 | + ); | |
| 286 | + yield* withRuntimeLocalWorkspace( | |
| 287 | + persistSource( | |
| 288 | + persistence.rows, | |
| 289 | + { | |
| 290 | + ...source, | |
| 291 | + updatedAt: now + 1, | |
| 292 | + }, | |
| 293 | + { | |
| 294 | + actorAccountId: accountId, | |
| 295 | + }, | |
| 296 | + ), | |
| 297 | + runtimeLocalWorkspace, | |
| 273 | 298 | ); |
| 274 | 299 | |
| 275 | 300 | const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({ |
| @@ -282,264 +307,4 @@ describe("source-store", () => { · 6 unmodified lines | ||
| 282 | 307 | expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2); |
| 283 | 308 | }), |
| 284 | 309 | ); |
| 285 | − | |
| 286 | − it.scoped("cleans up orphaned recipe data when the source config changes", () => | |
| 287 | − Effect.gen(function* () { | |
| 288 | − const persistence = yield* makePersistence; | |
| 289 | − const now = Date.now(); | |
| 290 | − const accountId = AccountIdSchema.make("acc_recipe_rewrite"); | |
| 291 | − const organizationId = OrganizationIdSchema.make("org_recipe_rewrite"); | |
| 292 | − const workspaceId = WorkspaceIdSchema.make("ws_recipe_rewrite"); | |
| 293 | − const sourceId = SourceIdSchema.make("src_recipe_rewrite"); | |
| 294 | − | |
| 295 | − yield* persistence.rows.organizations.insert({ | |
| 296 | − id: organizationId, | |
| 297 | − slug: "recipe-rewrite", | |
| 298 | − name: "Recipe Rewrite", | |
| 299 | − status: "active", | |
| 300 | − createdByAccountId: accountId, | |
| 301 | − createdAt: now, | |
| 302 | − updatedAt: now, | |
| 303 | − }); | |
| 304 | − yield* persistence.rows.workspaces.insert({ | |
| 305 | − id: workspaceId, | |
| 306 | − organizationId, | |
| 307 | − name: "Primary", | |
| 308 | − createdByAccountId: accountId, | |
| 309 | − createdAt: now, | |
| 310 | − updatedAt: now, | |
| 311 | − }); | |
| 312 | − | |
| 313 | − const initialSource = makeOpenApiSource({ | |
| 314 | − workspaceId, | |
| 315 | − sourceId, | |
| 316 | − now, | |
| 317 | − auth: { kind: "none" }, | |
| 318 | − }); | |
| 319 | − const initialRecipeId = stableSourceRecipeId(initialSource); | |
| 320 | − const initialRecipeRevisionId = stableSourceRecipeRevisionId(initialSource); | |
| 321 | − | |
| 322 | − yield* persistSource(persistence.rows, initialSource); | |
| 323 | − yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 324 | − recipeRevisionId: initialRecipeRevisionId, | |
| 325 | − documents: [{ | |
| 326 | − id: "src_recipe_doc_recipe_rewrite", | |
| 327 | − recipeRevisionId: initialRecipeRevisionId, | |
| 328 | − documentKind: "openapi", | |
| 329 | − documentKey: | |
| 330 | − typeof initialSource.binding.specUrl === "string" | |
| 331 | − ? initialSource.binding.specUrl | |
| 332 | − : initialSource.endpoint, | |
| 333 | − contentText: "{}", | |
| 334 | − contentHash: "hash_recipe_rewrite", | |
| 335 | − fetchedAt: now, | |
| 336 | − createdAt: now, | |
| 337 | − updatedAt: now, | |
| 338 | − }], | |
| 339 | − }); | |
| 340 | − yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 341 | − recipeRevisionId: initialRecipeRevisionId, | |
| 342 | − operations: [{ | |
| 343 | − id: "src_recipe_op_recipe_rewrite", | |
| 344 | − recipeRevisionId: initialRecipeRevisionId, | |
| 345 | − operationKey: "getRepo", | |
| 346 | − transportKind: "http", | |
| 347 | − toolId: "getRepo", | |
| 348 | − title: "Get Repo", | |
| 349 | − description: "Read a repository", | |
| 350 | − operationKind: "read", | |
| 351 | − searchText: "github get repo", | |
| 352 | − inputSchemaJson: null, | |
| 353 | − outputSchemaJson: null, | |
| 354 | − providerKind: "openapi", | |
| 355 | − providerDataJson: JSON.stringify({ | |
| 356 | − kind: "openapi", | |
| 357 | − toolId: "getRepo", | |
| 358 | − rawToolId: "repos_getRepo", | |
| 359 | − operationId: "repos.getRepo", | |
| 360 | − group: "repos", | |
| 361 | − leaf: "getRepo", | |
| 362 | − tags: ["repos"], | |
| 363 | − method: "get", | |
| 364 | − path: "/repos/{owner}/{repo}", | |
| 365 | − operationHash: "hash_recipe_rewrite", | |
| 366 | − invocation: { | |
| 367 | − method: "get", | |
| 368 | − pathTemplate: "/repos/{owner}/{repo}", | |
| 369 | − parameters: [], | |
| 370 | − requestBody: null, | |
| 371 | − }, | |
| 372 | − }), | |
| 373 | − createdAt: now, | |
| 374 | − updatedAt: now, | |
| 375 | − }], | |
| 376 | − }); | |
| 377 | − | |
| 378 | − const updatedSource = makeOpenApiSource({ | |
| 379 | − workspaceId, | |
| 380 | − sourceId, | |
| 381 | − now, | |
| 382 | − updatedAt: now + 1, | |
| 383 | − endpoint: "https://api.example.com", | |
| 384 | − specUrl: "https://api.example.com/openapi.json", | |
| 385 | − auth: { kind: "none" }, | |
| 386 | − }); | |
| 387 | − const nextRecipeId = stableSourceRecipeId(updatedSource); | |
| 388 | − const nextRecipeRevisionId = stableSourceRecipeRevisionId(updatedSource); | |
| 389 | − | |
| 390 | − yield* persistSource(persistence.rows, updatedSource); | |
| 391 | − | |
| 392 | − expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(initialRecipeId))).toBe(true); | |
| 393 | − expect( | |
| 394 | − Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(initialRecipeRevisionId)), | |
| 395 | − ).toBe(true); | |
| 396 | − expect( | |
| 397 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(initialRecipeRevisionId), | |
| 398 | − ).toEqual([]); | |
| 399 | − expect( | |
| 400 | − yield* persistence.rows.sourceRecipeOperations.listByRevisionId(initialRecipeRevisionId), | |
| 401 | − ).toEqual([]); | |
| 402 | − expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(nextRecipeId))).toBe(true); | |
| 403 | − expect( | |
| 404 | − Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(nextRecipeRevisionId)), | |
| 405 | − ).toBe(true); | |
| 406 | − }), | |
| 407 | − ); | |
| 408 | − | |
| 409 | − it.scoped("retains shared recipe data until the last source reference is removed", () => | |
| 410 | − Effect.gen(function* () { | |
| 411 | − const persistence = yield* makePersistence; | |
| 412 | − const now = Date.now(); | |
| 413 | − const accountId = AccountIdSchema.make("acc_shared_recipe"); | |
| 414 | − const organizationId = OrganizationIdSchema.make("org_shared_recipe"); | |
| 415 | − const workspaceId = WorkspaceIdSchema.make("ws_shared_recipe"); | |
| 416 | − const firstSourceId = SourceIdSchema.make("src_shared_recipe_one"); | |
| 417 | − const secondSourceId = SourceIdSchema.make("src_shared_recipe_two"); | |
| 418 | − | |
| 419 | − yield* persistence.rows.organizations.insert({ | |
| 420 | − id: organizationId, | |
| 421 | − slug: "shared-recipe", | |
| 422 | − name: "Shared Recipe", | |
| 423 | − status: "active", | |
| 424 | − createdByAccountId: accountId, | |
| 425 | − createdAt: now, | |
| 426 | − updatedAt: now, | |
| 427 | − }); | |
| 428 | − yield* persistence.rows.workspaces.insert({ | |
| 429 | − id: workspaceId, | |
| 430 | − organizationId, | |
| 431 | − name: "Primary", | |
| 432 | − createdByAccountId: accountId, | |
| 433 | − createdAt: now, | |
| 434 | − updatedAt: now, | |
| 435 | − }); | |
| 436 | − | |
| 437 | − const firstSource = makeOpenApiSource({ | |
| 438 | − workspaceId, | |
| 439 | − sourceId: firstSourceId, | |
| 440 | − now, | |
| 441 | − name: "GitHub One", | |
| 442 | − auth: { kind: "none" }, | |
| 443 | − }); | |
| 444 | − const secondSource = makeOpenApiSource({ | |
| 445 | − workspaceId, | |
| 446 | − sourceId: secondSourceId, | |
| 447 | − now, | |
| 448 | − updatedAt: now + 1, | |
| 449 | − name: "GitHub Two", | |
| 450 | − auth: { kind: "none" }, | |
| 451 | − }); | |
| 452 | − const sharedRecipeId = stableSourceRecipeId(firstSource); | |
| 453 | − const sharedRecipeRevisionId = stableSourceRecipeRevisionId(firstSource); | |
| 454 | − | |
| 455 | − yield* persistSource(persistence.rows, firstSource); | |
| 456 | − yield* persistSource(persistence.rows, secondSource); | |
| 457 | − yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 458 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 459 | − documents: [{ | |
| 460 | − id: "src_recipe_doc_shared_recipe", | |
| 461 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 462 | − documentKind: "openapi", | |
| 463 | − documentKey: | |
| 464 | − typeof firstSource.binding.specUrl === "string" | |
| 465 | − ? firstSource.binding.specUrl | |
| 466 | − : firstSource.endpoint, | |
| 467 | − contentText: "{}", | |
| 468 | − contentHash: "hash_shared_recipe", | |
| 469 | − fetchedAt: now, | |
| 470 | − createdAt: now, | |
| 471 | − updatedAt: now, | |
| 472 | − }], | |
| 473 | − }); | |
| 474 | − yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 475 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 476 | − operations: [{ | |
| 477 | − id: "src_recipe_op_shared_recipe", | |
| 478 | − recipeRevisionId: sharedRecipeRevisionId, | |
| 479 | − operationKey: "getRepo", | |
| 480 | − transportKind: "http", | |
| 481 | − toolId: "getRepo", | |
| 482 | − title: "Get Repo", | |
| 483 | − description: "Read a repository", | |
| 484 | − operationKind: "read", | |
| 485 | − searchText: "github get repo", | |
| 486 | − inputSchemaJson: null, | |
| 487 | − outputSchemaJson: null, | |
| 488 | − providerKind: "openapi", | |
| 489 | − providerDataJson: JSON.stringify({ | |
| 490 | − kind: "openapi", | |
| 491 | − toolId: "getRepo", | |
| 492 | − rawToolId: "repos_getRepo", | |
| 493 | − operationId: "repos.getRepo", | |
| 494 | − group: "repos", | |
| 495 | − leaf: "getRepo", | |
| 496 | − tags: ["repos"], | |
| 497 | − method: "get", | |
| 498 | − path: "/repos/{owner}/{repo}", | |
| 499 | − operationHash: "hash_shared_recipe", | |
| 500 | − invocation: { | |
| 501 | − method: "get", | |
| 502 | − pathTemplate: "/repos/{owner}/{repo}", | |
| 503 | − parameters: [], | |
| 504 | − requestBody: null, | |
| 505 | − }, | |
| 506 | − }), | |
| 507 | − createdAt: now, | |
| 508 | − updatedAt: now, | |
| 509 | − }], | |
| 510 | − }); | |
| 511 | − | |
| 512 | − yield* removeSourceById(persistence.rows, { | |
| 513 | − workspaceId, | |
| 514 | − sourceId: firstSourceId, | |
| 515 | − }); | |
| 516 | − | |
| 517 | − expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true); | |
| 518 | − expect( | |
| 519 | − Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)), | |
| 520 | − ).toBe(true); | |
| 521 | − expect( | |
| 522 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId), | |
| 523 | − ).toHaveLength(1); | |
| 524 | − expect( | |
| 525 | − yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId), | |
| 526 | − ).toHaveLength(1); | |
| 527 | − | |
| 528 | − yield* removeSourceById(persistence.rows, { | |
| 529 | − workspaceId, | |
| 530 | − sourceId: secondSourceId, | |
| 531 | − }); | |
| 532 | − | |
| 533 | − expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true); | |
| 534 | − expect( | |
| 535 | − Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)), | |
| 536 | − ).toBe(true); | |
| 537 | − expect( | |
| 538 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId), | |
| 539 | − ).toEqual([]); | |
| 540 | − expect( | |
| 541 | − yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId), | |
| 542 | − ).toEqual([]); | |
| 543 | − }), | |
| 544 | − ); | |
| 545 | 310 | }); |
| @@ -2,27 +2,60 @@ import type { · 1 unmodified line | ||
| 2 | 2 | AccountId, |
| 3 | 3 | AuthArtifact, |
| 4 | 4 | CredentialSlot, |
| 5 | + LocalConfigSecretInput, | |
| 6 | + LocalConfigSource, | |
| 5 | 7 | Source, |
| 6 | − SourceRecipeId, | |
| 7 | − SourceRecipeRevisionId, | |
| 8 | + SourceId, | |
| 8 | 9 | WorkspaceId, |
| 9 | 10 | } from "#schema"; |
| 11 | +import { SourceIdSchema } from "#schema"; | |
| 10 | 12 | import { type SqlControlPlaneRows } from "#persistence"; |
| 11 | 13 | import * as Effect from "effect/Effect"; |
| 12 | −import * as Option from "effect/Option"; | |
| 13 | 14 | |
| 14 | 15 | import { |
| 15 | − createSourceRecipeRecord, | |
| 16 | − createSourceRecipeRevisionRecord, | |
| 17 | − projectSourceFromStorage, | |
| 18 | − projectSourcesFromStorage, | |
| 19 | 16 | stableSourceRecipeId, |
| 20 | 17 | stableSourceRecipeRevisionId, |
| 21 | 18 | splitSourceForStorage, |
| 22 | 19 | } from "./source-definitions"; |
| 20 | +import { | |
| 21 | + sourceAuthFromAuthArtifact, | |
| 22 | +} from "./auth-artifacts"; | |
| 23 | +import { | |
| 24 | + loadLocalExecutorConfig, | |
| 25 | + type LoadedLocalExecutorConfig, | |
| 26 | + type ResolvedLocalWorkspaceContext, | |
| 27 | + writeProjectLocalExecutorConfig, | |
| 28 | +} from "./local-config"; | |
| 29 | +import { | |
| 30 | + readLocalSourceArtifact, | |
| 31 | + removeLocalSourceArtifact, | |
| 32 | +} from "./local-source-artifacts"; | |
| 33 | +import { | |
| 34 | + LocalConfiguredSourceNotFoundError, | |
| 35 | + LocalExecutorConfigDecodeError, | |
| 36 | + LocalFileSystemError, | |
| 37 | + LocalUnsupportedSourceKindError, | |
| 38 | + LocalWorkspaceStateDecodeError, | |
| 39 | + RuntimeLocalWorkspaceMismatchError, | |
| 40 | + RuntimeLocalWorkspaceUnavailableError, | |
| 41 | +} from "./local-errors"; | |
| 42 | +import { | |
| 43 | + requireRuntimeLocalWorkspace, | |
| 44 | +} from "./local-runtime-context"; | |
| 45 | +import { | |
| 46 | + loadLocalWorkspaceState, | |
| 47 | + writeLocalWorkspaceState, | |
| 48 | + type LocalWorkspaceState, | |
| 49 | +} from "./local-workspace-state"; | |
| 50 | +import { | |
| 51 | + fromConfigSecretProviderId, | |
| 52 | + toConfigSecretProviderId, | |
| 53 | +} from "./local-config-secrets"; | |
| 23 | 54 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; |
| 24 | 55 | import { authArtifactSecretMaterialRefs } from "./auth-artifacts"; |
| 25 | 56 | import { removeAuthLeaseAndSecrets } from "./auth-leases"; |
| 57 | +import { getSourceAdapter } from "./source-adapters"; | |
| 58 | +import { slugify } from "./slug"; | |
| 26 | 59 | |
| 27 | 60 | const secretRefKey = (ref: { providerId: string; handle: string }): string => |
| 28 | 61 | `${ref.providerId}:${ref.handle}`; |
| @@ -79,78 +112,408 @@ const selectExactAuthArtifact = (input: { · 50 unmodified lines | ||
| 79 | 112 | && artifact.actorAccountId === (input.actorAccountId ?? null), |
| 80 | 113 | ) ?? null; |
| 81 | 114 | |
| 115 | +const trimOrNull = (value: string | null | undefined): string | null => { | |
| 116 | + if (value == null) { | |
| 117 | + return null; | |
| 118 | + } | |
| 119 | + | |
| 120 | + const trimmed = value.trim(); | |
| 121 | + return trimmed.length > 0 ? trimmed : null; | |
| 122 | +}; | |
| 123 | + | |
| 124 | +const cloneJson = <T>(value: T): T => | |
| 125 | + JSON.parse(JSON.stringify(value)) as T; | |
| 126 | + | |
| 127 | +const deriveLocalSourceId = ( | |
| 128 | + source: Pick<Source, "namespace" | "name">, | |
| 129 | + used: ReadonlySet<string>, | |
| 130 | +): SourceId => { | |
| 131 | + const base = | |
| 132 | + trimOrNull(source.namespace) | |
| 133 | + ?? trimOrNull(source.name) | |
| 134 | + ?? "source"; | |
| 135 | + const slugBase = slugify(base) || "source"; | |
| 136 | + let candidate = slugBase; | |
| 137 | + let counter = 2; | |
| 138 | + while (used.has(candidate)) { | |
| 139 | + candidate = `${slugBase}-${counter}`; | |
| 140 | + counter += 1; | |
| 141 | + } | |
| 142 | + return SourceIdSchema.make(candidate); | |
| 143 | +}; | |
| 144 | + | |
| 145 | +const resolveLocalConfigSecretProviderAlias = (config: LoadedLocalExecutorConfig["config"]): string | null => { | |
| 146 | + const defaultAlias = trimOrNull(config?.secrets?.defaults?.env); | |
| 147 | + if (defaultAlias !== null && config?.secrets?.providers?.[defaultAlias]) { | |
| 148 | + return defaultAlias; | |
| 149 | + } | |
| 150 | + | |
| 151 | + return config?.secrets?.providers?.default ? "default" : null; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +const sourceAuthFromConfigInput = (input: { | |
| 155 | + auth: unknown; | |
| 156 | + config: LoadedLocalExecutorConfig["config"]; | |
| 157 | + existing: Source["auth"] | null; | |
| 158 | +}): Source["auth"] => { | |
| 159 | + if (input.auth === undefined) { | |
| 160 | + return input.existing ?? { kind: "none" }; | |
| 161 | + } | |
| 162 | + | |
| 163 | + if (typeof input.auth === "string") { | |
| 164 | + const providerAlias = resolveLocalConfigSecretProviderAlias(input.config); | |
| 165 | + return { | |
| 166 | + kind: "bearer", | |
| 167 | + headerName: "Authorization", | |
| 168 | + prefix: "Bearer ", | |
| 169 | + token: { | |
| 170 | + providerId: providerAlias ? toConfigSecretProviderId(providerAlias) : "env", | |
| 171 | + handle: input.auth, | |
| 172 | + }, | |
| 173 | + }; | |
| 174 | + } | |
| 175 | + | |
| 176 | + if (typeof input.auth === "object" && input.auth !== null) { | |
| 177 | + const explicit = input.auth as { | |
| 178 | + source?: string; | |
| 179 | + provider?: string; | |
| 180 | + id?: string; | |
| 181 | + }; | |
| 182 | + const providerAlias = trimOrNull(explicit.provider); | |
| 183 | + const providerId = providerAlias | |
| 184 | + ? (providerAlias === "params" ? "params" : toConfigSecretProviderId(providerAlias)) | |
| 185 | + : explicit.source === "env" | |
| 186 | + ? "env" | |
| 187 | + : explicit.source === "params" | |
| 188 | + ? "params" | |
| 189 | + : null; | |
| 190 | + const handle = trimOrNull(explicit.id); | |
| 191 | + if (providerId && handle) { | |
| 192 | + return { | |
| 193 | + kind: "bearer", | |
| 194 | + headerName: "Authorization", | |
| 195 | + prefix: "Bearer ", | |
| 196 | + token: { | |
| 197 | + providerId, | |
| 198 | + handle, | |
| 199 | + }, | |
| 200 | + }; | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + return input.existing ?? { kind: "none" }; | |
| 205 | +}; | |
| 206 | + | |
| 207 | +const configAuthFromSource = (input: { | |
| 208 | + source: Source; | |
| 209 | + existingConfigAuth: LocalConfigSecretInput | undefined; | |
| 210 | + config: LoadedLocalExecutorConfig["config"]; | |
| 211 | +}): LocalConfigSecretInput | undefined => { | |
| 212 | + if (input.source.auth.kind !== "bearer") { | |
| 213 | + return input.existingConfigAuth; | |
| 214 | + } | |
| 215 | + | |
| 216 | + if (input.source.auth.token.providerId === "env") { | |
| 217 | + return input.source.auth.token.handle; | |
| 218 | + } | |
| 219 | + | |
| 220 | + if (input.source.auth.token.providerId === "params") { | |
| 221 | + return { | |
| 222 | + source: "params", | |
| 223 | + provider: "params", | |
| 224 | + id: input.source.auth.token.handle, | |
| 225 | + }; | |
| 226 | + } | |
| 227 | + | |
| 228 | + const provider = fromConfigSecretProviderId(input.source.auth.token.providerId); | |
| 229 | + if (provider !== null) { | |
| 230 | + const configuredProvider = input.config?.secrets?.providers?.[provider]; | |
| 231 | + if (configuredProvider) { | |
| 232 | + return { | |
| 233 | + source: configuredProvider.source, | |
| 234 | + provider, | |
| 235 | + id: input.source.auth.token.handle, | |
| 236 | + }; | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + return input.existingConfigAuth; | |
| 241 | +}; | |
| 242 | + | |
| 243 | +const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId): Effect.Effect<{ | |
| 244 | + context: ResolvedLocalWorkspaceContext; | |
| 245 | + installation: { | |
| 246 | + workspaceId: WorkspaceId; | |
| 247 | + accountId: AccountId; | |
| 248 | + }; | |
| 249 | + loadedConfig: LoadedLocalExecutorConfig; | |
| 250 | + workspaceState: LocalWorkspaceState; | |
| 251 | +}, | |
| 252 | + | RuntimeLocalWorkspaceUnavailableError | |
| 253 | + | RuntimeLocalWorkspaceMismatchError | |
| 254 | + | LocalFileSystemError | |
| 255 | + | LocalExecutorConfigDecodeError | |
| 256 | + | LocalWorkspaceStateDecodeError, | |
| 257 | +never> => | |
| 258 | + Effect.gen(function* () { | |
| 259 | + const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId); | |
| 260 | + const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context); | |
| 261 | + const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); | |
| 262 | + | |
| 263 | + return { | |
| 264 | + context: runtimeLocalWorkspace.context, | |
| 265 | + installation: runtimeLocalWorkspace.installation, | |
| 266 | + loadedConfig, | |
| 267 | + workspaceState, | |
| 268 | + }; | |
| 269 | + }); | |
| 270 | + | |
| 271 | +const buildLocalSourceRecord = (input: { | |
| 272 | + context: ResolvedLocalWorkspaceContext; | |
| 273 | + workspaceId: WorkspaceId; | |
| 274 | + loadedConfig: LoadedLocalExecutorConfig; | |
| 275 | + workspaceState: LocalWorkspaceState; | |
| 276 | + sourceId: SourceId; | |
| 277 | + actorAccountId?: AccountId | null; | |
| 278 | + authArtifacts: ReadonlyArray<AuthArtifact>; | |
| 279 | +}): Effect.Effect<{ | |
| 280 | + source: Source; | |
| 281 | + sourceId: SourceId; | |
| 282 | +}, LocalConfiguredSourceNotFoundError | Error, never> => | |
| 283 | + Effect.gen(function* () { | |
| 284 | + const sourceConfig = input.loadedConfig.config?.sources?.[input.sourceId]; | |
| 285 | + if (!sourceConfig) { | |
| 286 | + return yield* Effect.fail( | |
| 287 | + new LocalConfiguredSourceNotFoundError({ | |
| 288 | + message: `Configured source not found for id ${input.sourceId}`, | |
| 289 | + sourceId: input.sourceId, | |
| 290 | + }), | |
| 291 | + ); | |
| 292 | + } | |
| 293 | + | |
| 294 | + const existingState = input.workspaceState.sources[input.sourceId]; | |
| 295 | + const adapter = getSourceAdapter(sourceConfig.kind); | |
| 296 | + const baseSource = yield* adapter.validateSource({ | |
| 297 | + id: SourceIdSchema.make(input.sourceId), | |
| 298 | + workspaceId: input.workspaceId, | |
| 299 | + name: trimOrNull(sourceConfig.name) ?? input.sourceId, | |
| 300 | + kind: sourceConfig.kind, | |
| 301 | + endpoint: sourceConfig.connection.endpoint.trim(), | |
| 302 | + status: existingState?.status ?? ((sourceConfig.enabled ?? true) ? "connected" : "draft"), | |
| 303 | + enabled: sourceConfig.enabled ?? true, | |
| 304 | + namespace: trimOrNull(sourceConfig.namespace) ?? input.sourceId, | |
| 305 | + bindingVersion: adapter.bindingConfigVersion, | |
| 306 | + binding: sourceConfig.binding, | |
| 307 | + importAuthPolicy: adapter.defaultImportAuthPolicy, | |
| 308 | + importAuth: { kind: "none" }, | |
| 309 | + auth: sourceAuthFromConfigInput({ | |
| 310 | + auth: sourceConfig.connection.auth, | |
| 311 | + config: input.loadedConfig.config, | |
| 312 | + existing: null, | |
| 313 | + }), | |
| 314 | + sourceHash: existingState?.sourceHash ?? null, | |
| 315 | + lastError: existingState?.lastError ?? null, | |
| 316 | + createdAt: existingState?.createdAt ?? Date.now(), | |
| 317 | + updatedAt: existingState?.updatedAt ?? Date.now(), | |
| 318 | + }); | |
| 319 | + | |
| 320 | + const artifact = yield* readLocalSourceArtifact({ | |
| 321 | + context: input.context, | |
| 322 | + sourceId: input.sourceId, | |
| 323 | + }).pipe( | |
| 324 | + Effect.catchAll(() => Effect.succeed(null)), | |
| 325 | + ); | |
| 326 | + | |
| 327 | + const runtimeAuthArtifact = selectPreferredAuthArtifact({ | |
| 328 | + authArtifacts: input.authArtifacts.filter((artifactItem) => artifactItem.sourceId === baseSource.id), | |
| 329 | + actorAccountId: input.actorAccountId, | |
| 330 | + slot: "runtime", | |
| 331 | + }); | |
| 332 | + const importAuthArtifact = selectPreferredAuthArtifact({ | |
| 333 | + authArtifacts: input.authArtifacts.filter((artifactItem) => artifactItem.sourceId === baseSource.id), | |
| 334 | + actorAccountId: input.actorAccountId, | |
| 335 | + slot: "import", | |
| 336 | + }); | |
| 337 | + | |
| 338 | + const sourceRecord = { | |
| 339 | + id: baseSource.id, | |
| 340 | + workspaceId: baseSource.workspaceId, | |
| 341 | + recipeId: artifact?.recipeId ?? stableSourceRecipeId(baseSource), | |
| 342 | + recipeRevisionId: artifact?.revision.id ?? stableSourceRecipeRevisionId(baseSource), | |
| 343 | + name: baseSource.name, | |
| 344 | + kind: baseSource.kind, | |
| 345 | + endpoint: baseSource.endpoint, | |
| 346 | + status: baseSource.status, | |
| 347 | + enabled: baseSource.enabled, | |
| 348 | + namespace: baseSource.namespace, | |
| 349 | + importAuthPolicy: baseSource.importAuthPolicy, | |
| 350 | + bindingConfigJson: adapter.serializeBindingConfig(baseSource), | |
| 351 | + sourceHash: baseSource.sourceHash, | |
| 352 | + lastError: baseSource.lastError, | |
| 353 | + createdAt: baseSource.createdAt, | |
| 354 | + updatedAt: baseSource.updatedAt, | |
| 355 | + }; | |
| 356 | + | |
| 357 | + const source: Source = { | |
| 358 | + ...baseSource, | |
| 359 | + auth: | |
| 360 | + runtimeAuthArtifact === null | |
| 361 | + ? baseSource.auth | |
| 362 | + : sourceAuthFromAuthArtifact(runtimeAuthArtifact), | |
| 363 | + importAuth: | |
| 364 | + baseSource.importAuthPolicy === "separate" | |
| 365 | + ? importAuthArtifact === null | |
| 366 | + ? baseSource.importAuth | |
| 367 | + : sourceAuthFromAuthArtifact(importAuthArtifact) | |
| 368 | + : { kind: "none" }, | |
| 369 | + sourceHash: sourceRecord.sourceHash, | |
| 370 | + lastError: sourceRecord.lastError, | |
| 371 | + createdAt: sourceRecord.createdAt, | |
| 372 | + updatedAt: sourceRecord.updatedAt, | |
| 373 | + }; | |
| 374 | + | |
| 375 | + return { | |
| 376 | + source, | |
| 377 | + sourceId: input.sourceId, | |
| 378 | + }; | |
| 379 | + }); | |
| 380 | + | |
| 82 | 381 | export const loadSourcesInWorkspace = ( |
| 83 | 382 | rows: SqlControlPlaneRows, |
| 84 | 383 | workspaceId: WorkspaceId, |
| 85 | 384 | options: { |
| 86 | 385 | actorAccountId?: AccountId | null; |
| 87 | 386 | } = {}, |
| 88 | −) => | |
| 387 | +): Effect.Effect< | |
| 388 | + readonly Source[], | |
| 389 | + | RuntimeLocalWorkspaceUnavailableError | |
| 390 | + | RuntimeLocalWorkspaceMismatchError | |
| 391 | + | LocalFileSystemError | |
| 392 | + | LocalExecutorConfigDecodeError | |
| 393 | + | LocalWorkspaceStateDecodeError | |
| 394 | + | LocalConfiguredSourceNotFoundError | |
| 395 | + | Error, | |
| 396 | + never | |
| 397 | +> => | |
| 89 | 398 | Effect.gen(function* () { |
| 90 | − const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); | |
| 399 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId); | |
| 91 | 400 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); |
| 92 | − const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => { | |
| 93 | − const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id); | |
| 94 | − const preferred = selectPreferredAuthArtifact({ | |
| 95 | − authArtifacts: matches, | |
| 96 | − actorAccountId: options.actorAccountId, | |
| 97 | − slot: "runtime", | |
| 98 | − }); | |
| 99 | − const preferredImport = selectPreferredAuthArtifact({ | |
| 100 | − authArtifacts: matches, | |
| 101 | − actorAccountId: options.actorAccountId, | |
| 102 | − slot: "import", | |
| 103 | − }); | |
| 104 | − return [preferred, preferredImport].filter( | |
| 105 | − (artifact): artifact is AuthArtifact => artifact !== null, | |
| 106 | − ); | |
| 107 | − }); | |
| 108 | − | |
| 109 | − return yield* projectSourcesFromStorage({ | |
| 110 | − sourceRecords, | |
| 111 | − authArtifacts: filteredAuthArtifacts, | |
| 112 | − }); | |
| 401 | + return yield* Effect.forEach( | |
| 402 | + Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}), | |
| 403 | + (sourceId) => | |
| 404 | + Effect.map( | |
| 405 | + buildLocalSourceRecord({ | |
| 406 | + context: localWorkspace.context, | |
| 407 | + workspaceId, | |
| 408 | + loadedConfig: localWorkspace.loadedConfig, | |
| 409 | + workspaceState: localWorkspace.workspaceState, | |
| 410 | + sourceId: SourceIdSchema.make(sourceId), | |
| 411 | + actorAccountId: options.actorAccountId, | |
| 412 | + authArtifacts, | |
| 413 | + }), | |
| 414 | + ({ source }) => source, | |
| 415 | + ), | |
| 416 | + ); | |
| 113 | 417 | }); |
| 114 | 418 | |
| 115 | 419 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { |
| 116 | 420 | workspaceId: WorkspaceId; |
| 117 | 421 | sourceId: Source["id"]; |
| 118 | 422 | actorAccountId?: AccountId | null; |
| 119 | −}) => | |
| 423 | +}): Effect.Effect< | |
| 424 | + Source, | |
| 425 | + | RuntimeLocalWorkspaceUnavailableError | |
| 426 | + | RuntimeLocalWorkspaceMismatchError | |
| 427 | + | LocalFileSystemError | |
| 428 | + | LocalExecutorConfigDecodeError | |
| 429 | + | LocalWorkspaceStateDecodeError | |
| 430 | + | LocalConfiguredSourceNotFoundError | |
| 431 | + | Error, | |
| 432 | + never | |
| 433 | +> => | |
| 120 | 434 | Effect.gen(function* () { |
| 121 | − const sourceRecord = yield* rows.sources.getByWorkspaceAndId( | |
| 122 | − input.workspaceId, | |
| 123 | − input.sourceId, | |
| 124 | − ); | |
| 125 | − | |
| 126 | − if (Option.isNone(sourceRecord)) { | |
| 435 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId); | |
| 436 | + const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId); | |
| 437 | + if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) { | |
| 127 | 438 | return yield* Effect.fail( |
| 128 | − new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`), | |
| 439 | + new LocalConfiguredSourceNotFoundError({ | |
| 440 | + message: `Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 441 | + sourceId: input.sourceId, | |
| 442 | + }), | |
| 129 | 443 | ); |
| 130 | 444 | } |
| 131 | 445 | |
| 132 | − const authArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ | |
| 446 | + const localSource = yield* buildLocalSourceRecord({ | |
| 447 | + context: localWorkspace.context, | |
| 133 | 448 | workspaceId: input.workspaceId, |
| 449 | + loadedConfig: localWorkspace.loadedConfig, | |
| 450 | + workspaceState: localWorkspace.workspaceState, | |
| 134 | 451 | sourceId: input.sourceId, |
| 135 | − }); | |
| 136 | − const authArtifact = selectPreferredAuthArtifact({ | |
| 137 | − authArtifacts, | |
| 138 | 452 | actorAccountId: input.actorAccountId, |
| 139 | − slot: "runtime", | |
| 140 | − }); | |
| 141 | − const importAuthArtifact = selectPreferredAuthArtifact({ | |
| 142 | 453 | authArtifacts, |
| 143 | − actorAccountId: input.actorAccountId, | |
| 144 | − slot: "import", | |
| 145 | 454 | }); |
| 146 | 455 | |
| 147 | − return yield* projectSourceFromStorage({ | |
| 148 | − sourceRecord: sourceRecord.value, | |
| 149 | − runtimeAuthArtifact: authArtifact, | |
| 150 | − importAuthArtifact, | |
| 151 | − }); | |
| 456 | + return localSource.source; | |
| 457 | + }); | |
| 458 | + | |
| 459 | +const configSourceFromLocalSource = (input: { | |
| 460 | + source: Source; | |
| 461 | + existingConfigAuth: LocalConfigSecretInput | undefined; | |
| 462 | + config: LoadedLocalExecutorConfig["config"]; | |
| 463 | +}): LocalConfigSource => { | |
| 464 | + const auth = configAuthFromSource({ | |
| 465 | + source: input.source, | |
| 466 | + existingConfigAuth: input.existingConfigAuth, | |
| 467 | + config: input.config, | |
| 152 | 468 | }); |
| 153 | 469 | |
| 470 | + const common = { | |
| 471 | + ...(trimOrNull(input.source.name) !== trimOrNull(input.source.id) | |
| 472 | + ? { name: input.source.name } | |
| 473 | + : {}), | |
| 474 | + ...(trimOrNull(input.source.namespace) !== trimOrNull(input.source.id) | |
| 475 | + ? { namespace: input.source.namespace ?? undefined } | |
| 476 | + : {}), | |
| 477 | + ...(input.source.enabled === false ? { enabled: false } : {}), | |
| 478 | + connection: { | |
| 479 | + endpoint: input.source.endpoint, | |
| 480 | + ...(auth !== undefined ? { auth } : {}), | |
| 481 | + }, | |
| 482 | + }; | |
| 483 | + | |
| 484 | + switch (input.source.kind) { | |
| 485 | + case "openapi": | |
| 486 | + return { | |
| 487 | + kind: "openapi", | |
| 488 | + ...common, | |
| 489 | + binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "openapi" }>["binding"], | |
| 490 | + }; | |
| 491 | + case "graphql": | |
| 492 | + return { | |
| 493 | + kind: "graphql", | |
| 494 | + ...common, | |
| 495 | + binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "graphql" }>["binding"], | |
| 496 | + }; | |
| 497 | + case "google_discovery": | |
| 498 | + return { | |
| 499 | + kind: "google_discovery", | |
| 500 | + ...common, | |
| 501 | + binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "google_discovery" }>["binding"], | |
| 502 | + }; | |
| 503 | + case "mcp": | |
| 504 | + return { | |
| 505 | + kind: "mcp", | |
| 506 | + ...common, | |
| 507 | + binding: cloneJson(input.source.binding) as Extract<LocalConfigSource, { kind: "mcp" }>["binding"], | |
| 508 | + }; | |
| 509 | + default: | |
| 510 | + throw new LocalUnsupportedSourceKindError({ | |
| 511 | + message: `Unsupported source kind for local config: ${input.source.kind}`, | |
| 512 | + kind: input.source.kind, | |
| 513 | + }); | |
| 514 | + } | |
| 515 | +}; | |
| 516 | + | |
| 154 | 517 | const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { |
| 155 | 518 | workspaceId: WorkspaceId; |
| 156 | 519 | sourceId: Source["id"]; |
| @@ -188,50 +551,46 @@ const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { · 31 unmodified lines | ||
| 188 | 551 | return existingAuthArtifacts.length; |
| 189 | 552 | }); |
| 190 | 553 | |
| 191 | −const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { | |
| 192 | − recipeId: SourceRecipeId; | |
| 193 | − recipeRevisionId: SourceRecipeRevisionId; | |
| 194 | −}) => | |
| 195 | − Effect.gen(function* () { | |
| 196 | − const revisionReferenceCount = yield* rows.sources.countByRecipeRevisionId( | |
| 197 | − input.recipeRevisionId, | |
| 198 | − ); | |
| 199 | − if (revisionReferenceCount === 0) { | |
| 200 | − yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId); | |
| 201 | − yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId); | |
| 202 | − yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId); | |
| 203 | − } | |
| 204 | − | |
| 205 | − const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId); | |
| 206 | − if (recipeReferenceCount > 0) { | |
| 207 | − return; | |
| 208 | − } | |
| 209 | − | |
| 210 | − const recipeRevisions = yield* rows.sourceRecipeRevisions.listByRecipeId(input.recipeId); | |
| 211 | − yield* Effect.forEach( | |
| 212 | − recipeRevisions, | |
| 213 | − (recipeRevision) => | |
| 214 | − Effect.all([ | |
| 215 | − rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id), | |
| 216 | − rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id), | |
| 217 | − rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id), | |
| 218 | − ]), | |
| 219 | − { discard: true }, | |
| 220 | − ); | |
| 221 | − yield* rows.sourceRecipeRevisions.removeByRecipeId(input.recipeId); | |
| 222 | − yield* rows.sourceRecipes.removeById(input.recipeId); | |
| 223 | − }); | |
| 224 | − | |
| 225 | 554 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { |
| 226 | 555 | workspaceId: WorkspaceId; |
| 227 | 556 | sourceId: Source["id"]; |
| 228 | 557 | }) => |
| 229 | 558 | Effect.gen(function* () { |
| 230 | − const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId); | |
| 231 | − if (Option.isNone(sourceRecord)) { | |
| 559 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId); | |
| 560 | + if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) { | |
| 232 | 561 | return false; |
| 233 | 562 | } |
| 234 | 563 | |
| 564 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 565 | + const sources = { | |
| 566 | + ...(projectConfig.sources ?? {}), | |
| 567 | + }; | |
| 568 | + delete sources[input.sourceId]; | |
| 569 | + yield* writeProjectLocalExecutorConfig({ | |
| 570 | + context: localWorkspace.context, | |
| 571 | + config: { | |
| 572 | + ...projectConfig, | |
| 573 | + sources, | |
| 574 | + }, | |
| 575 | + }); | |
| 576 | + | |
| 577 | + const { | |
| 578 | + [input.sourceId]: _removedSource, | |
| 579 | + ...remainingSources | |
| 580 | + } = localWorkspace.workspaceState.sources; | |
| 581 | + const workspaceState: LocalWorkspaceState = { | |
| 582 | + ...localWorkspace.workspaceState, | |
| 583 | + sources: remainingSources, | |
| 584 | + }; | |
| 585 | + yield* writeLocalWorkspaceState({ | |
| 586 | + context: localWorkspace.context, | |
| 587 | + state: workspaceState, | |
| 588 | + }); | |
| 589 | + yield* removeLocalSourceArtifact({ | |
| 590 | + context: localWorkspace.context, | |
| 591 | + sourceId: input.sourceId, | |
| 592 | + }); | |
| 593 | + | |
| 235 | 594 | yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( |
| 236 | 595 | input.workspaceId, |
| 237 | 596 | input.sourceId, |
| @@ -241,15 +600,6 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 3 unmodified lines | ||
| 241 | 600 | sourceId: input.sourceId, |
| 242 | 601 | }); |
| 243 | 602 | yield* removeAuthArtifactsForSource(rows, input); |
| 244 | − const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); | |
| 245 | − if (!removed) { | |
| 246 | − return false; | |
| 247 | − } | |
| 248 | − | |
| 249 | − yield* cleanupOrphanedRecipeData(rows, { | |
| 250 | − recipeId: sourceRecord.value.recipeId, | |
| 251 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 252 | − }); | |
| 253 | 603 | |
| 254 | 604 | return true; |
| 255 | 605 | }); |
| @@ -262,10 +612,21 @@ export const persistSource = ( · 6 unmodified lines | ||
| 262 | 612 | } = {}, |
| 263 | 613 | ) => |
| 264 | 614 | Effect.gen(function* () { |
| 265 | − const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); | |
| 615 | + const localWorkspace = yield* resolveRuntimeLocalWorkspace(source.workspaceId); | |
| 616 | + const nextSource = { | |
| 617 | + ...source, | |
| 618 | + id: | |
| 619 | + localWorkspace.loadedConfig.config?.sources?.[source.id] | |
| 620 | + || localWorkspace.workspaceState.sources[source.id] | |
| 621 | + ? source.id | |
| 622 | + : deriveLocalSourceId( | |
| 623 | + source, | |
| 624 | + new Set(Object.keys(localWorkspace.loadedConfig.config?.sources ?? {})), | |
| 625 | + ), | |
| 626 | + } satisfies Source; | |
| 266 | 627 | const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 267 | − workspaceId: source.workspaceId, | |
| 268 | − sourceId: source.id, | |
| 628 | + workspaceId: nextSource.workspaceId, | |
| 629 | + sourceId: nextSource.id, | |
| 269 | 630 | }); |
| 270 | 631 | const existingRuntimeAuthArtifact = selectExactAuthArtifact({ |
| 271 | 632 | authArtifacts: existingAuthArtifacts, |
| @@ -277,73 +638,33 @@ export const persistSource = ( · 5 unmodified lines | ||
| 277 | 638 | actorAccountId: options.actorAccountId, |
| 278 | 639 | slot: "import", |
| 279 | 640 | }); |
| 280 | − | |
| 281 | − const nextRecipeId = stableSourceRecipeId(source); | |
| 282 | − const nextRecipeRevisionId = Option.isSome(existing) && existing.value.recipeId === nextRecipeId | |
| 283 | − ? existing.value.recipeRevisionId | |
| 284 | − : stableSourceRecipeRevisionId(source); | |
| 285 | − const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); | |
| 286 | − const nextRevision = createSourceRecipeRevisionRecord({ | |
| 287 | − source, | |
| 288 | − recipeId: nextRecipeId, | |
| 289 | − recipeRevisionId: nextRecipeRevisionId, | |
| 290 | − revisionNumber: Option.isSome(existingTargetRevision) | |
| 291 | − ? existingTargetRevision.value.revisionNumber | |
| 292 | − : 1, | |
| 293 | − manifestJson: Option.isSome(existingTargetRevision) | |
| 294 | − ? existingTargetRevision.value.manifestJson | |
| 295 | − : null, | |
| 296 | − manifestHash: Option.isSome(existingTargetRevision) | |
| 297 | − ? existingTargetRevision.value.manifestHash | |
| 298 | − : null, | |
| 299 | − materializationHash: Option.isSome(existingTargetRevision) | |
| 300 | − ? existingTargetRevision.value.materializationHash | |
| 301 | − : null, | |
| 641 | + const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 642 | + const sources = { | |
| 643 | + ...(projectConfig.sources ?? {}), | |
| 644 | + }; | |
| 645 | + const existingConfigSource = sources[nextSource.id]; | |
| 646 | + sources[nextSource.id] = configSourceFromLocalSource({ | |
| 647 | + source: nextSource, | |
| 648 | + existingConfigAuth: existingConfigSource?.connection.auth, | |
| 649 | + config: localWorkspace.loadedConfig.config, | |
| 302 | 650 | }); |
| 303 | − | |
| 304 | − const nextRecipe = createSourceRecipeRecord({ | |
| 305 | − source, | |
| 306 | − recipeId: nextRecipeId, | |
| 307 | − latestRevisionId: nextRevision.id, | |
| 651 | + yield* writeProjectLocalExecutorConfig({ | |
| 652 | + context: localWorkspace.context, | |
| 653 | + config: { | |
| 654 | + ...projectConfig, | |
| 655 | + sources, | |
| 656 | + }, | |
| 308 | 657 | }); |
| 309 | 658 | |
| 310 | − const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ | |
| 311 | − source, | |
| 312 | − recipeId: nextRecipe.id, | |
| 313 | − recipeRevisionId: nextRevision.id, | |
| 659 | + const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ | |
| 660 | + source: nextSource, | |
| 661 | + recipeId: stableSourceRecipeId(nextSource), | |
| 662 | + recipeRevisionId: stableSourceRecipeRevisionId(nextSource), | |
| 314 | 663 | actorAccountId: options.actorAccountId, |
| 315 | 664 | existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null, |
| 316 | 665 | existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null, |
| 317 | 666 | }); |
| 318 | 667 | |
| 319 | − if (Option.isNone(existing)) { | |
| 320 | − yield* rows.sources.insert(sourceRecord); | |
| 321 | − } else { | |
| 322 | − const { | |
| 323 | − id: _id, | |
| 324 | − workspaceId: _workspaceId, | |
| 325 | − createdAt: _createdAt, | |
| 326 | − ...patch | |
| 327 | − } = sourceRecord; | |
| 328 | − yield* rows.sources.update(source.workspaceId, source.id, patch); | |
| 329 | − } | |
| 330 | − | |
| 331 | − yield* rows.sourceRecipes.upsert(nextRecipe); | |
| 332 | − yield* rows.sourceRecipeRevisions.upsert(nextRevision); | |
| 333 | − | |
| 334 | − if ( | |
| 335 | − Option.isSome(existing) | |
| 336 | − && ( | |
| 337 | − existing.value.recipeId !== nextRecipeId | |
| 338 | − || existing.value.recipeRevisionId !== nextRecipeRevisionId | |
| 339 | − ) | |
| 340 | − ) { | |
| 341 | − yield* cleanupOrphanedRecipeData(rows, { | |
| 342 | − recipeId: existing.value.recipeId, | |
| 343 | − recipeRevisionId: existing.value.recipeRevisionId, | |
| 344 | − }); | |
| 345 | − } | |
| 346 | − | |
| 347 | 668 | if (runtimeAuthArtifact === null) { |
| 348 | 669 | if (existingRuntimeAuthArtifact !== null) { |
| 349 | 670 | yield* removeAuthLeaseAndSecrets(rows, { |
| @@ -351,8 +672,8 @@ export const persistSource = ( · 1 unmodified line | ||
| 351 | 672 | }); |
| 352 | 673 | } |
| 353 | 674 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ |
| 354 | − workspaceId: source.workspaceId, | |
| 355 | − sourceId: source.id, | |
| 675 | + workspaceId: nextSource.workspaceId, | |
| 676 | + sourceId: nextSource.id, | |
| 356 | 677 | actorAccountId: options.actorAccountId ?? null, |
| 357 | 678 | slot: "runtime", |
| 358 | 679 | }); |
| @@ -380,8 +701,8 @@ export const persistSource = ( · 21 unmodified lines | ||
| 380 | 701 | }); |
| 381 | 702 | } |
| 382 | 703 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ |
| 383 | − workspaceId: source.workspaceId, | |
| 384 | − sourceId: source.id, | |
| 704 | + workspaceId: nextSource.workspaceId, | |
| 705 | + sourceId: nextSource.id, | |
| 385 | 706 | actorAccountId: options.actorAccountId ?? null, |
| 386 | 707 | slot: "import", |
| 387 | 708 | }); |
| @@ -402,5 +723,28 @@ export const persistSource = ( · 14 unmodified lines | ||
| 402 | 723 | next: importAuthArtifact, |
| 403 | 724 | }); |
| 404 | 725 | |
| 405 | − return source; | |
| 726 | + const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id]; | |
| 727 | + const workspaceState: LocalWorkspaceState = { | |
| 728 | + ...localWorkspace.workspaceState, | |
| 729 | + sources: { | |
| 730 | + ...localWorkspace.workspaceState.sources, | |
| 731 | + [nextSource.id]: { | |
| 732 | + status: nextSource.status, | |
| 733 | + lastError: nextSource.lastError, | |
| 734 | + sourceHash: nextSource.sourceHash, | |
| 735 | + createdAt: existingSourceState?.createdAt ?? nextSource.createdAt, | |
| 736 | + updatedAt: nextSource.updatedAt, | |
| 737 | + }, | |
| 738 | + }, | |
| 739 | + }; | |
| 740 | + yield* writeLocalWorkspaceState({ | |
| 741 | + context: localWorkspace.context, | |
| 742 | + state: workspaceState, | |
| 743 | + }); | |
| 744 | + | |
| 745 | + return yield* loadSourceById(rows, { | |
| 746 | + workspaceId: nextSource.workspaceId, | |
| 747 | + sourceId: nextSource.id, | |
| 748 | + actorAccountId: options.actorAccountId, | |
| 749 | + }); | |
| 406 | 750 | }); |
| @@ -2,7 +2,6 @@ import type { · 1 unmodified line | ||
| 2 | 2 | CreateSourcePayload, |
| 3 | 3 | UpdateSourcePayload, |
| 4 | 4 | } from "../api/sources/api"; |
| 5 | −import { Actor } from "#domain"; | |
| 6 | 5 | import { |
| 7 | 6 | type AccountId, |
| 8 | 7 | SourceIdSchema, |
| @@ -137,11 +136,14 @@ const syncArtifactsForSource = (input: { · 128 unmodified lines | ||
| 137 | 136 | }); |
| 138 | 137 | }); |
| 139 | 138 | |
| 140 | −export const listSources = (workspaceId: WorkspaceId) => | |
| 139 | +export const listSources = (input: { | |
| 140 | + workspaceId: WorkspaceId; | |
| 141 | + accountId: AccountId; | |
| 142 | +}) => | |
| 141 | 143 | Effect.flatMap(ControlPlaneStore, (store) => |
| 142 | − Effect.flatMap(Actor, (actor) => | |
| 143 | − loadSourcesInWorkspace(store, workspaceId, { | |
| 144 | − actorAccountId: actor.principal.accountId, | |
| 144 | + Effect.gen(function* () { | |
| 145 | + return yield* loadSourcesInWorkspace(store, input.workspaceId, { | |
| 146 | + actorAccountId: input.accountId, | |
| 145 | 147 | }).pipe( |
| 146 | 148 | Effect.mapError((error) => |
| 147 | 149 | sourceOps.list.unknownStorage( |
| @@ -149,58 +151,60 @@ export const listSources = (workspaceId: WorkspaceId) => · 1 unmodified line | ||
| 149 | 151 | "Failed projecting stored sources", |
| 150 | 152 | ), |
| 151 | 153 | ), |
| 152 | − ), | |
| 153 | − )); | |
| 154 | + ); | |
| 155 | + })); | |
| 154 | 156 | |
| 155 | 157 | export const createSource = (input: { |
| 156 | 158 | workspaceId: WorkspaceId; |
| 159 | + accountId: AccountId; | |
| 157 | 160 | payload: CreateSourcePayload; |
| 158 | 161 | }) => |
| 159 | 162 | Effect.flatMap(ControlPlaneStore, (store) => |
| 160 | − Effect.flatMap(Actor, (actor) => | |
| 161 | − Effect.gen(function* () { | |
| 162 | − const now = Date.now(); | |
| 163 | + Effect.gen(function* () { | |
| 164 | + const now = Date.now(); | |
| 163 | 165 | |
| 164 | − const source = yield* createSourceFromPayload({ | |
| 165 | − workspaceId: input.workspaceId, | |
| 166 | − sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | |
| 167 | − payload: input.payload, | |
| 168 | − now, | |
| 169 | − }).pipe( | |
| 170 | − Effect.mapError((cause) => | |
| 171 | − sourceOps.create.badRequest( | |
| 172 | − "Invalid source definition", | |
| 173 | − cause instanceof Error ? cause.message : String(cause), | |
| 174 | − ), | |
| 166 | + const source = yield* createSourceFromPayload({ | |
| 167 | + workspaceId: input.workspaceId, | |
| 168 | + sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | |
| 169 | + payload: input.payload, | |
| 170 | + now, | |
| 171 | + }).pipe( | |
| 172 | + Effect.mapError((cause) => | |
| 173 | + sourceOps.create.badRequest( | |
| 174 | + "Invalid source definition", | |
| 175 | + cause instanceof Error ? cause.message : String(cause), | |
| 175 | 176 | ), |
| 176 | − ); | |
| 177 | + ), | |
| 178 | + ); | |
| 177 | 179 | |
| 178 | − yield* mapPersistenceError( | |
| 179 | − sourceOps.create.child("persist"), | |
| 180 | − persistSource(store, source, { | |
| 181 | − actorAccountId: actor.principal.accountId, | |
| 182 | − }), | |
| 183 | − ); | |
| 180 | + const persistedSource = yield* mapPersistenceError( | |
| 181 | + sourceOps.create.child("persist"), | |
| 182 | + persistSource(store, source, { | |
| 183 | + actorAccountId: input.accountId, | |
| 184 | + }), | |
| 185 | + ); | |
| 184 | 186 | |
| 185 | − return yield* syncArtifactsForSource({ | |
| 186 | − store, | |
| 187 | − source, | |
| 188 | − actorAccountId: actor.principal.accountId, | |
| 189 | − operation: sourceOps.create, | |
| 190 | − }); | |
| 191 | − }), | |
| 192 | − )); | |
| 187 | + const synchronizedSource = yield* syncArtifactsForSource({ | |
| 188 | + store, | |
| 189 | + source: persistedSource, | |
| 190 | + actorAccountId: input.accountId, | |
| 191 | + operation: sourceOps.create, | |
| 192 | + }); | |
| 193 | + | |
| 194 | + return synchronizedSource; | |
| 195 | + })); | |
| 193 | 196 | |
| 194 | 197 | export const getSource = (input: { |
| 195 | 198 | workspaceId: WorkspaceId; |
| 196 | 199 | sourceId: SourceId; |
| 200 | + accountId: AccountId; | |
| 197 | 201 | }) => |
| 198 | 202 | Effect.flatMap(ControlPlaneStore, (store) => |
| 199 | − Effect.flatMap(Actor, (actor) => | |
| 200 | − loadSourceById(store, { | |
| 203 | + Effect.gen(function* () { | |
| 204 | + return yield* loadSourceById(store, { | |
| 201 | 205 | workspaceId: input.workspaceId, |
| 202 | 206 | sourceId: input.sourceId, |
| 203 | − actorAccountId: actor.principal.accountId, | |
| 207 | + actorAccountId: input.accountId, | |
| 204 | 208 | }).pipe( |
| 205 | 209 | Effect.mapError((cause) => |
| 206 | 210 | cause instanceof Error && cause.message.startsWith("Source not found:") |
| @@ -213,63 +217,64 @@ export const getSource = (input: { · 6 unmodified lines | ||
| 213 | 217 | "Failed projecting stored source", |
| 214 | 218 | ), |
| 215 | 219 | ), |
| 216 | − ), | |
| 217 | − )); | |
| 220 | + ); | |
| 221 | + })); | |
| 218 | 222 | |
| 219 | 223 | export const updateSource = (input: { |
| 220 | 224 | workspaceId: WorkspaceId; |
| 221 | 225 | sourceId: SourceId; |
| 226 | + accountId: AccountId; | |
| 222 | 227 | payload: UpdateSourcePayload; |
| 223 | 228 | }) => |
| 224 | 229 | Effect.flatMap(ControlPlaneStore, (store) => |
| 225 | − Effect.flatMap(Actor, (actor) => | |
| 226 | − Effect.gen(function* () { | |
| 227 | − const existingSource = yield* loadSourceById(store, { | |
| 228 | − workspaceId: input.workspaceId, | |
| 229 | − sourceId: input.sourceId, | |
| 230 | − actorAccountId: actor.principal.accountId, | |
| 231 | − }).pipe( | |
| 232 | − Effect.mapError((cause) => | |
| 233 | − cause instanceof Error && cause.message.startsWith("Source not found:") | |
| 234 | − ? sourceOps.update.notFound( | |
| 235 | − "Source not found", | |
| 236 | − `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 237 | − ) | |
| 238 | − : sourceOps.update.unknownStorage( | |
| 239 | − cause, | |
| 240 | − "Failed projecting stored source", | |
| 241 | − ), | |
| 242 | − ), | |
| 243 | − ); | |
| 230 | + Effect.gen(function* () { | |
| 231 | + const existingSource = yield* loadSourceById(store, { | |
| 232 | + workspaceId: input.workspaceId, | |
| 233 | + sourceId: input.sourceId, | |
| 234 | + actorAccountId: input.accountId, | |
| 235 | + }).pipe( | |
| 236 | + Effect.mapError((cause) => | |
| 237 | + cause instanceof Error && cause.message.startsWith("Source not found:") | |
| 238 | + ? sourceOps.update.notFound( | |
| 239 | + "Source not found", | |
| 240 | + `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 241 | + ) | |
| 242 | + : sourceOps.update.unknownStorage( | |
| 243 | + cause, | |
| 244 | + "Failed projecting stored source", | |
| 245 | + ), | |
| 246 | + ), | |
| 247 | + ); | |
| 244 | 248 | |
| 245 | − const updatedSource = yield* updateSourceFromPayload({ | |
| 246 | − source: existingSource, | |
| 247 | − payload: input.payload, | |
| 248 | − now: Date.now(), | |
| 249 | − }).pipe( | |
| 250 | − Effect.mapError((cause) => | |
| 251 | − sourceOps.update.badRequest( | |
| 252 | − "Invalid source definition", | |
| 253 | − cause instanceof Error ? cause.message : String(cause), | |
| 254 | − ), | |
| 249 | + const updatedSource = yield* updateSourceFromPayload({ | |
| 250 | + source: existingSource, | |
| 251 | + payload: input.payload, | |
| 252 | + now: Date.now(), | |
| 253 | + }).pipe( | |
| 254 | + Effect.mapError((cause) => | |
| 255 | + sourceOps.update.badRequest( | |
| 256 | + "Invalid source definition", | |
| 257 | + cause instanceof Error ? cause.message : String(cause), | |
| 255 | 258 | ), |
| 256 | − ); | |
| 259 | + ), | |
| 260 | + ); | |
| 257 | 261 | |
| 258 | − yield* mapPersistenceError( | |
| 259 | − sourceOps.update.child("persist"), | |
| 260 | − persistSource(store, updatedSource, { | |
| 261 | − actorAccountId: actor.principal.accountId, | |
| 262 | − }), | |
| 263 | − ); | |
| 262 | + const persistedSource = yield* mapPersistenceError( | |
| 263 | + sourceOps.update.child("persist"), | |
| 264 | + persistSource(store, updatedSource, { | |
| 265 | + actorAccountId: input.accountId, | |
| 266 | + }), | |
| 267 | + ); | |
| 264 | 268 | |
| 265 | − return yield* syncArtifactsForSource({ | |
| 266 | − store, | |
| 267 | − source: updatedSource, | |
| 268 | − actorAccountId: actor.principal.accountId, | |
| 269 | − operation: sourceOps.update, | |
| 270 | − }); | |
| 271 | − }), | |
| 272 | − )); | |
| 269 | + const synchronizedSource = yield* syncArtifactsForSource({ | |
| 270 | + store, | |
| 271 | + source: persistedSource, | |
| 272 | + actorAccountId: input.accountId, | |
| 273 | + operation: sourceOps.update, | |
| 274 | + }); | |
| 275 | + | |
| 276 | + return synchronizedSource; | |
| 277 | + })); | |
| 273 | 278 | |
| 274 | 279 | export const removeSource = (input: { |
| 275 | 280 | workspaceId: WorkspaceId; |
| @@ -1,8 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | HttpApiBuilder, |
| 3 | 3 | HttpApiClient, |
| 4 | − HttpClient, | |
| 5 | − HttpClientRequest, | |
| 6 | 4 | } from "@effect/platform"; |
| 7 | 5 | import { NodeHttpServer } from "@effect/platform-node"; |
| 8 | 6 | import * as Effect from "effect/Effect"; |
| @@ -14,11 +12,10 @@ import { · 5 unmodified lines | ||
| 14 | 12 | } from "#api"; |
| 15 | 13 | |
| 16 | 14 | import { |
| 17 | − ControlPlaneAuthHeaders, | |
| 18 | − type SqlControlPlaneRuntime, | |
| 15 | + type ControlPlaneRuntime, | |
| 19 | 16 | } from "./index"; |
| 20 | 17 | |
| 21 | −const createClientLayer = (runtime: SqlControlPlaneRuntime) => { | |
| 18 | +const createClientLayer = (runtime: ControlPlaneRuntime) => { | |
| 22 | 19 | const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer); |
| 23 | 20 | |
| 24 | 21 | return HttpApiBuilder.serve().pipe( |
| @@ -28,19 +25,9 @@ const createClientLayer = (runtime: SqlControlPlaneRuntime) => { · 3 unmodified lines | ||
| 28 | 25 | }; |
| 29 | 26 | |
| 30 | 27 | const createControlPlaneClient = (accountId?: string) => |
| 28 | + (void accountId, | |
| 31 | 29 | HttpApiClient.make(ControlPlaneApi, { |
| 32 | − transformClient: accountId | |
| 33 | − ? (client) => | |
| 34 | − client.pipe( | |
| 35 | − HttpClient.mapRequest( | |
| 36 | − HttpClientRequest.setHeader( | |
| 37 | − ControlPlaneAuthHeaders.accountId, | |
| 38 | − accountId, | |
| 39 | − ), | |
| 40 | − ), | |
| 41 | − ) | |
| 42 | − : undefined, | |
| 43 | − }); | |
| 30 | + })); | |
| 44 | 31 | |
| 45 | 32 | type ControlPlaneClient = Effect.Effect.Success< |
| 46 | 33 | ReturnType<typeof createControlPlaneClient> |
| @@ -48,7 +35,7 @@ type ControlPlaneClient = Effect.Effect.Success< · 1 unmodified line | ||
| 48 | 35 | |
| 49 | 36 | export const withControlPlaneClient = <A, E>( |
| 50 | 37 | input: { |
| 51 | − runtime: SqlControlPlaneRuntime; | |
| 38 | + runtime: ControlPlaneRuntime; | |
| 52 | 39 | accountId?: string; |
| 53 | 40 | }, |
| 54 | 41 | f: (client: ControlPlaneClient) => Effect.Effect<A, E, never>, |
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | +import { mkdtempSync } from "node:fs"; | |
| 2 | 3 | import { createServer } from "node:http"; |
| 4 | +import { tmpdir } from "node:os"; | |
| 5 | +import { join } from "node:path"; | |
| 3 | 6 | |
| 4 | 7 | import { |
| 5 | 8 | HttpApi, |
| @@ -45,7 +48,6 @@ import * as Effect from "effect/Effect"; · 39 unmodified lines | ||
| 45 | 48 | import * as Exit from "effect/Exit"; |
| 46 | 49 | import * as Layer from "effect/Layer"; |
| 47 | 50 | import * as Scope from "effect/Scope"; |
| 48 | −import * as Option from "effect/Option"; | |
| 49 | 51 | import { Schema } from "effect"; |
| 50 | 52 | import * as z from "zod/v4"; |
| 51 | 53 | |
| @@ -59,9 +61,28 @@ import { · 7 unmodified lines | ||
| 59 | 61 | createRuntimeSourceAuthService, |
| 60 | 62 | type RuntimeSourceAuthService, |
| 61 | 63 | } from "./source-auth-service"; |
| 64 | +import { | |
| 65 | + loadLocalExecutorConfig, | |
| 66 | + resolveLocalWorkspaceContext, | |
| 67 | + writeProjectLocalExecutorConfig, | |
| 68 | +} from "./local-config"; | |
| 62 | 69 | import { createLiveExecutionManager } from "./live-execution"; |
| 63 | −import { persistSource } from "./source-store"; | |
| 64 | −import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization"; | |
| 70 | +import { | |
| 71 | + RuntimeLocalWorkspaceService, | |
| 72 | + type RuntimeLocalWorkspaceState, | |
| 73 | +} from "./local-runtime-context"; | |
| 74 | +import { | |
| 75 | + buildLocalSourceArtifact, | |
| 76 | + readLocalSourceArtifact, | |
| 77 | + writeLocalSourceArtifact, | |
| 78 | +} from "./local-source-artifacts"; | |
| 79 | +import { | |
| 80 | + loadLocalWorkspaceState, | |
| 81 | + writeLocalWorkspaceState, | |
| 82 | +} from "./local-workspace-state"; | |
| 83 | +import { | |
| 84 | + materializationFromMcpManifestEntries, | |
| 85 | +} from "./source-adapters/mcp"; | |
| 65 | 86 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 66 | 87 | |
| 67 | 88 | type CountedMcpServer = { |
| @@ -579,7 +600,7 @@ const makeGraphqlServer = Effect.acquireRelease( · 511 unmodified lines | ||
| 579 | 600 | ); |
| 580 | 601 | |
| 581 | 602 | const persistConnectedEchoTool = (input: { |
| 582 | − persistence: SqlControlPlanePersistence; | |
| 603 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState; | |
| 583 | 604 | endpoint: string; |
| 584 | 605 | workspaceId: WorkspaceId; |
| 585 | 606 | sourceId: SourceId; |
| @@ -610,32 +631,70 @@ const persistConnectedEchoTool = (input: { · 24 unmodified lines | ||
| 610 | 631 | updatedAt: now, |
| 611 | 632 | }; |
| 612 | 633 | |
| 613 | − yield* persistSource(input.persistence.rows, source); | |
| 614 | − yield* persistMcpRecipeMaterializationFromManifest({ | |
| 615 | − rows: input.persistence.rows, | |
| 616 | − source, | |
| 617 | − manifestEntries: [ | |
| 618 | − { | |
| 619 | − toolId: "echo", | |
| 620 | − toolName: "echo", | |
| 621 | − description: "Echo the provided string", | |
| 622 | − inputSchemaJson: JSON.stringify({ | |
| 623 | − type: "object", | |
| 624 | − properties: { | |
| 625 | − value: { | |
| 626 | − type: "string", | |
| 627 | − }, | |
| 634 | + yield* writeProjectLocalExecutorConfig({ | |
| 635 | + context: input.runtimeLocalWorkspace.context, | |
| 636 | + config: { | |
| 637 | + sources: { | |
| 638 | + [input.sourceId]: { | |
| 639 | + kind: "mcp", | |
| 640 | + name: source.name, | |
| 641 | + ...(source.namespace ? { namespace: source.namespace } : {}), | |
| 642 | + connection: { | |
| 643 | + endpoint: source.endpoint, | |
| 628 | 644 | }, |
| 629 | − required: ["value"], | |
| 630 | − additionalProperties: false, | |
| 631 | − }), | |
| 645 | + binding: { | |
| 646 | + transport: "streamable-http", | |
| 647 | + }, | |
| 648 | + }, | |
| 632 | 649 | }, |
| 633 | − ], | |
| 650 | + }, | |
| 634 | 651 | }); |
| 635 | − }); | |
| 652 | + yield* writeLocalSourceArtifact({ | |
| 653 | + context: input.runtimeLocalWorkspace.context, | |
| 654 | + sourceId: source.id, | |
| 655 | + artifact: buildLocalSourceArtifact({ | |
| 656 | + source, | |
| 657 | + materialization: materializationFromMcpManifestEntries({ | |
| 658 | + recipeRevisionId: "src_recipe_rev_materialization" as never, | |
| 659 | + endpoint: source.endpoint, | |
| 660 | + manifestEntries: [ | |
| 661 | + { | |
| 662 | + toolId: "echo", | |
| 663 | + toolName: "echo", | |
| 664 | + description: "Echo the provided string", | |
| 665 | + inputSchemaJson: JSON.stringify({ | |
| 666 | + type: "object", | |
| 667 | + properties: { | |
| 668 | + value: { type: "string" }, | |
| 669 | + }, | |
| 670 | + required: ["value"], | |
| 671 | + additionalProperties: false, | |
| 672 | + }), | |
| 673 | + }, | |
| 674 | + ], | |
| 675 | + }), | |
| 676 | + }), | |
| 677 | + }); | |
| 678 | + yield* writeLocalWorkspaceState({ | |
| 679 | + context: input.runtimeLocalWorkspace.context, | |
| 680 | + state: { | |
| 681 | + version: 1, | |
| 682 | + sources: { | |
| 683 | + [input.sourceId]: { | |
| 684 | + status: "connected", | |
| 685 | + lastError: null, | |
| 686 | + sourceHash: null, | |
| 687 | + createdAt: now, | |
| 688 | + updatedAt: now, | |
| 689 | + }, | |
| 690 | + }, | |
| 691 | + policies: {}, | |
| 692 | + }, | |
| 693 | + }); | |
| 694 | + }) as Effect.Effect<void, Error, never>; | |
| 636 | 695 | |
| 637 | 696 | const persistConnectedGithubOpenApiSource = (input: { |
| 638 | − persistence: SqlControlPlanePersistence; | |
| 697 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState; | |
| 639 | 698 | workspaceId: WorkspaceId; |
| 640 | 699 | sourceId: SourceId; |
| 641 | 700 | endpoint?: string; |
| @@ -831,7 +890,22 @@ const persistConnectedGithubOpenApiSource = (input: { · 189 unmodified lines | ||
| 831 | 890 | updatedAt: now, |
| 832 | 891 | }; |
| 833 | 892 | |
| 834 | − yield* persistSource(input.persistence.rows, source); | |
| 893 | + const configAuth = | |
| 894 | + source.auth.kind === "bearer" | |
| 895 | + ? source.auth.token.providerId === "params" | |
| 896 | + ? { | |
| 897 | + source: "params" as const, | |
| 898 | + provider: "params", | |
| 899 | + id: source.auth.token.handle, | |
| 900 | + } | |
| 901 | + : source.auth.token.providerId === "env" | |
| 902 | + ? source.auth.token.handle | |
| 903 | + : { | |
| 904 | + source: "env" as const, | |
| 905 | + provider: "env", | |
| 906 | + id: source.auth.token.handle, | |
| 907 | + } | |
| 908 | + : undefined; | |
| 835 | 909 | |
| 836 | 910 | const manifest = yield* extractOpenApiManifest( |
| 837 | 911 | source.name, |
| @@ -842,131 +916,165 @@ const persistConnectedGithubOpenApiSource = (input: { · 4 unmodified lines | ||
| 842 | 916 | ), |
| 843 | 917 | ); |
| 844 | 918 | const definitions = compileOpenApiToolDefinitions(manifest); |
| 845 | − const sourceRecord = | |
| 846 | − yield* input.persistence.rows.sources.getByWorkspaceAndId( | |
| 847 | − input.workspaceId, | |
| 848 | − input.sourceId, | |
| 849 | − ); | |
| 850 | − if (Option.isNone(sourceRecord)) { | |
| 851 | − return yield* Effect.fail( | |
| 852 | − new Error(`Missing stored source ${input.sourceId}`), | |
| 853 | − ); | |
| 854 | − } | |
| 855 | − | |
| 856 | − yield* input.persistence.rows.sourceRecipeRevisions.update( | |
| 857 | − sourceRecord.value.recipeRevisionId, | |
| 858 | − { | |
| 859 | − manifestJson: JSON.stringify(manifest), | |
| 860 | − manifestHash: manifest.sourceHash, | |
| 861 | − updatedAt: now, | |
| 862 | − }, | |
| 863 | − ); | |
| 864 | − yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 865 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 866 | − documents: [ | |
| 867 | − { | |
| 868 | − id: `src_recipe_doc_${randomUUID()}`, | |
| 869 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 870 | − documentKind: "openapi", | |
| 871 | − documentKey: | |
| 872 | − typeof source.binding.specUrl === "string" | |
| 873 | − ? source.binding.specUrl | |
| 874 | − : source.endpoint, | |
| 875 | − contentText: openApiDocumentText, | |
| 876 | − contentHash: manifest.sourceHash, | |
| 877 | − fetchedAt: now, | |
| 878 | − createdAt: now, | |
| 879 | − updatedAt: now, | |
| 919 | + yield* writeProjectLocalExecutorConfig({ | |
| 920 | + context: input.runtimeLocalWorkspace.context, | |
| 921 | + config: { | |
| 922 | + sources: { | |
| 923 | + [input.sourceId]: { | |
| 924 | + kind: "openapi", | |
| 925 | + name: source.name, | |
| 926 | + ...(source.namespace ? { namespace: source.namespace } : {}), | |
| 927 | + connection: { | |
| 928 | + endpoint: source.endpoint, | |
| 929 | + ...(configAuth !== undefined ? { auth: configAuth } : {}), | |
| 930 | + }, | |
| 931 | + binding: { | |
| 932 | + specUrl: String(source.binding.specUrl), | |
| 933 | + defaultHeaders: null, | |
| 934 | + }, | |
| 935 | + }, | |
| 880 | 936 | }, |
| 881 | − ], | |
| 937 | + }, | |
| 882 | 938 | }); |
| 883 | 939 | const refEntries = Object.entries(manifest.refHintTable ?? {}); |
| 884 | − yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | |
| 885 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 886 | − bundles: | |
| 887 | − refEntries.length > 0 | |
| 888 | − ? [ | |
| 889 | − { | |
| 890 | − id: SourceRecipeSchemaBundleIdSchema.make( | |
| 891 | − `src_recipe_bundle_${randomUUID()}`, | |
| 892 | − ), | |
| 893 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 894 | − bundleKind: "json_schema_ref_map", | |
| 895 | − refsJson: JSON.stringify( | |
| 896 | − Object.fromEntries( | |
| 897 | − refEntries.map(([ref, rawValue]) => { | |
| 898 | − try { | |
| 899 | − return [ref, JSON.parse(rawValue) as unknown]; | |
| 900 | − } catch { | |
| 901 | − return [ref, rawValue]; | |
| 902 | − } | |
| 903 | − }), | |
| 940 | + yield* writeLocalSourceArtifact({ | |
| 941 | + context: input.runtimeLocalWorkspace.context, | |
| 942 | + sourceId: source.id, | |
| 943 | + artifact: buildLocalSourceArtifact({ | |
| 944 | + source: { | |
| 945 | + ...source, | |
| 946 | + sourceHash: manifest.sourceHash, | |
| 947 | + }, | |
| 948 | + materialization: { | |
| 949 | + manifestJson: JSON.stringify(manifest), | |
| 950 | + manifestHash: manifest.sourceHash, | |
| 951 | + sourceHash: manifest.sourceHash, | |
| 952 | + documents: [ | |
| 953 | + { | |
| 954 | + id: `src_recipe_doc_${randomUUID()}`, | |
| 955 | + recipeRevisionId: "src_recipe_rev_materialization" as never, | |
| 956 | + documentKind: "openapi", | |
| 957 | + documentKey: | |
| 958 | + typeof source.binding.specUrl === "string" | |
| 959 | + ? source.binding.specUrl | |
| 960 | + : source.endpoint, | |
| 961 | + contentText: openApiDocumentText, | |
| 962 | + contentHash: manifest.sourceHash, | |
| 963 | + fetchedAt: now, | |
| 964 | + createdAt: now, | |
| 965 | + updatedAt: now, | |
| 966 | + }, | |
| 967 | + ], | |
| 968 | + schemaBundles: | |
| 969 | + refEntries.length > 0 | |
| 970 | + ? [ | |
| 971 | + { | |
| 972 | + id: SourceRecipeSchemaBundleIdSchema.make( | |
| 973 | + `src_recipe_bundle_${randomUUID()}`, | |
| 974 | + ), | |
| 975 | + recipeRevisionId: "src_recipe_rev_materialization" as never, | |
| 976 | + bundleKind: "json_schema_ref_map", | |
| 977 | + refsJson: JSON.stringify( | |
| 978 | + Object.fromEntries( | |
| 979 | + refEntries.map(([ref, rawValue]) => { | |
| 980 | + try { | |
| 981 | + return [ref, JSON.parse(rawValue) as unknown]; | |
| 982 | + } catch { | |
| 983 | + return [ref, rawValue]; | |
| 984 | + } | |
| 985 | + }), | |
| 986 | + ), | |
| 987 | + ), | |
| 988 | + contentHash: manifest.sourceHash, | |
| 989 | + createdAt: now, | |
| 990 | + updatedAt: now, | |
| 991 | + }, | |
| 992 | + ] | |
| 993 | + : [], | |
| 994 | + operations: definitions.map((definition) => { | |
| 995 | + const presentation = buildOpenApiToolPresentation({ | |
| 996 | + definition, | |
| 997 | + }); | |
| 998 | + const method = definition.method.toUpperCase(); | |
| 999 | + | |
| 1000 | + return { | |
| 1001 | + id: `src_recipe_op_${randomUUID()}`, | |
| 1002 | + recipeRevisionId: "src_recipe_rev_materialization" as never, | |
| 1003 | + operationKey: definition.toolId, | |
| 1004 | + transportKind: "http", | |
| 1005 | + toolId: definition.toolId, | |
| 1006 | + title: definition.name, | |
| 1007 | + description: definition.description, | |
| 1008 | + operationKind: | |
| 1009 | + method === "GET" || method === "HEAD" | |
| 1010 | + ? "read" | |
| 1011 | + : method === "DELETE" | |
| 1012 | + ? "delete" | |
| 1013 | + : "write", | |
| 1014 | + searchText: normalizeSearchText( | |
| 1015 | + definition.toolId, | |
| 1016 | + definition.name, | |
| 1017 | + definition.description, | |
| 1018 | + definition.rawToolId, | |
| 1019 | + definition.operationId ?? undefined, | |
| 1020 | + definition.method, | |
| 1021 | + definition.path, | |
| 1022 | + definition.group, | |
| 1023 | + definition.leaf, | |
| 1024 | + definition.tags.join(" "), | |
| 904 | 1025 | ), |
| 905 | − ), | |
| 906 | − contentHash: manifest.sourceHash, | |
| 907 | − createdAt: now, | |
| 908 | − updatedAt: now, | |
| 909 | − }, | |
| 910 | − ] | |
| 911 | − : [], | |
| 912 | − }); | |
| 913 | − yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 914 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 915 | − operations: definitions.map((definition) => { | |
| 916 | − const presentation = buildOpenApiToolPresentation({ | |
| 917 | − definition, | |
| 918 | − }); | |
| 919 | − const method = definition.method.toUpperCase(); | |
| 920 | − | |
| 921 | − return { | |
| 922 | − id: `src_recipe_op_${randomUUID()}`, | |
| 923 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 924 | − operationKey: definition.toolId, | |
| 925 | − transportKind: "http", | |
| 926 | − toolId: definition.toolId, | |
| 927 | − title: definition.name, | |
| 928 | − description: definition.description, | |
| 929 | − operationKind: | |
| 930 | − method === "GET" || method === "HEAD" | |
| 931 | − ? "read" | |
| 932 | − : method === "DELETE" | |
| 933 | − ? "delete" | |
| 934 | − : "write", | |
| 935 | − searchText: normalizeSearchText( | |
| 936 | − definition.toolId, | |
| 937 | − definition.name, | |
| 938 | − definition.description, | |
| 939 | − definition.rawToolId, | |
| 940 | − definition.operationId ?? undefined, | |
| 941 | − definition.method, | |
| 942 | − definition.path, | |
| 943 | − definition.group, | |
| 944 | − definition.leaf, | |
| 945 | − definition.tags.join(" "), | |
| 946 | − ), | |
| 947 | − inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 948 | − outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 949 | − providerKind: "openapi", | |
| 950 | − providerDataJson: presentation.providerDataJson, | |
| 951 | − createdAt: now, | |
| 952 | − updatedAt: now, | |
| 953 | − }; | |
| 954 | − }), | |
| 1026 | + inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 1027 | + outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 1028 | + providerKind: "openapi", | |
| 1029 | + providerDataJson: presentation.providerDataJson, | |
| 1030 | + createdAt: now, | |
| 1031 | + updatedAt: now, | |
| 1032 | + }; | |
| 1033 | + }), | |
| 1034 | + }, | |
| 1035 | + }), | |
| 955 | 1036 | }); |
| 956 | − yield* input.persistence.rows.sources.update( | |
| 957 | − input.workspaceId, | |
| 958 | − input.sourceId, | |
| 959 | − { | |
| 960 | − sourceHash: manifest.sourceHash, | |
| 961 | − updatedAt: now, | |
| 1037 | + yield* writeLocalWorkspaceState({ | |
| 1038 | + context: input.runtimeLocalWorkspace.context, | |
| 1039 | + state: { | |
| 1040 | + version: 1, | |
| 1041 | + sources: { | |
| 1042 | + [input.sourceId]: { | |
| 1043 | + status: "connected", | |
| 1044 | + lastError: null, | |
| 1045 | + sourceHash: manifest.sourceHash, | |
| 1046 | + createdAt: now, | |
| 1047 | + updatedAt: now, | |
| 1048 | + }, | |
| 1049 | + }, | |
| 1050 | + policies: {}, | |
| 962 | 1051 | }, |
| 963 | − ); | |
| 1052 | + }); | |
| 964 | 1053 | |
| 965 | 1054 | return; |
| 1055 | + }) as Effect.Effect<void, Error, never>; | |
| 1056 | + | |
| 1057 | +const makeRuntimeLocalWorkspaceState = (workspaceId: WorkspaceId): Effect.Effect<RuntimeLocalWorkspaceState, Error, never> => | |
| 1058 | + Effect.gen(function* () { | |
| 1059 | + const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-workspace-env-")); | |
| 1060 | + const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); | |
| 1061 | + const loadedConfig = yield* loadLocalExecutorConfig(context); | |
| 1062 | + return { | |
| 1063 | + context, | |
| 1064 | + installation: { | |
| 1065 | + workspaceId, | |
| 1066 | + accountId: AccountIdSchema.make("acc_local_test"), | |
| 1067 | + }, | |
| 1068 | + loadedConfig, | |
| 1069 | + } satisfies RuntimeLocalWorkspaceState; | |
| 966 | 1070 | }); |
| 967 | 1071 | |
| 968 | −const makeResolver = (persistence: SqlControlPlanePersistence) => | |
| 969 | − createWorkspaceExecutionEnvironmentResolver({ | |
| 1072 | +const makeResolver = ( | |
| 1073 | + persistence: SqlControlPlanePersistence, | |
| 1074 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | |
| 1075 | +) => | |
| 1076 | + ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) => | |
| 1077 | + createWorkspaceExecutionEnvironmentResolver({ | |
| 970 | 1078 | rows: persistence.rows, |
| 971 | 1079 | sourceAuthService: { |
| 972 | 1080 | getLocalServerBaseUrl: () => null, |
| @@ -983,19 +1091,25 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 10 unmodified lines | ||
| 983 | 1091 | completeSourceCredentialSetup: () => |
| 984 | 1092 | Effect.fail(new Error("not implemented in test")), |
| 985 | 1093 | } as RuntimeSourceAuthService, |
| 986 | − }); | |
| 1094 | + })(input).pipe( | |
| 1095 | + Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | |
| 1096 | + )); | |
| 987 | 1097 | |
| 988 | 1098 | const makeResolverWithLiveSourceAuth = ( |
| 989 | 1099 | persistence: SqlControlPlanePersistence, |
| 1100 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | |
| 990 | 1101 | ) => |
| 991 | − createWorkspaceExecutionEnvironmentResolver({ | |
| 1102 | + ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) => | |
| 1103 | + createWorkspaceExecutionEnvironmentResolver({ | |
| 992 | 1104 | rows: persistence.rows, |
| 993 | 1105 | sourceAuthService: createRuntimeSourceAuthService({ |
| 994 | 1106 | rows: persistence.rows, |
| 995 | 1107 | liveExecutionManager: createLiveExecutionManager(), |
| 996 | 1108 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", |
| 997 | 1109 | }), |
| 998 | − }); | |
| 1110 | + })(input).pipe( | |
| 1111 | + Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | |
| 1112 | + )); | |
| 999 | 1113 | |
| 1000 | 1114 | describe("workspace-execution-environment", () => { |
| 1001 | 1115 | it.scoped("discovers persisted tools without live MCP listing", () => |
| @@ -1004,8 +1118,9 @@ describe("workspace-execution-environment", () => { · 2 unmodified lines | ||
| 1004 | 1118 | const persistence = yield* makePersistence; |
| 1005 | 1119 | const workspaceId = WorkspaceIdSchema.make("ws_catalog"); |
| 1006 | 1120 | const sourceId = SourceIdSchema.make("src_catalog"); |
| 1121 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1007 | 1122 | yield* persistConnectedEchoTool({ |
| 1008 | − persistence, | |
| 1123 | + runtimeLocalWorkspace, | |
| 1009 | 1124 | endpoint: server.endpoint, |
| 1010 | 1125 | workspaceId, |
| 1011 | 1126 | sourceId, |
| @@ -1017,7 +1132,7 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines | ||
| 1017 | 1132 | cause instanceof Error ? cause : new Error(String(cause)), |
| 1018 | 1133 | }).pipe(Effect.orDie); |
| 1019 | 1134 | |
| 1020 | − const resolveEnvironment = makeResolver(persistence); | |
| 1135 | + const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); | |
| 1021 | 1136 | const environment = yield* resolveEnvironment({ |
| 1022 | 1137 | workspaceId, |
| 1023 | 1138 | accountId: AccountIdSchema.make("acc_catalog"), |
| @@ -1050,13 +1165,14 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines | ||
| 1050 | 1165 | const persistence = yield* makePersistence; |
| 1051 | 1166 | const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); |
| 1052 | 1167 | const sourceId = SourceIdSchema.make("src_github_discovery"); |
| 1168 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1053 | 1169 | yield* persistConnectedGithubOpenApiSource({ |
| 1054 | − persistence, | |
| 1170 | + runtimeLocalWorkspace, | |
| 1055 | 1171 | workspaceId, |
| 1056 | 1172 | sourceId, |
| 1057 | 1173 | }); |
| 1058 | 1174 | |
| 1059 | − const resolveEnvironment = makeResolver(persistence); | |
| 1175 | + const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); | |
| 1060 | 1176 | const environment = yield* resolveEnvironment({ |
| 1061 | 1177 | workspaceId, |
| 1062 | 1178 | accountId: AccountIdSchema.make("acc_github_discovery"), |
| @@ -1103,14 +1219,15 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines | ||
| 1103 | 1219 | const persistence = yield* makePersistence; |
| 1104 | 1220 | const workspaceId = WorkspaceIdSchema.make("ws_invoke"); |
| 1105 | 1221 | const sourceId = SourceIdSchema.make("src_invoke"); |
| 1222 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1106 | 1223 | yield* persistConnectedEchoTool({ |
| 1107 | − persistence, | |
| 1224 | + runtimeLocalWorkspace, | |
| 1108 | 1225 | endpoint: server.endpoint, |
| 1109 | 1226 | workspaceId, |
| 1110 | 1227 | sourceId, |
| 1111 | 1228 | }); |
| 1112 | 1229 | |
| 1113 | − const resolveEnvironment = makeResolver(persistence); | |
| 1230 | + const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); | |
| 1114 | 1231 | const environment = yield* resolveEnvironment({ |
| 1115 | 1232 | workspaceId, |
| 1116 | 1233 | accountId: AccountIdSchema.make("acc_invoke"), |
| @@ -1139,7 +1256,8 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines | ||
| 1139 | 1256 | const specServer = yield* makeOpenApiSpecServer; |
| 1140 | 1257 | const persistence = yield* makePersistence; |
| 1141 | 1258 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); |
| 1142 | − const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1259 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1260 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace); | |
| 1143 | 1261 | const now = Date.now(); |
| 1144 | 1262 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 1145 | 1263 | "sec_test_openapi_bearer", |
| @@ -1220,21 +1338,22 @@ describe("workspace-execution-environment", () => { · 74 unmodified lines | ||
| 1220 | 1338 | encodeURIComponent("exec_add_openapi:executor.sources.add:"), |
| 1221 | 1339 | ); |
| 1222 | 1340 | |
| 1223 | − const storedSource = | |
| 1224 | − yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1225 | − workspaceId, | |
| 1226 | − added.id, | |
| 1227 | − ); | |
| 1228 | − expect(Option.isSome(storedSource)).toBe(true); | |
| 1229 | − if (Option.isSome(storedSource)) { | |
| 1230 | − const documents = | |
| 1231 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | |
| 1232 | − storedSource.value.recipeRevisionId, | |
| 1233 | − ); | |
| 1234 | − expect(documents[0]?.contentText).toContain( | |
| 1235 | − '"operationId":"repos.getRepo"', | |
| 1236 | − ); | |
| 1237 | − } | |
| 1341 | + const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context); | |
| 1342 | + expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({ | |
| 1343 | + kind: "openapi", | |
| 1344 | + connection: { | |
| 1345 | + endpoint: `${specServer.baseUrl}/`, | |
| 1346 | + }, | |
| 1347 | + }); | |
| 1348 | + const artifact = yield* readLocalSourceArtifact({ | |
| 1349 | + context: runtimeLocalWorkspace.context, | |
| 1350 | + sourceId: added.id, | |
| 1351 | + }); | |
| 1352 | + expect(artifact?.documents[0]?.contentText).toContain( | |
| 1353 | + '"operationId":"repos.getRepo"', | |
| 1354 | + ); | |
| 1355 | + const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); | |
| 1356 | + expect(workspaceState.sources[added.id]?.status).toBe("connected"); | |
| 1238 | 1357 | |
| 1239 | 1358 | const freshEnvironment = yield* resolveEnvironment({ |
| 1240 | 1359 | workspaceId, |
| @@ -1280,7 +1399,8 @@ describe("workspace-execution-environment", () => { · 39 unmodified lines | ||
| 1280 | 1399 | const graphqlServer = yield* makeGraphqlServer; |
| 1281 | 1400 | const persistence = yield* makePersistence; |
| 1282 | 1401 | const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); |
| 1283 | − const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1402 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1403 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace); | |
| 1284 | 1404 | const now = Date.now(); |
| 1285 | 1405 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 1286 | 1406 | "sec_test_graphql_bearer", |
| @@ -1339,33 +1459,28 @@ describe("workspace-execution-environment", () => { · 52 unmodified lines | ||
| 1339 | 1459 | expect(added.auth.kind).toBe("bearer"); |
| 1340 | 1460 | expect(added.auth.token?.providerId).toBe("postgres"); |
| 1341 | 1461 | |
| 1342 | − const storedSource = | |
| 1343 | − yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1344 | − workspaceId, | |
| 1345 | − added.id, | |
| 1346 | − ); | |
| 1347 | − expect(Option.isSome(storedSource)).toBe(true); | |
| 1348 | − if (Option.isSome(storedSource)) { | |
| 1349 | − const documents = | |
| 1350 | − yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | |
| 1351 | − storedSource.value.recipeRevisionId, | |
| 1352 | − ); | |
| 1353 | − expect(documents[0]?.contentText).toContain('"__schema"'); | |
| 1354 | − expect( | |
| 1355 | − (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( | |
| 1356 | − storedSource.value.recipeRevisionId, | |
| 1357 | − )).length, | |
| 1358 | − ).toBe(1); | |
| 1462 | + const artifact = yield* readLocalSourceArtifact({ | |
| 1463 | + context: runtimeLocalWorkspace.context, | |
| 1464 | + sourceId: added.id, | |
| 1465 | + }); | |
| 1466 | + expect(artifact?.documents[0]?.contentText).toContain('"__schema"'); | |
| 1467 | + expect(artifact?.schemaBundles.length).toBe(1); | |
| 1468 | + if (artifact === null) { | |
| 1469 | + throw new Error(`Missing local source artifact for ${added.id}`); | |
| 1359 | 1470 | } |
| 1360 | 1471 | |
| 1361 | − const connectedSourceRecord = Option.getOrThrow(storedSource); | |
| 1362 | − yield* persistence.rows.sourceRecipeRevisions.update( | |
| 1363 | − connectedSourceRecord.recipeRevisionId, | |
| 1364 | − { | |
| 1365 | − manifestJson: null, | |
| 1366 | − updatedAt: Date.now(), | |
| 1472 | + yield* writeLocalSourceArtifact({ | |
| 1473 | + context: runtimeLocalWorkspace.context, | |
| 1474 | + sourceId: added.id, | |
| 1475 | + artifact: { | |
| 1476 | + ...artifact, | |
| 1477 | + revision: { | |
| 1478 | + ...artifact.revision, | |
| 1479 | + manifestJson: null, | |
| 1480 | + updatedAt: Date.now(), | |
| 1481 | + }, | |
| 1367 | 1482 | }, |
| 1368 | − ); | |
| 1483 | + }); | |
| 1369 | 1484 | |
| 1370 | 1485 | const freshEnvironment = yield* resolveEnvironment({ |
| 1371 | 1486 | workspaceId, |
| @@ -1515,9 +1630,10 @@ describe("workspace-execution-environment", () => { · 143 unmodified lines | ||
| 1515 | 1630 | const persistence = yield* makePersistence; |
| 1516 | 1631 | const workspaceId = WorkspaceIdSchema.make("ws_params_auth"); |
| 1517 | 1632 | const sourceId = SourceIdSchema.make("src_params_auth"); |
| 1633 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1518 | 1634 | |
| 1519 | 1635 | yield* persistConnectedGithubOpenApiSource({ |
| 1520 | − persistence, | |
| 1636 | + runtimeLocalWorkspace, | |
| 1521 | 1637 | workspaceId, |
| 1522 | 1638 | sourceId, |
| 1523 | 1639 | endpoint: specServer.baseUrl, |
| @@ -1527,7 +1643,7 @@ describe("workspace-execution-environment", () => { · 3 unmodified lines | ||
| 1527 | 1643 | }, |
| 1528 | 1644 | }); |
| 1529 | 1645 | |
| 1530 | − const resolveEnvironment = makeResolver(persistence); | |
| 1646 | + const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); | |
| 1531 | 1647 | const environment = yield* resolveEnvironment({ |
| 1532 | 1648 | workspaceId, |
| 1533 | 1649 | accountId: AccountIdSchema.make("acc_params_auth"), |
| @@ -1567,14 +1683,15 @@ describe("workspace-execution-environment", () => { · 33 unmodified lines | ||
| 1567 | 1683 | "ws_openapi_describe_bundle", |
| 1568 | 1684 | ); |
| 1569 | 1685 | const sourceId = SourceIdSchema.make("src_openapi_describe_bundle"); |
| 1686 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1570 | 1687 | |
| 1571 | 1688 | yield* persistConnectedGithubOpenApiSource({ |
| 1572 | − persistence, | |
| 1689 | + runtimeLocalWorkspace, | |
| 1573 | 1690 | workspaceId, |
| 1574 | 1691 | sourceId, |
| 1575 | 1692 | }); |
| 1576 | 1693 | |
| 1577 | − const resolveEnvironment = makeResolver(persistence); | |
| 1694 | + const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); | |
| 1578 | 1695 | const environment = yield* resolveEnvironment({ |
| 1579 | 1696 | workspaceId, |
| 1580 | 1697 | accountId: AccountIdSchema.make("acc_openapi_describe_bundle"), |
| @@ -1621,10 +1738,12 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines | ||
| 1621 | 1738 | () => |
| 1622 | 1739 | Effect.gen(function* () { |
| 1623 | 1740 | const persistence = yield* makePersistence; |
| 1624 | − const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1741 | + const workspaceId = WorkspaceIdSchema.make("ws_describe_add_source"); | |
| 1742 | + const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | |
| 1743 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace); | |
| 1625 | 1744 | |
| 1626 | 1745 | const environment = yield* resolveEnvironment({ |
| 1627 | − workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"), | |
| 1746 | + workspaceId, | |
| 1628 | 1747 | accountId: AccountIdSchema.make("acc_describe_add_source"), |
| 1629 | 1748 | executionId: ExecutionIdSchema.make("exec_describe_add_source"), |
| 1630 | 1749 | }); |
| @@ -37,8 +37,10 @@ import { · 36 unmodified lines | ||
| 37 | 37 | type RuntimeSourceAuthService, |
| 38 | 38 | } from "./source-auth-service"; |
| 39 | 39 | import { |
| 40 | + loadSourceWithRecipe, | |
| 40 | 41 | loadWorkspaceSourceRecipeToolByPath, |
| 41 | 42 | loadWorkspaceSourceRecipeToolIndex, |
| 43 | + loadWorkspaceSourceRecipes, | |
| 42 | 44 | type LoadedSourceRecipeToolIndexEntry, |
| 43 | 45 | } from "./source-recipes-runtime"; |
| 44 | 46 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| @@ -53,6 +55,14 @@ import { · 8 unmodified lines | ||
| 53 | 55 | evaluateInvocationPolicy, |
| 54 | 56 | type InvocationDescriptor, |
| 55 | 57 | } from "./invocation-policy-engine"; |
| 58 | +import { | |
| 59 | + loadRuntimeLocalWorkspacePolicies, | |
| 60 | +} from "./policies-operations"; | |
| 61 | +import { | |
| 62 | + getRuntimeLocalWorkspaceOption, | |
| 63 | + RuntimeLocalWorkspaceService, | |
| 64 | + type RuntimeLocalWorkspaceState, | |
| 65 | +} from "./local-runtime-context"; | |
| 56 | 66 | |
| 57 | 67 | const asToolPath = (value: string): ToolPath => value as ToolPath; |
| 58 | 68 | |
| @@ -180,31 +190,38 @@ const loadWorkspaceSchemaBundle = (input: { · 121 unmodified lines | ||
| 180 | 190 | id: SourceRecipeSchemaBundleId; |
| 181 | 191 | }): Effect.Effect<ToolSchemaBundle | null, Error, never> => |
| 182 | 192 | Effect.gen(function* () { |
| 183 | − const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById( | |
| 184 | − input.id, | |
| 185 | − ); | |
| 186 | − if (Option.isNone(bundle)) { | |
| 187 | − return null; | |
| 193 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 194 | + if (runtimeLocalWorkspace !== null) { | |
| 195 | + const recipes = yield* loadWorkspaceSourceRecipes({ | |
| 196 | + rows: input.rows, | |
| 197 | + workspaceId: input.workspaceId, | |
| 198 | + }); | |
| 199 | + const localBundle = recipes | |
| 200 | + .flatMap((recipe) => recipe.schemaBundles) | |
| 201 | + .find((schemaBundle) => schemaBundle.id === input.id); | |
| 202 | + return localBundle | |
| 203 | + ? { | |
| 204 | + id: localBundle.id, | |
| 205 | + kind: localBundle.bundleKind, | |
| 206 | + hash: localBundle.contentHash, | |
| 207 | + refsJson: localBundle.refsJson, | |
| 208 | + } | |
| 209 | + : null; | |
| 188 | 210 | } |
| 189 | 211 | |
| 190 | − const sourceRecords = yield* input.rows.sources.listByWorkspaceId( | |
| 191 | − input.workspaceId, | |
| 192 | − ); | |
| 193 | − if ( | |
| 194 | − !sourceRecords.some( | |
| 195 | − (sourceRecord) => | |
| 196 | − sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId, | |
| 197 | − ) | |
| 198 | − ) { | |
| 199 | − return null; | |
| 212 | + const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(input.id); | |
| 213 | + if (Option.isSome(bundle)) { | |
| 214 | + const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | |
| 215 | + if (sourceRecords.some((sourceRecord) => sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId)) { | |
| 216 | + return { | |
| 217 | + id: bundle.value.id, | |
| 218 | + kind: bundle.value.bundleKind, | |
| 219 | + hash: bundle.value.contentHash, | |
| 220 | + refsJson: bundle.value.refsJson, | |
| 221 | + }; | |
| 222 | + } | |
| 200 | 223 | } |
| 201 | − | |
| 202 | − return { | |
| 203 | − id: bundle.value.id, | |
| 204 | − kind: bundle.value.bundleKind, | |
| 205 | − hash: bundle.value.contentHash, | |
| 206 | − refsJson: bundle.value.refsJson, | |
| 207 | − }; | |
| 224 | + return null; | |
| 208 | 225 | }); |
| 209 | 226 | |
| 210 | 227 | const scoreRecipeTool = ( |
| @@ -373,41 +390,18 @@ const authorizePersistedToolInvocation = (input: { · 162 unmodified lines | ||
| 373 | 390 | >[0]["onElicitation"]; |
| 374 | 391 | }): Effect.Effect<void, Error, never> => |
| 375 | 392 | Effect.gen(function* () { |
| 376 | − const workspace = yield* input.rows.workspaces | |
| 377 | − .getById(input.workspaceId) | |
| 378 | − .pipe( | |
| 379 | − Effect.mapError((cause) => | |
| 380 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 381 | − ), | |
| 382 | − ); | |
| 383 | − const policies = Option.isSome(workspace) | |
| 384 | − ? yield* input.rows.policies | |
| 385 | − .listForWorkspaceContext({ | |
| 386 | − organizationId: workspace.value.organizationId, | |
| 387 | − workspaceId: input.workspaceId, | |
| 388 | − }) | |
| 389 | − .pipe( | |
| 390 | − Effect.mapError((cause) => | |
| 391 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 392 | − ), | |
| 393 | − ) | |
| 394 | − : []; | |
| 393 | + const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( | |
| 394 | + Effect.mapError((cause) => | |
| 395 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 396 | + ), | |
| 397 | + ); | |
| 395 | 398 | |
| 396 | 399 | const decision = evaluateInvocationPolicy({ |
| 397 | 400 | descriptor: input.descriptor, |
| 398 | 401 | args: input.args, |
| 399 | − policies, | |
| 402 | + policies: localWorkspacePolicies.policies, | |
| 400 | 403 | context: { |
| 401 | 404 | workspaceId: input.workspaceId, |
| 402 | − organizationId: Option.isSome(workspace) | |
| 403 | − ? workspace.value.organizationId | |
| 404 | − : ("org_unknown" as never), | |
| 405 | − accountId: input.accountId, | |
| 406 | − clientId: | |
| 407 | − typeof input.context?.clientId === "string" && | |
| 408 | − input.context.clientId.length > 0 | |
| 409 | − ? input.context.clientId | |
| 410 | − : null, | |
| 411 | 405 | }, |
| 412 | 406 | }); |
| 413 | 407 | |
| @@ -471,14 +465,28 @@ const authorizePersistedToolInvocation = (input: { · 57 unmodified lines | ||
| 471 | 465 | } |
| 472 | 466 | }); |
| 473 | 467 | |
| 468 | +const provideRuntimeLocalWorkspace = <A, E, R>( | |
| 469 | + effect: Effect.Effect<A, E, R>, | |
| 470 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null, | |
| 471 | +): Effect.Effect<A, E, R> => | |
| 472 | + runtimeLocalWorkspace === null | |
| 473 | + ? effect | |
| 474 | + : effect.pipe( | |
| 475 | + Effect.provideService( | |
| 476 | + RuntimeLocalWorkspaceService, | |
| 477 | + runtimeLocalWorkspace, | |
| 478 | + ), | |
| 479 | + ); | |
| 480 | + | |
| 474 | 481 | const createWorkspaceToolCatalog = (input: { |
| 475 | 482 | workspaceId: Source["workspaceId"]; |
| 476 | 483 | accountId: AccountId; |
| 477 | 484 | rows: SqlControlPlaneRows; |
| 478 | 485 | executorCatalog: ToolCatalog; |
| 486 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | |
| 479 | 487 | }): ToolCatalog => ({ |
| 480 | 488 | listNamespaces: ({ limit }) => |
| 481 | − Effect.gen(function* () { | |
| 489 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 482 | 490 | const [recipeTools, executor] = yield* Effect.all([ |
| 483 | 491 | loadWorkspaceRecipeTools({ |
| 484 | 492 | rows: input.rows, |
| @@ -513,10 +521,10 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines | ||
| 513 | 521 | return [...merged.values()] |
| 514 | 522 | .sort((left, right) => left.namespace.localeCompare(right.namespace)) |
| 515 | 523 | .slice(0, limit); |
| 516 | − }), | |
| 524 | + }), input.runtimeLocalWorkspace), | |
| 517 | 525 | |
| 518 | 526 | listTools: ({ namespace, query, limit, includeSchemas = false }) => |
| 519 | − Effect.gen(function* () { | |
| 527 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 520 | 528 | const [recipeTools, executor] = yield* Effect.all([ |
| 521 | 529 | loadWorkspaceRecipeTools({ |
| 522 | 530 | rows: input.rows, |
| @@ -549,10 +557,10 @@ const createWorkspaceToolCatalog = (input: { · 26 unmodified lines | ||
| 549 | 557 | return [...recipeTools.map((tool) => tool.descriptor), ...executor] |
| 550 | 558 | .sort((left, right) => left.path.localeCompare(right.path)) |
| 551 | 559 | .slice(0, limit); |
| 552 | − }), | |
| 560 | + }), input.runtimeLocalWorkspace), | |
| 553 | 561 | |
| 554 | 562 | getToolByPath: ({ path, includeSchemas }) => |
| 555 | − Effect.gen(function* () { | |
| 563 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 556 | 564 | const executor = yield* input.executorCatalog.getToolByPath({ |
| 557 | 565 | path, |
| 558 | 566 | includeSchemas, |
| @@ -569,17 +577,17 @@ const createWorkspaceToolCatalog = (input: { · 10 unmodified lines | ||
| 569 | 577 | includeSchemas, |
| 570 | 578 | }); |
| 571 | 579 | return recipeTool?.descriptor ?? null; |
| 572 | − }), | |
| 580 | + }), input.runtimeLocalWorkspace), | |
| 573 | 581 | |
| 574 | 582 | getSchemaBundle: ({ id }) => |
| 575 | − loadWorkspaceSchemaBundle({ | |
| 583 | + provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({ | |
| 576 | 584 | rows: input.rows, |
| 577 | 585 | workspaceId: input.workspaceId, |
| 578 | 586 | id: SourceRecipeSchemaBundleIdSchema.make(id), |
| 579 | − }), | |
| 587 | + }), input.runtimeLocalWorkspace), | |
| 580 | 588 | |
| 581 | 589 | searchTools: ({ query, namespace, limit }) => |
| 582 | − Effect.gen(function* () { | |
| 590 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 583 | 591 | const queryTokens = tokenize(query); |
| 584 | 592 | const [recipeTools, executor] = yield* Effect.all([ |
| 585 | 593 | loadWorkspaceRecipeTools({ |
| @@ -614,7 +622,7 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines | ||
| 614 | 622 | right.score - left.score || left.path.localeCompare(right.path), |
| 615 | 623 | ) |
| 616 | 624 | .slice(0, limit); |
| 617 | − }), | |
| 625 | + }), input.runtimeLocalWorkspace), | |
| 618 | 626 | }); |
| 619 | 627 | |
| 620 | 628 | const createWorkspaceToolInvoker = (input: { |
| @@ -623,6 +631,7 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines | ||
| 623 | 631 | rows: SqlControlPlaneRows; |
| 624 | 632 | resolveSecretMaterial: ResolveSecretMaterial; |
| 625 | 633 | sourceAuthService: RuntimeSourceAuthService; |
| 634 | + runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | |
| 626 | 635 | onElicitation?: Parameters< |
| 627 | 636 | typeof makeToolInvokerFromTools |
| 628 | 637 | >[0]["onElicitation"]; |
| @@ -634,6 +643,7 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines | ||
| 634 | 643 | workspaceId: input.workspaceId, |
| 635 | 644 | accountId: input.accountId, |
| 636 | 645 | sourceAuthService: input.sourceAuthService, |
| 646 | + runtimeLocalWorkspace: input.runtimeLocalWorkspace, | |
| 637 | 647 | }); |
| 638 | 648 | const executorCatalog = createToolCatalogFromTools({ |
| 639 | 649 | tools: executorTools, |
| @@ -643,6 +653,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines | ||
| 643 | 653 | accountId: input.accountId, |
| 644 | 654 | rows: input.rows, |
| 645 | 655 | executorCatalog, |
| 656 | + runtimeLocalWorkspace: input.runtimeLocalWorkspace, | |
| 646 | 657 | }); |
| 647 | 658 | const systemTools = createSystemToolMap({ catalog }); |
| 648 | 659 | const systemToolPaths = new Set(Object.keys(systemTools)); |
| @@ -661,7 +672,7 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines | ||
| 661 | 672 | args: unknown; |
| 662 | 673 | context?: Record<string, unknown>; |
| 663 | 674 | }) => |
| 664 | − Effect.gen(function* () { | |
| 675 | + provideRuntimeLocalWorkspace(Effect.gen(function* () { | |
| 665 | 676 | const recipeTool = yield* loadWorkspaceRecipeToolByPath({ |
| 666 | 677 | rows: input.rows, |
| 667 | 678 | workspaceId: input.workspaceId, |
| @@ -700,17 +711,12 @@ const createWorkspaceToolInvoker = (input: { · 32 unmodified lines | ||
| 700 | 711 | id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId), |
| 701 | 712 | }) |
| 702 | 713 | : null; |
| 703 | − const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | |
| 704 | − input.workspaceId, | |
| 705 | − recipeTool.source.id, | |
| 706 | − ); | |
| 707 | − let manifestJson: string | null = null; | |
| 708 | − if (Option.isSome(sourceRecord)) { | |
| 709 | − const revision = yield* input.rows.sourceRecipeRevisions.getById( | |
| 710 | − sourceRecord.value.recipeRevisionId, | |
| 711 | − ); | |
| 712 | − manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null; | |
| 713 | − } | |
| 714 | + const recipe = yield* loadSourceWithRecipe({ | |
| 715 | + rows: input.rows, | |
| 716 | + workspaceId: input.workspaceId, | |
| 717 | + sourceId: recipeTool.source.id, | |
| 718 | + actorAccountId: input.accountId, | |
| 719 | + }); | |
| 714 | 720 | |
| 715 | 721 | return yield* getSourceAdapterForOperation(recipeTool.operation) |
| 716 | 722 | .invokePersistedTool({ |
| @@ -721,23 +727,26 @@ const createWorkspaceToolInvoker = (input: { · 4 unmodified lines | ||
| 721 | 727 | path: invocation.path, |
| 722 | 728 | operation: recipeTool.operation, |
| 723 | 729 | schemaBundle, |
| 724 | − manifestJson, | |
| 730 | + manifestJson: recipe.revision.manifestJson, | |
| 725 | 731 | auth, |
| 726 | 732 | args: invocation.args, |
| 727 | 733 | context: invocation.context, |
| 728 | 734 | onElicitation: input.onElicitation, |
| 729 | 735 | }); |
| 730 | − }); | |
| 736 | + }), input.runtimeLocalWorkspace); | |
| 731 | 737 | |
| 732 | 738 | return { |
| 733 | 739 | catalog, |
| 734 | 740 | toolInvoker: { |
| 735 | 741 | invoke: ({ path, args, context }) => |
| 736 | − systemToolPaths.has(path) | |
| 737 | − ? systemInvoker.invoke({ path, args, context }) | |
| 738 | − : executorToolPaths.has(path) | |
| 739 | − ? executorInvoker.invoke({ path, args, context }) | |
| 740 | − : invokePersistedTool({ path, args, context }), | |
| 742 | + provideRuntimeLocalWorkspace( | |
| 743 | + systemToolPaths.has(path) | |
| 744 | + ? systemInvoker.invoke({ path, args, context }) | |
| 745 | + : executorToolPaths.has(path) | |
| 746 | + ? executorInvoker.invoke({ path, args, context }) | |
| 747 | + : invokePersistedTool({ path, args, context }), | |
| 748 | + input.runtimeLocalWorkspace, | |
| 749 | + ), | |
| 741 | 750 | }, |
| 742 | 751 | }; |
| 743 | 752 | }; |
| @@ -754,13 +763,15 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 10 unmodified lines | ||
| 754 | 763 | }); |
| 755 | 764 | |
| 756 | 765 | return ({ workspaceId, accountId, onElicitation }) => |
| 757 | − Effect.sync(() => { | |
| 766 | + Effect.gen(function* () { | |
| 767 | + const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | |
| 758 | 768 | const { catalog, toolInvoker } = createWorkspaceToolInvoker({ |
| 759 | 769 | workspaceId, |
| 760 | 770 | accountId, |
| 761 | 771 | rows: input.rows, |
| 762 | 772 | resolveSecretMaterial, |
| 763 | 773 | sourceAuthService: input.sourceAuthService, |
| 774 | + runtimeLocalWorkspace, | |
| 764 | 775 | onElicitation, |
| 765 | 776 | }); |
| 766 | 777 | |
| @@ -1,13 +1,3 @@ | ||
| 1 | −export { | |
| 2 | − OrganizationStatusSchema, | |
| 3 | − type OrganizationStatus, | |
| 4 | −} from "./models/auth/organization"; | |
| 5 | −export { | |
| 6 | − OrganizationMemberStatusSchema, | |
| 7 | − RoleSchema, | |
| 8 | − type OrganizationMemberStatus, | |
| 9 | − type Role, | |
| 10 | −} from "./models/auth/organization-membership"; | |
| 11 | 1 | export { |
| 12 | 2 | SourceAuthSchema, |
| 13 | 3 | SourceBindingSchema, |
| @@ -72,12 +62,8 @@ export { · 58 unmodified lines | ||
| 72 | 62 | type SourceAuthSessionStatus, |
| 73 | 63 | } from "./models/source-auth-session"; |
| 74 | 64 | export { |
| 75 | − PolicyApprovalModeSchema, | |
| 76 | − PolicyEffectSchema, | |
| 77 | − PolicyMatchTypeSchema, | |
| 78 | − PolicyResourceTypeSchema, | |
| 79 | − type PolicyApprovalMode, | |
| 80 | − type PolicyEffect, | |
| 81 | − type PolicyMatchType, | |
| 82 | − type PolicyResourceType, | |
| 65 | + LocalWorkspacePolicyApprovalModeSchema, | |
| 66 | + LocalWorkspacePolicyEffectSchema, | |
| 67 | + type LocalWorkspacePolicyApprovalMode, | |
| 68 | + type LocalWorkspacePolicyEffect, | |
| 83 | 69 | } from "./models/policy"; |
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | |
| 3 | 3 | export const AccountIdSchema = Schema.String.pipe(Schema.brand("AccountId")); |
| 4 | −export const OrganizationIdSchema = Schema.String.pipe(Schema.brand("OrganizationId")); | |
| 5 | −export const OrganizationMemberIdSchema = Schema.String.pipe( | |
| 6 | − Schema.brand("OrganizationMemberId"), | |
| 7 | −); | |
| 8 | 4 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); |
| 9 | 5 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); |
| 10 | 6 | export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId")); |
| @@ -31,15 +27,12 @@ export const SecretMaterialIdSchema = Schema.String.pipe( · 20 unmodified lines | ||
| 31 | 27 | Schema.brand("SecretMaterialId"), |
| 32 | 28 | ); |
| 33 | 29 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); |
| 34 | −export const InstallationIdSchema = Schema.String.pipe(Schema.brand("InstallationId")); | |
| 35 | 30 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); |
| 36 | 31 | export const ExecutionInteractionIdSchema = Schema.String.pipe( |
| 37 | 32 | Schema.brand("ExecutionInteractionId"), |
| 38 | 33 | ); |
| 39 | 34 | |
| 40 | 35 | export type AccountId = typeof AccountIdSchema.Type; |
| 41 | −export type OrganizationId = typeof OrganizationIdSchema.Type; | |
| 42 | −export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; | |
| 43 | 36 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 44 | 37 | export type SourceId = typeof SourceIdSchema.Type; |
| 45 | 38 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; |
| @@ -52,6 +45,5 @@ export type CredentialId = typeof CredentialIdSchema.Type; · 6 unmodified lines | ||
| 52 | 45 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; |
| 53 | 46 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 54 | 47 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 55 | −export type InstallationId = typeof InstallationIdSchema.Type; | |
| 56 | 48 | export type ExecutionId = typeof ExecutionIdSchema.Type; |
| 57 | 49 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; |
| @@ -3,7 +3,6 @@ export * from "./enums"; · 2 unmodified lines | ||
| 3 | 3 | export * from "./ids"; |
| 4 | 4 | |
| 5 | 5 | export * from "./models/auth"; |
| 6 | −export * from "./models/workspace"; | |
| 7 | 6 | export * from "./models/source"; |
| 8 | 7 | export * from "./models/source-recipe"; |
| 9 | 8 | export * from "./models/auth-artifact"; |
| @@ -13,6 +12,7 @@ export * from "./models/secret-material"; · 3 unmodified lines | ||
| 13 | 12 | export * from "./models/source-auth-session"; |
| 14 | 13 | export * from "./models/policy"; |
| 15 | 14 | export * from "./models/local-installation"; |
| 15 | +export * from "./models/local-config"; | |
| 16 | 16 | export * from "./models/execution"; |
| 17 | 17 | export * from "./models/source-inspection"; |
| 18 | 18 | export * from "./models/source-discovery"; |
| @@ -1,7 +1,2 @@ | ||
| 1 | 1 | export * from "./account"; |
| 2 | −export * from "./membership"; | |
| 3 | −export * from "./organization"; | |
| 4 | −export * from "./organization-membership"; | |
| 5 | −export * from "./permission"; | |
| 6 | 2 | export * from "./principal"; |
| 7 | −export * from "./role"; | |
| @@ -1,24 +0,0 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 2 | − | |
| 3 | −import { TimestampMsSchema } from "../../common"; | |
| 4 | −import { AccountIdSchema, WorkspaceIdSchema } from "../../ids"; | |
| 5 | − | |
| 6 | −import { RoleSchema } from "./organization-membership"; | |
| 7 | − | |
| 8 | −export const MembershipStatusSchema = Schema.Literal( | |
| 9 | − "active", | |
| 10 | − "suspended", | |
| 11 | − "revoked", | |
| 12 | −); | |
| 13 | − | |
| 14 | −export const WorkspaceMembershipSchema = Schema.Struct({ | |
| 15 | − accountId: AccountIdSchema, | |
| 16 | − workspaceId: WorkspaceIdSchema, | |
| 17 | − role: RoleSchema, | |
| 18 | − status: MembershipStatusSchema, | |
| 19 | − grantedAt: TimestampMsSchema, | |
| 20 | − updatedAt: TimestampMsSchema, | |
| 21 | −}); | |
| 22 | − | |
| 23 | −export type MembershipStatus = typeof MembershipStatusSchema.Type; | |
| 24 | −export type WorkspaceMembership = typeof WorkspaceMembershipSchema.Type; | |
| @@ -1,55 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − createInsertSchema, | |
| 3 | − createSelectSchema, | |
| 4 | − createUpdateSchema, | |
| 5 | −} from "drizzle-orm/effect-schema"; | |
| 6 | −import { Schema } from "effect"; | |
| 7 | − | |
| 8 | −import { organizationMembershipsTable } from "../../../persistence/schema"; | |
| 9 | −import { TimestampMsSchema } from "../../common"; | |
| 10 | −import { | |
| 11 | − AccountIdSchema, | |
| 12 | − OrganizationIdSchema, | |
| 13 | − OrganizationMemberIdSchema, | |
| 14 | −} from "../../ids"; | |
| 15 | − | |
| 16 | −export const RoleSchema = Schema.Literal("viewer", "editor", "admin", "owner"); | |
| 17 | − | |
| 18 | −export const OrganizationMemberStatusSchema = Schema.Literal( | |
| 19 | − "invited", | |
| 20 | − "active", | |
| 21 | − "suspended", | |
| 22 | − "removed", | |
| 23 | −); | |
| 24 | − | |
| 25 | −const organizationMembershipSchemaOverrides = { | |
| 26 | − id: OrganizationMemberIdSchema, | |
| 27 | − organizationId: OrganizationIdSchema, | |
| 28 | − accountId: AccountIdSchema, | |
| 29 | − role: RoleSchema, | |
| 30 | − status: OrganizationMemberStatusSchema, | |
| 31 | − billable: Schema.Boolean, | |
| 32 | − invitedByAccountId: Schema.NullOr(AccountIdSchema), | |
| 33 | − joinedAt: Schema.NullOr(TimestampMsSchema), | |
| 34 | − createdAt: TimestampMsSchema, | |
| 35 | − updatedAt: TimestampMsSchema, | |
| 36 | −} as const; | |
| 37 | − | |
| 38 | −export const OrganizationMembershipSchema = createSelectSchema( | |
| 39 | − organizationMembershipsTable, | |
| 40 | − organizationMembershipSchemaOverrides, | |
| 41 | −); | |
| 42 | − | |
| 43 | −export const OrganizationMembershipInsertSchema = createInsertSchema( | |
| 44 | − organizationMembershipsTable, | |
| 45 | − organizationMembershipSchemaOverrides, | |
| 46 | −); | |
| 47 | − | |
| 48 | −export const OrganizationMembershipUpdateSchema = createUpdateSchema( | |
| 49 | − organizationMembershipsTable, | |
| 50 | − organizationMembershipSchemaOverrides, | |
| 51 | −); | |
| 52 | − | |
| 53 | −export type Role = typeof RoleSchema.Type; | |
| 54 | −export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type; | |
| 55 | −export type OrganizationMembership = typeof OrganizationMembershipSchema.Type; | |
| @@ -1,42 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − createInsertSchema, | |
| 3 | − createSelectSchema, | |
| 4 | − createUpdateSchema, | |
| 5 | −} from "drizzle-orm/effect-schema"; | |
| 6 | −import { Schema } from "effect"; | |
| 7 | − | |
| 8 | −import { organizationsTable } from "../../../persistence/schema"; | |
| 9 | −import { TimestampMsSchema } from "../../common"; | |
| 10 | −import { AccountIdSchema, OrganizationIdSchema } from "../../ids"; | |
| 11 | − | |
| 12 | −export const OrganizationStatusSchema = Schema.Literal( | |
| 13 | − "active", | |
| 14 | − "suspended", | |
| 15 | − "archived", | |
| 16 | −); | |
| 17 | − | |
| 18 | −const organizationSchemaOverrides = { | |
| 19 | − id: OrganizationIdSchema, | |
| 20 | − status: OrganizationStatusSchema, | |
| 21 | − createdByAccountId: Schema.NullOr(AccountIdSchema), | |
| 22 | − createdAt: TimestampMsSchema, | |
| 23 | − updatedAt: TimestampMsSchema, | |
| 24 | −} as const; | |
| 25 | − | |
| 26 | −export const OrganizationSchema = createSelectSchema( | |
| 27 | − organizationsTable, | |
| 28 | − organizationSchemaOverrides, | |
| 29 | −); | |
| 30 | − | |
| 31 | −export const OrganizationInsertSchema = createInsertSchema( | |
| 32 | − organizationsTable, | |
| 33 | − organizationSchemaOverrides, | |
| 34 | −); | |
| 35 | − | |
| 36 | −export const OrganizationUpdateSchema = createUpdateSchema( | |
| 37 | − organizationsTable, | |
| 38 | − organizationSchemaOverrides, | |
| 39 | −); | |
| 40 | − | |
| 41 | −export type OrganizationStatus = typeof OrganizationStatusSchema.Type; | |
| 42 | −export type Organization = typeof OrganizationSchema.Type; | |
| @@ -1,36 +0,0 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 2 | − | |
| 3 | −const permissionDomainActions = { | |
| 4 | − organizations: ["read", "manage"], | |
| 5 | − memberships: ["read", "write", "manage"], | |
| 6 | − workspace: ["read", "manage"], | |
| 7 | − sources: ["read", "write", "manage"], | |
| 8 | − policies: ["read", "write", "manage"], | |
| 9 | −} as const; | |
| 10 | − | |
| 11 | −export const PermissionDomainActions = permissionDomainActions; | |
| 12 | − | |
| 13 | −export type PermissionDomain = keyof typeof permissionDomainActions; | |
| 14 | −type PermissionAction<D extends PermissionDomain> = | |
| 15 | − (typeof permissionDomainActions)[D][number]; | |
| 16 | − | |
| 17 | −export type Permission = { | |
| 18 | − [D in PermissionDomain]: `${D}:${PermissionAction<D>}`; | |
| 19 | −}[PermissionDomain]; | |
| 20 | − | |
| 21 | −export const PermissionValues: ReadonlyArray<Permission> = Object.entries( | |
| 22 | − permissionDomainActions, | |
| 23 | −).flatMap(([domain, actions]) => | |
| 24 | − actions.map((action) => `${domain}:${action}` as Permission), | |
| 25 | −); | |
| 26 | − | |
| 27 | −const permissionSet = new Set<string>(PermissionValues); | |
| 28 | − | |
| 29 | −const isPermission = (value: string): value is Permission => | |
| 30 | − permissionSet.has(value); | |
| 31 | − | |
| 32 | −export const PermissionSchema = Schema.String.pipe( | |
| 33 | − Schema.filter(isPermission, { | |
| 34 | − message: () => "Invalid permission", | |
| 35 | − }), | |
| 36 | −); | |
| @@ -1,32 +0,0 @@ | ||
| 1 | −import { Schema } from "effect"; | |
| 2 | − | |
| 3 | −import { RoleSchema } from "./organization-membership"; | |
| 4 | −import { | |
| 5 | − PermissionSchema, | |
| 6 | − PermissionValues, | |
| 7 | − type Permission, | |
| 8 | −} from "./permission"; | |
| 9 | − | |
| 10 | −type Role = typeof RoleSchema.Type; | |
| 11 | − | |
| 12 | −const readOnlyPermissions: ReadonlyArray<Permission> = PermissionValues.filter( | |
| 13 | − (permission) => permission.endsWith(":read"), | |
| 14 | −); | |
| 15 | − | |
| 16 | −const writePermissions: ReadonlyArray<Permission> = PermissionValues.filter( | |
| 17 | − (permission) => permission.endsWith(":write"), | |
| 18 | −); | |
| 19 | − | |
| 20 | −export const RolePermissions = { | |
| 21 | − viewer: readOnlyPermissions, | |
| 22 | − editor: [...readOnlyPermissions, ...writePermissions], | |
| 23 | − admin: PermissionValues, | |
| 24 | − owner: PermissionValues, | |
| 25 | −} as const satisfies Record<Role, ReadonlyArray<Permission>>; | |
| 26 | − | |
| 27 | −export const RolePermissionsSchema = Schema.Record({ | |
| 28 | − key: RoleSchema, | |
| 29 | − value: Schema.Array(PermissionSchema), | |
| 30 | −}); | |
| 31 | − | |
| 32 | −export type RolePermissions = typeof RolePermissionsSchema.Type; | |
| @@ -0,0 +1,185 @@ | ||
| 1 | +import { Schema } from "effect"; | |
| 2 | + | |
| 3 | +import { SourceTransportSchema, StringMapSchema } from "./source"; | |
| 4 | + | |
| 5 | +export const LocalConfigSecretProviderSourceSchema = Schema.Literal( | |
| 6 | + "env", | |
| 7 | + "file", | |
| 8 | + "exec", | |
| 9 | + "params", | |
| 10 | +); | |
| 11 | + | |
| 12 | +export const LocalConfigEnvSecretProviderSchema = Schema.Struct({ | |
| 13 | + source: Schema.Literal("env"), | |
| 14 | +}); | |
| 15 | + | |
| 16 | +export const LocalConfigFileSecretProviderModeSchema = Schema.Literal( | |
| 17 | + "singleValue", | |
| 18 | + "json", | |
| 19 | +); | |
| 20 | + | |
| 21 | +export const LocalConfigFileSecretProviderSchema = Schema.Struct({ | |
| 22 | + source: Schema.Literal("file"), | |
| 23 | + path: Schema.String, | |
| 24 | + mode: Schema.optional(LocalConfigFileSecretProviderModeSchema), | |
| 25 | +}); | |
| 26 | + | |
| 27 | +export const LocalConfigExecSecretProviderSchema = Schema.Struct({ | |
| 28 | + source: Schema.Literal("exec"), | |
| 29 | + command: Schema.String, | |
| 30 | + args: Schema.optional(Schema.Array(Schema.String)), | |
| 31 | + env: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.String })), | |
| 32 | + allowSymlinkCommand: Schema.optional(Schema.Boolean), | |
| 33 | + trustedDirs: Schema.optional(Schema.Array(Schema.String)), | |
| 34 | +}); | |
| 35 | + | |
| 36 | +export const LocalConfigSecretProviderSchema = Schema.Union( | |
| 37 | + LocalConfigEnvSecretProviderSchema, | |
| 38 | + LocalConfigFileSecretProviderSchema, | |
| 39 | + LocalConfigExecSecretProviderSchema, | |
| 40 | +); | |
| 41 | + | |
| 42 | +export const LocalConfigExplicitSecretRefSchema = Schema.Struct({ | |
| 43 | + source: LocalConfigSecretProviderSourceSchema, | |
| 44 | + provider: Schema.String, | |
| 45 | + id: Schema.String, | |
| 46 | +}); | |
| 47 | + | |
| 48 | +export const LocalConfigSecretInputSchema = Schema.Union( | |
| 49 | + Schema.String, | |
| 50 | + LocalConfigExplicitSecretRefSchema, | |
| 51 | +); | |
| 52 | + | |
| 53 | +export const LocalConfigSourceConnectionSchema = Schema.Struct({ | |
| 54 | + endpoint: Schema.String, | |
| 55 | + auth: Schema.optional(LocalConfigSecretInputSchema), | |
| 56 | +}); | |
| 57 | + | |
| 58 | +export const LocalConfigOpenApiBindingSchema = Schema.Struct({ | |
| 59 | + specUrl: Schema.String, | |
| 60 | + defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 61 | +}); | |
| 62 | + | |
| 63 | +export const LocalConfigGraphqlBindingSchema = Schema.Struct({ | |
| 64 | + defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 65 | +}); | |
| 66 | + | |
| 67 | +export const LocalConfigMcpBindingSchema = Schema.Struct({ | |
| 68 | + transport: Schema.optional(Schema.NullOr(SourceTransportSchema)), | |
| 69 | + queryParams: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 70 | + headers: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 71 | +}); | |
| 72 | + | |
| 73 | +export const LocalConfigGoogleDiscoveryBindingSchema = Schema.Struct({ | |
| 74 | + service: Schema.String, | |
| 75 | + version: Schema.String, | |
| 76 | + discoveryUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 77 | + defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | |
| 78 | + scopes: Schema.optional(Schema.Array(Schema.String)), | |
| 79 | +}); | |
| 80 | + | |
| 81 | +const LocalConfigSourceEntryBaseSchema = Schema.Struct({ | |
| 82 | + name: Schema.optional(Schema.String), | |
| 83 | + namespace: Schema.optional(Schema.String), | |
| 84 | + enabled: Schema.optional(Schema.Boolean), | |
| 85 | + connection: LocalConfigSourceConnectionSchema, | |
| 86 | +}); | |
| 87 | + | |
| 88 | +export const LocalConfigOpenApiSourceSchema = Schema.extend( | |
| 89 | + LocalConfigSourceEntryBaseSchema, | |
| 90 | + Schema.Struct({ | |
| 91 | + kind: Schema.Literal("openapi"), | |
| 92 | + binding: LocalConfigOpenApiBindingSchema, | |
| 93 | + }), | |
| 94 | +); | |
| 95 | + | |
| 96 | +export const LocalConfigGraphqlSourceSchema = Schema.extend( | |
| 97 | + LocalConfigSourceEntryBaseSchema, | |
| 98 | + Schema.Struct({ | |
| 99 | + kind: Schema.Literal("graphql"), | |
| 100 | + binding: LocalConfigGraphqlBindingSchema, | |
| 101 | + }), | |
| 102 | +); | |
| 103 | + | |
| 104 | +export const LocalConfigMcpSourceSchema = Schema.extend( | |
| 105 | + LocalConfigSourceEntryBaseSchema, | |
| 106 | + Schema.Struct({ | |
| 107 | + kind: Schema.Literal("mcp"), | |
| 108 | + binding: LocalConfigMcpBindingSchema, | |
| 109 | + }), | |
| 110 | +); | |
| 111 | + | |
| 112 | +export const LocalConfigGoogleDiscoverySourceSchema = Schema.extend( | |
| 113 | + LocalConfigSourceEntryBaseSchema, | |
| 114 | + Schema.Struct({ | |
| 115 | + kind: Schema.Literal("google_discovery"), | |
| 116 | + binding: LocalConfigGoogleDiscoveryBindingSchema, | |
| 117 | + }), | |
| 118 | +); | |
| 119 | + | |
| 120 | +export const LocalConfigSourceSchema = Schema.Union( | |
| 121 | + LocalConfigOpenApiSourceSchema, | |
| 122 | + LocalConfigGraphqlSourceSchema, | |
| 123 | + LocalConfigMcpSourceSchema, | |
| 124 | + LocalConfigGoogleDiscoverySourceSchema, | |
| 125 | +); | |
| 126 | + | |
| 127 | +export const LocalConfigPolicyActionSchema = Schema.Literal("allow", "deny"); | |
| 128 | +export const LocalConfigPolicyApprovalSchema = Schema.Literal("auto", "manual"); | |
| 129 | + | |
| 130 | +export const LocalConfigPolicySchema = Schema.Struct({ | |
| 131 | + match: Schema.String, | |
| 132 | + action: LocalConfigPolicyActionSchema, | |
| 133 | + approval: LocalConfigPolicyApprovalSchema, | |
| 134 | + enabled: Schema.optional(Schema.Boolean), | |
| 135 | + priority: Schema.optional(Schema.Number), | |
| 136 | +}); | |
| 137 | + | |
| 138 | +export const LocalConfigSecretsSchema = Schema.Struct({ | |
| 139 | + providers: Schema.optional( | |
| 140 | + Schema.Record({ | |
| 141 | + key: Schema.String, | |
| 142 | + value: LocalConfigSecretProviderSchema, | |
| 143 | + }), | |
| 144 | + ), | |
| 145 | + defaults: Schema.optional( | |
| 146 | + Schema.Struct({ | |
| 147 | + env: Schema.optional(Schema.String), | |
| 148 | + file: Schema.optional(Schema.String), | |
| 149 | + exec: Schema.optional(Schema.String), | |
| 150 | + }), | |
| 151 | + ), | |
| 152 | +}); | |
| 153 | + | |
| 154 | +export const LocalConfigWorkspaceSchema = Schema.Struct({ | |
| 155 | + name: Schema.optional(Schema.String), | |
| 156 | +}); | |
| 157 | + | |
| 158 | +export const LocalExecutorConfigSchema = Schema.Struct({ | |
| 159 | + workspace: Schema.optional(LocalConfigWorkspaceSchema), | |
| 160 | + sources: Schema.optional( | |
| 161 | + Schema.Record({ | |
| 162 | + key: Schema.String, | |
| 163 | + value: LocalConfigSourceSchema, | |
| 164 | + }), | |
| 165 | + ), | |
| 166 | + policies: Schema.optional( | |
| 167 | + Schema.Record({ | |
| 168 | + key: Schema.String, | |
| 169 | + value: LocalConfigPolicySchema, | |
| 170 | + }), | |
| 171 | + ), | |
| 172 | + secrets: Schema.optional(LocalConfigSecretsSchema), | |
| 173 | +}); | |
| 174 | + | |
| 175 | +export type LocalConfigSecretProviderSource = | |
| 176 | + typeof LocalConfigSecretProviderSourceSchema.Type; | |
| 177 | +export type LocalConfigSecretProvider = | |
| 178 | + typeof LocalConfigSecretProviderSchema.Type; | |
| 179 | +export type LocalConfigExplicitSecretRef = | |
| 180 | + typeof LocalConfigExplicitSecretRefSchema.Type; | |
| 181 | +export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type; | |
| 182 | +export type LocalConfigSource = typeof LocalConfigSourceSchema.Type; | |
| 183 | +export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type; | |
| 184 | +export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type; | |
| 185 | +export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type; | |
| @@ -1,40 +1,16 @@ | ||
| 1 | −import { | |
| 2 | − createInsertSchema, | |
| 3 | − createSelectSchema, | |
| 4 | − createUpdateSchema, | |
| 5 | −} from "drizzle-orm/effect-schema"; | |
| 1 | +import * as Schema from "effect/Schema"; | |
| 6 | 2 | |
| 7 | −import { localInstallationsTable } from "../../persistence/schema"; | |
| 8 | −import { TimestampMsSchema } from "../common"; | |
| 9 | 3 | import { |
| 10 | 4 | AccountIdSchema, |
| 11 | − InstallationIdSchema, | |
| 12 | − OrganizationIdSchema, | |
| 13 | 5 | WorkspaceIdSchema, |
| 14 | 6 | } from "../ids"; |
| 15 | 7 | |
| 16 | −const localInstallationSchemaOverrides = { | |
| 17 | − id: InstallationIdSchema, | |
| 8 | +export const LocalInstallationSchema = Schema.Struct({ | |
| 18 | 9 | accountId: AccountIdSchema, |
| 19 | − organizationId: OrganizationIdSchema, | |
| 20 | 10 | workspaceId: WorkspaceIdSchema, |
| 21 | − createdAt: TimestampMsSchema, | |
| 22 | − updatedAt: TimestampMsSchema, | |
| 23 | −} as const; | |
| 24 | − | |
| 25 | −export const LocalInstallationSchema = createSelectSchema( | |
| 26 | − localInstallationsTable, | |
| 27 | − localInstallationSchemaOverrides, | |
| 28 | −); | |
| 29 | − | |
| 30 | −export const LocalInstallationInsertSchema = createInsertSchema( | |
| 31 | − localInstallationsTable, | |
| 32 | − localInstallationSchemaOverrides, | |
| 33 | −); | |
| 11 | +}); | |
| 34 | 12 | |
| 35 | −export const LocalInstallationUpdateSchema = createUpdateSchema( | |
| 36 | − localInstallationsTable, | |
| 37 | − localInstallationSchemaOverrides, | |
| 38 | −); | |
| 13 | +export const LocalInstallationInsertSchema = LocalInstallationSchema; | |
| 14 | +export const LocalInstallationUpdateSchema = Schema.partial(LocalInstallationSchema); | |
| 39 | 15 | |
| 40 | 16 | export type LocalInstallation = typeof LocalInstallationSchema.Type; |
| @@ -1,79 +1,28 @@ | ||
| 1 | −import { | |
| 2 | − createInsertSchema, | |
| 3 | − createSelectSchema, | |
| 4 | − createUpdateSchema, | |
| 5 | −} from "drizzle-orm/effect-schema"; | |
| 6 | 1 | import { Schema } from "effect"; |
| 7 | 2 | |
| 8 | −import { policiesTable } from "../../persistence/schema"; | |
| 9 | 3 | import { TimestampMsSchema } from "../common"; |
| 10 | −import { | |
| 11 | − AccountIdSchema, | |
| 12 | − OrganizationIdSchema, | |
| 13 | − PolicyIdSchema, | |
| 14 | − WorkspaceIdSchema, | |
| 15 | −} from "../ids"; | |
| 4 | +import { PolicyIdSchema, WorkspaceIdSchema } from "../ids"; | |
| 16 | 5 | |
| 17 | −export const PolicyScopeTypeSchema = Schema.Literal("organization", "workspace"); | |
| 6 | +export const LocalWorkspacePolicyEffectSchema = Schema.Literal("allow", "deny"); | |
| 7 | +export const LocalWorkspacePolicyApprovalModeSchema = Schema.Literal("auto", "required"); | |
| 18 | 8 | |
| 19 | −export const PolicyResourceTypeSchema = Schema.Literal( | |
| 20 | − "all_tools", | |
| 21 | − "source", | |
| 22 | − "namespace", | |
| 23 | − "tool_path", | |
| 24 | −); | |
| 25 | − | |
| 26 | −export const PolicyMatchTypeSchema = Schema.Literal("glob", "exact"); | |
| 27 | −export const PolicyEffectSchema = Schema.Literal("allow", "deny"); | |
| 28 | −export const PolicyApprovalModeSchema = Schema.Literal("auto", "required"); | |
| 29 | − | |
| 30 | −export const ArgumentConditionOperatorSchema = Schema.Literal( | |
| 31 | − "equals", | |
| 32 | − "contains", | |
| 33 | − "starts_with", | |
| 34 | − "not_equals", | |
| 35 | −); | |
| 36 | − | |
| 37 | −export const PolicyArgumentConditionSchema = Schema.Struct({ | |
| 38 | − key: Schema.String, | |
| 39 | − operator: ArgumentConditionOperatorSchema, | |
| 40 | − value: Schema.String, | |
| 41 | −}); | |
| 42 | − | |
| 43 | −const policySchemaOverrides = { | |
| 9 | +export const LocalWorkspacePolicySchema = Schema.Struct({ | |
| 44 | 10 | id: PolicyIdSchema, |
| 45 | − scopeType: PolicyScopeTypeSchema, | |
| 46 | − organizationId: OrganizationIdSchema, | |
| 47 | − workspaceId: Schema.NullOr(WorkspaceIdSchema), | |
| 48 | − targetAccountId: Schema.NullOr(AccountIdSchema), | |
| 49 | − clientId: Schema.NullOr(Schema.String), | |
| 50 | − resourceType: PolicyResourceTypeSchema, | |
| 51 | − matchType: PolicyMatchTypeSchema, | |
| 52 | − effect: PolicyEffectSchema, | |
| 53 | − approvalMode: PolicyApprovalModeSchema, | |
| 54 | − argumentConditionsJson: Schema.NullOr(Schema.String), | |
| 11 | + key: Schema.String, | |
| 12 | + workspaceId: WorkspaceIdSchema, | |
| 13 | + resourcePattern: Schema.String, | |
| 14 | + effect: LocalWorkspacePolicyEffectSchema, | |
| 15 | + approvalMode: LocalWorkspacePolicyApprovalModeSchema, | |
| 16 | + priority: Schema.Number, | |
| 17 | + enabled: Schema.Boolean, | |
| 55 | 18 | createdAt: TimestampMsSchema, |
| 56 | 19 | updatedAt: TimestampMsSchema, |
| 57 | −} as const; | |
| 58 | − | |
| 59 | −export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides); | |
| 60 | − | |
| 61 | −export const PolicyInsertSchema = createInsertSchema( | |
| 62 | − policiesTable, | |
| 63 | − policySchemaOverrides, | |
| 64 | −); | |
| 20 | +}); | |
| 65 | 21 | |
| 66 | −export const PolicyUpdateSchema = createUpdateSchema( | |
| 67 | − policiesTable, | |
| 68 | − policySchemaOverrides, | |
| 69 | −); | |
| 22 | +export const LocalWorkspacePolicyInsertSchema = LocalWorkspacePolicySchema; | |
| 23 | +export const LocalWorkspacePolicyUpdateSchema = Schema.partial(LocalWorkspacePolicySchema); | |
| 70 | 24 | |
| 71 | −export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type; | |
| 72 | −export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type; | |
| 73 | −export type PolicyEffect = typeof PolicyEffectSchema.Type; | |
| 74 | −export type PolicyApprovalMode = typeof PolicyApprovalModeSchema.Type; | |
| 75 | −export type PolicyScopeType = typeof PolicyScopeTypeSchema.Type; | |
| 76 | −export type ArgumentConditionOperator = | |
| 77 | − typeof ArgumentConditionOperatorSchema.Type; | |
| 78 | −export type PolicyArgumentCondition = typeof PolicyArgumentConditionSchema.Type; | |
| 79 | −export type Policy = typeof PolicySchema.Type; | |
| 25 | +export type LocalWorkspacePolicyEffect = typeof LocalWorkspacePolicyEffectSchema.Type; | |
| 26 | +export type LocalWorkspacePolicyApprovalMode = | |
| 27 | + typeof LocalWorkspacePolicyApprovalModeSchema.Type; | |
| 28 | +export type LocalWorkspacePolicy = typeof LocalWorkspacePolicySchema.Type; | |
| @@ -1,32 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − createInsertSchema, | |
| 3 | − createSelectSchema, | |
| 4 | − createUpdateSchema, | |
| 5 | −} from "drizzle-orm/effect-schema"; | |
| 6 | −import { Schema } from "effect"; | |
| 7 | − | |
| 8 | −import { workspacesTable } from "../../persistence/schema"; | |
| 9 | −import { TimestampMsSchema } from "../common"; | |
| 10 | −import { AccountIdSchema, OrganizationIdSchema, WorkspaceIdSchema } from "../ids"; | |
| 11 | − | |
| 12 | −const workspaceSchemaOverrides = { | |
| 13 | − id: WorkspaceIdSchema, | |
| 14 | − organizationId: OrganizationIdSchema, | |
| 15 | − createdByAccountId: Schema.NullOr(AccountIdSchema), | |
| 16 | − createdAt: TimestampMsSchema, | |
| 17 | − updatedAt: TimestampMsSchema, | |
| 18 | −} as const; | |
| 19 | − | |
| 20 | −export const WorkspaceSchema = createSelectSchema(workspacesTable, workspaceSchemaOverrides); | |
| 21 | − | |
| 22 | −export const WorkspaceInsertSchema = createInsertSchema( | |
| 23 | − workspacesTable, | |
| 24 | − workspaceSchemaOverrides, | |
| 25 | −); | |
| 26 | − | |
| 27 | −export const WorkspaceUpdateSchema = createUpdateSchema( | |
| 28 | − workspacesTable, | |
| 29 | − workspaceSchemaOverrides, | |
| 30 | −); | |
| 31 | − | |
| 32 | −export type Workspace = typeof WorkspaceSchema.Type; | |
| @@ -3,19 +3,10 @@ import * as Either from "effect/Either"; · 2 unmodified lines | ||
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | − PermissionValues, | |
| 7 | − RolePermissions, | |
| 8 | 6 | StoredSourceRecipeOperationRecordSchema, |
| 9 | 7 | } from "./index"; |
| 10 | 8 | |
| 11 | 9 | describe("control-plane-schema", () => { |
| 12 | − it("exposes stable permission and role mappings", () => { | |
| 13 | − expect(PermissionValues).toContain("organizations:manage"); | |
| 14 | − expect(RolePermissions.viewer).toContain("workspace:read"); | |
| 15 | − expect(RolePermissions.editor).toContain("sources:write"); | |
| 16 | − expect(RolePermissions.owner).toContain("policies:manage"); | |
| 17 | − }); | |
| 18 | − | |
| 19 | 10 | it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => { |
| 20 | 11 | const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema); |
| 21 | 12 | |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | ExecutionEnvelope, |
| 3 | − ExecutionInteraction, | |
| 4 | − SqlControlPlaneRuntime, | |
| 3 | + ControlPlaneRuntime, | |
| 5 | 4 | } from "@executor/control-plane"; |
| 6 | 5 | import { |
| 7 | 6 | EXECUTOR_SOURCES_ADD_HELP_LINES, |
| @@ -18,6 +17,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 10 unmodified lines | ||
| 18 | 17 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 19 | 18 | import { z } from "zod/v4"; |
| 20 | 19 | |
| 20 | +import { | |
| 21 | + buildPausedResultText, | |
| 22 | + parseInteractionPayload, | |
| 23 | +} from "./paused-result"; | |
| 24 | + | |
| 21 | 25 | const pollingIntervalMs = 200; |
| 22 | 26 | |
| 23 | 27 | const executeInputSchema = { |
| @@ -34,14 +38,6 @@ const resumeInputSchema = { · 10 unmodified lines | ||
| 34 | 38 | }).optional(), |
| 35 | 39 | }; |
| 36 | 40 | |
| 37 | −type ParsedInteractionPayload = { | |
| 38 | − mode: "form" | "url"; | |
| 39 | − message: string; | |
| 40 | − url?: string; | |
| 41 | − requestedSchema?: Record<string, unknown>; | |
| 42 | − elicitationId?: string; | |
| 43 | −}; | |
| 44 | − | |
| 45 | 41 | type ResumePayload = { |
| 46 | 42 | executionId: string; |
| 47 | 43 | }; |
| @@ -62,9 +58,6 @@ export type ExecutorMcpRequestHandler = { · 14 unmodified lines | ||
| 62 | 58 | close: () => Promise<void>; |
| 63 | 59 | }; |
| 64 | 60 | |
| 65 | −const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 66 | − typeof value === "object" && value !== null && !Array.isArray(value); | |
| 67 | − | |
| 68 | 61 | const parseJsonValue = (value: string | null): unknown => { |
| 69 | 62 | if (value === null) { |
| 70 | 63 | return null; |
| @@ -93,48 +86,8 @@ const formatResultPreview = (resultJson: string): string => { · 22 unmodified lines | ||
| 93 | 86 | return truncateText(resultJson, maxResultPreviewChars); |
| 94 | 87 | } |
| 95 | 88 | }; |
| 96 | − | |
| 97 | − | |
| 98 | −const parseInteractionPayload = ( | |
| 99 | − interaction: ExecutionInteraction, | |
| 100 | −): ParsedInteractionPayload | null => { | |
| 101 | − try { | |
| 102 | − const parsed = JSON.parse(interaction.payloadJson) as { | |
| 103 | − elicitation?: { | |
| 104 | − mode?: "form" | "url"; | |
| 105 | − message?: string; | |
| 106 | − url?: string; | |
| 107 | − requestedSchema?: Record<string, unknown>; | |
| 108 | − elicitationId?: string; | |
| 109 | − id?: string; | |
| 110 | − }; | |
| 111 | − }; | |
| 112 | − | |
| 113 | − if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") { | |
| 114 | − return null; | |
| 115 | − } | |
| 116 | − | |
| 117 | − return { | |
| 118 | − mode: parsed.elicitation.mode === "url" ? "url" : "form", | |
| 119 | − message: parsed.elicitation.message, | |
| 120 | − url: parsed.elicitation.url, | |
| 121 | − requestedSchema: isRecord(parsed.elicitation.requestedSchema) | |
| 122 | − ? parsed.elicitation.requestedSchema | |
| 123 | − : undefined, | |
| 124 | − elicitationId: | |
| 125 | − typeof parsed.elicitation.elicitationId === "string" | |
| 126 | − ? parsed.elicitation.elicitationId | |
| 127 | − : typeof parsed.elicitation.id === "string" | |
| 128 | − ? parsed.elicitation.id | |
| 129 | − : undefined, | |
| 130 | − }; | |
| 131 | − } catch { | |
| 132 | − return null; | |
| 133 | − } | |
| 134 | −}; | |
| 135 | − | |
| 136 | 89 | const runControlPlane = async <A, E, R>( |
| 137 | − runtime: SqlControlPlaneRuntime, | |
| 90 | + runtime: ControlPlaneRuntime, | |
| 138 | 91 | effect: Effect.Effect<A, E, R>, |
| 139 | 92 | ): Promise<A> => { |
| 140 | 93 | const exit = await Effect.runPromiseExit( |
| @@ -192,7 +145,7 @@ const buildExecuteWorkflowText = (namespaces: readonly string[] = []): string => · 51 unmodified lines | ||
| 192 | 145 | |
| 193 | 146 | const defaultExecuteDescription = buildExecuteWorkflowText(); |
| 194 | 147 | |
| 195 | −const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string> => | |
| 148 | +const loadExecuteDescription = (runtime: ControlPlaneRuntime): Promise<string> => | |
| 196 | 149 | runControlPlane( |
| 197 | 150 | runtime, |
| 198 | 151 | Effect.gen(function* () { |
| @@ -265,9 +218,7 @@ const buildPausedResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult = · 66 unmodified lines | ||
| 265 | 218 | return { |
| 266 | 219 | content: [{ |
| 267 | 220 | type: "text", |
| 268 | − text: interaction | |
| 269 | − ? `Execution ${envelope.execution.id} paused: ${parsed?.message ?? "Interaction required."}` | |
| 270 | − : `Execution ${envelope.execution.id} is waiting for interaction.`, | |
| 221 | + text: buildPausedResultText(envelope), | |
| 271 | 222 | }], |
| 272 | 223 | structuredContent: { |
| 273 | 224 | executionId: envelope.execution.id, |
| @@ -305,7 +256,7 @@ const buildToolResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult => · 31 unmodified lines | ||
| 305 | 256 | }; |
| 306 | 257 | |
| 307 | 258 | const waitForInteractionProgress = async (input: { |
| 308 | − runtime: SqlControlPlaneRuntime; | |
| 259 | + runtime: ControlPlaneRuntime; | |
| 309 | 260 | workspaceId: string; |
| 310 | 261 | executionId: string; |
| 311 | 262 | pendingInteractionId: string; |
| @@ -331,7 +282,7 @@ const waitForInteractionProgress = async (input: { · 19 unmodified lines | ||
| 331 | 282 | }; |
| 332 | 283 | |
| 333 | 284 | const driveExecutionWithElicitation = async (input: { |
| 334 | − runtime: SqlControlPlaneRuntime; | |
| 285 | + runtime: ControlPlaneRuntime; | |
| 335 | 286 | workspaceId: string; |
| 336 | 287 | accountId: string; |
| 337 | 288 | server: McpServer; |
| @@ -408,7 +359,7 @@ const driveExecutionWithElicitation = async (input: { · 70 unmodified lines | ||
| 408 | 359 | }; |
| 409 | 360 | |
| 410 | 361 | const driveExecutionWithoutElicitation = async (input: { |
| 411 | − runtime: SqlControlPlaneRuntime; | |
| 362 | + runtime: ControlPlaneRuntime; | |
| 412 | 363 | workspaceId: string; |
| 413 | 364 | accountId: string; |
| 414 | 365 | executionId: string; |
| @@ -447,7 +398,7 @@ const driveExecutionWithoutElicitation = async (input: { · 32 unmodified lines | ||
| 447 | 398 | }; |
| 448 | 399 | |
| 449 | 400 | const createExecutorMcpServer = async (config: { |
| 450 | − runtime: SqlControlPlaneRuntime; | |
| 401 | + runtime: ControlPlaneRuntime; | |
| 451 | 402 | }): Promise<McpServer> => { |
| 452 | 403 | const executeDescription = await loadExecuteDescription(config.runtime); |
| 453 | 404 | const server = new McpServer( |
| @@ -552,7 +503,7 @@ const jsonErrorResponse = (status: number, code: number, message: string) => · 98 unmodified lines | ||
| 552 | 503 | }); |
| 553 | 504 | |
| 554 | 505 | export const createExecutorMcpRequestHandler = ( |
| 555 | − runtime: SqlControlPlaneRuntime, | |
| 506 | + runtime: ControlPlaneRuntime, | |
| 556 | 507 | ): ExecutorMcpRequestHandler => { |
| 557 | 508 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 558 | 509 | const servers = new Map<string, McpServer>(); |
| @@ -0,0 +1,97 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import type { ExecutionEnvelope, ExecutionInteraction } from "@executor/control-plane"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + buildPausedResultText, | |
| 6 | + parseInteractionPayload, | |
| 7 | +} from "./paused-result"; | |
| 8 | + | |
| 9 | +const makeInteraction = (patch: Partial<ExecutionInteraction> = {}): ExecutionInteraction => ({ | |
| 10 | + id: "exec_123:source_connect_oauth2:call_1" as never, | |
| 11 | + executionId: "exec_123" as never, | |
| 12 | + status: "pending", | |
| 13 | + kind: "url", | |
| 14 | + purpose: "source_connect_oauth2", | |
| 15 | + payloadJson: JSON.stringify({ | |
| 16 | + elicitation: { | |
| 17 | + mode: "url", | |
| 18 | + message: "Finish connecting Axiom", | |
| 19 | + url: "https://mcp.axiom.co/authorize", | |
| 20 | + elicitationId: "oauth-session-123", | |
| 21 | + }, | |
| 22 | + }), | |
| 23 | + responseJson: null, | |
| 24 | + createdAt: 1, | |
| 25 | + updatedAt: 1, | |
| 26 | + ...patch, | |
| 27 | +}); | |
| 28 | + | |
| 29 | +const makeEnvelope = ( | |
| 30 | + patch: Partial<ExecutionEnvelope> = {}, | |
| 31 | +): ExecutionEnvelope => ({ | |
| 32 | + execution: { | |
| 33 | + id: "exec_123" as never, | |
| 34 | + workspaceId: "ws_123" as never, | |
| 35 | + createdByAccountId: "acct_123" as never, | |
| 36 | + status: "waiting_for_interaction", | |
| 37 | + code: "return await tools.executor.sources.add({ endpoint: 'https://mcp.axiom.co/mcp' });", | |
| 38 | + resultJson: null, | |
| 39 | + errorText: null, | |
| 40 | + logsJson: null, | |
| 41 | + startedAt: 1, | |
| 42 | + completedAt: null, | |
| 43 | + createdAt: 1, | |
| 44 | + updatedAt: 1, | |
| 45 | + }, | |
| 46 | + pendingInteraction: makeInteraction(), | |
| 47 | + ...patch, | |
| 48 | +}); | |
| 49 | + | |
| 50 | +describe("paused-result", () => { | |
| 51 | + it("parses the browser URL from a paused interaction payload", () => { | |
| 52 | + const parsed = parseInteractionPayload(makeInteraction()); | |
| 53 | + | |
| 54 | + expect(parsed).not.toBeNull(); | |
| 55 | + expect(parsed?.mode).toBe("url"); | |
| 56 | + expect(parsed?.url).toBe("https://mcp.axiom.co/authorize"); | |
| 57 | + expect(parsed?.elicitationId).toBe("oauth-session-123"); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("includes URL and resume instructions in the paused text for browser auth", () => { | |
| 61 | + const text = buildPausedResultText(makeEnvelope()); | |
| 62 | + | |
| 63 | + expect(text).toContain("Finish connecting Axiom"); | |
| 64 | + expect(text).toContain("Open this URL in a browser:"); | |
| 65 | + expect(text).toContain("https://mcp.axiom.co/authorize"); | |
| 66 | + expect(text).toContain("executor.resume"); | |
| 67 | + expect(text).toContain('"executionId": "exec_123"'); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it("includes resume guidance for form interactions", () => { | |
| 71 | + const text = buildPausedResultText( | |
| 72 | + makeEnvelope({ | |
| 73 | + pendingInteraction: makeInteraction({ | |
| 74 | + kind: "form", | |
| 75 | + purpose: "tool_execution_gate", | |
| 76 | + payloadJson: JSON.stringify({ | |
| 77 | + elicitation: { | |
| 78 | + mode: "form", | |
| 79 | + message: "Approve the tool call", | |
| 80 | + requestedSchema: { | |
| 81 | + type: "object", | |
| 82 | + properties: { | |
| 83 | + approve: { type: "boolean" }, | |
| 84 | + }, | |
| 85 | + required: ["approve"], | |
| 86 | + }, | |
| 87 | + }, | |
| 88 | + }), | |
| 89 | + }), | |
| 90 | + }), | |
| 91 | + ); | |
| 92 | + | |
| 93 | + expect(text).toContain("Approve the tool call"); | |
| 94 | + expect(text).toContain("include a response object matching the requested schema"); | |
| 95 | + expect(text).toContain('"executionId": "exec_123"'); | |
| 96 | + }); | |
| 97 | +}); | |
| @@ -0,0 +1,95 @@ | ||
| 1 | +import type { | |
| 2 | + ExecutionEnvelope, | |
| 3 | + ExecutionInteraction, | |
| 4 | +} from "@executor/control-plane"; | |
| 5 | + | |
| 6 | +export type ParsedInteractionPayload = { | |
| 7 | + mode: "form" | "url"; | |
| 8 | + message: string; | |
| 9 | + url?: string; | |
| 10 | + requestedSchema?: Record<string, unknown>; | |
| 11 | + elicitationId?: string; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 15 | + typeof value === "object" && value !== null && !Array.isArray(value); | |
| 16 | + | |
| 17 | +export const parseInteractionPayload = ( | |
| 18 | + interaction: ExecutionInteraction, | |
| 19 | +): ParsedInteractionPayload | null => { | |
| 20 | + try { | |
| 21 | + const parsed = JSON.parse(interaction.payloadJson) as { | |
| 22 | + elicitation?: { | |
| 23 | + mode?: "form" | "url"; | |
| 24 | + message?: string; | |
| 25 | + url?: string; | |
| 26 | + requestedSchema?: Record<string, unknown>; | |
| 27 | + elicitationId?: string; | |
| 28 | + id?: string; | |
| 29 | + }; | |
| 30 | + }; | |
| 31 | + | |
| 32 | + if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") { | |
| 33 | + return null; | |
| 34 | + } | |
| 35 | + | |
| 36 | + return { | |
| 37 | + mode: parsed.elicitation.mode === "url" ? "url" : "form", | |
| 38 | + message: parsed.elicitation.message, | |
| 39 | + url: parsed.elicitation.url, | |
| 40 | + requestedSchema: isRecord(parsed.elicitation.requestedSchema) | |
| 41 | + ? parsed.elicitation.requestedSchema | |
| 42 | + : undefined, | |
| 43 | + elicitationId: | |
| 44 | + typeof parsed.elicitation.elicitationId === "string" | |
| 45 | + ? parsed.elicitation.elicitationId | |
| 46 | + : typeof parsed.elicitation.id === "string" | |
| 47 | + ? parsed.elicitation.id | |
| 48 | + : undefined, | |
| 49 | + }; | |
| 50 | + } catch { | |
| 51 | + return null; | |
| 52 | + } | |
| 53 | +}; | |
| 54 | + | |
| 55 | +const formatResumePayload = (executionId: string): string => | |
| 56 | + JSON.stringify({ executionId }, null, 2); | |
| 57 | + | |
| 58 | +export const buildPausedResultText = (envelope: ExecutionEnvelope): string => { | |
| 59 | + const interaction = envelope.pendingInteraction; | |
| 60 | + const parsed = interaction ? parseInteractionPayload(interaction) : null; | |
| 61 | + | |
| 62 | + if (!interaction) { | |
| 63 | + return [ | |
| 64 | + `Execution ${envelope.execution.id} is waiting for interaction.`, | |
| 65 | + "Resume with executor.resume using this resumePayload:", | |
| 66 | + formatResumePayload(envelope.execution.id), | |
| 67 | + ].join("\n"); | |
| 68 | + } | |
| 69 | + | |
| 70 | + const lines = [ | |
| 71 | + `Execution ${envelope.execution.id} paused: ${parsed?.message ?? "Interaction required."}`, | |
| 72 | + ]; | |
| 73 | + | |
| 74 | + if (parsed?.mode === "url" && typeof parsed.url === "string" && parsed.url.trim().length > 0) { | |
| 75 | + lines.push("Open this URL in a browser:"); | |
| 76 | + lines.push(parsed.url.trim()); | |
| 77 | + lines.push( | |
| 78 | + "After finishing the browser flow, call executor.resume with this resumePayload if your client does not refresh automatically:", | |
| 79 | + ); | |
| 80 | + lines.push(formatResumePayload(envelope.execution.id)); | |
| 81 | + return lines.join("\n"); | |
| 82 | + } | |
| 83 | + | |
| 84 | + if (parsed?.mode === "form") { | |
| 85 | + lines.push( | |
| 86 | + "Resume with executor.resume using this resumePayload and include a response object matching the requested schema:", | |
| 87 | + ); | |
| 88 | + lines.push(formatResumePayload(envelope.execution.id)); | |
| 89 | + return lines.join("\n"); | |
| 90 | + } | |
| 91 | + | |
| 92 | + lines.push("Resume with executor.resume using this resumePayload:"); | |
| 93 | + lines.push(formatResumePayload(envelope.execution.id)); | |
| 94 | + return lines.join("\n"); | |
| 95 | +}; | |
| @@ -5,10 +5,10 @@ import { NodeHttpServer } from "@effect/platform-node"; · 4 unmodified lines | ||
| 5 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 6 | import { |
| 7 | 7 | createControlPlaneApiLayer, |
| 8 | − createSqlControlPlaneRuntime, | |
| 8 | + createControlPlaneRuntime, | |
| 9 | 9 | type LocalInstallation, |
| 10 | 10 | type Source, |
| 11 | − type SqlControlPlaneRuntime, | |
| 11 | + type ControlPlaneRuntime, | |
| 12 | 12 | } from "@executor/control-plane"; |
| 13 | 13 | import * as Context from "effect/Context"; |
| 14 | 14 | import * as Effect from "effect/Effect"; |
| @@ -69,7 +69,7 @@ type RunningServer = { · 54 unmodified lines | ||
| 69 | 69 | }; |
| 70 | 70 | |
| 71 | 71 | type ApiServer = RunningServer & { |
| 72 | − runtime: SqlControlPlaneRuntime; | |
| 72 | + runtime: ControlPlaneRuntime; | |
| 73 | 73 | }; |
| 74 | 74 | |
| 75 | 75 | type HookHarness<T> = { |
| @@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 19 unmodified lines | ||
| 95 | 95 | |
| 96 | 96 | const startControlPlaneServer = async (): Promise<ApiServer> => { |
| 97 | 97 | const runtime = await Effect.runPromise( |
| 98 | − createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | |
| 98 | + createControlPlaneRuntime({ | |
| 99 | + localDataDir: ":memory:", | |
| 100 | + workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")), | |
| 101 | + }), | |
| 99 | 102 | ); |
| 100 | 103 | const scope = await Effect.runPromise(Scope.make()); |
| 101 | 104 | |
| @@ -531,6 +534,7 @@ describe("executor-react source hooks", () => { · 429 unmodified lines | ||
| 531 | 534 | await apiServer.close(); |
| 532 | 535 | } |
| 533 | 536 | }), |
| 537 | + 60_000, | |
| 534 | 538 | ); |
| 535 | 539 | |
| 536 | 540 | it.effect("applies optimistic source updates and refreshes live inspection data after success", () => |
| @@ -624,6 +628,7 @@ describe("executor-react source hooks", () => { · 87 unmodified lines | ||
| 624 | 628 | await apiServer.close(); |
| 625 | 629 | } |
| 626 | 630 | }), |
| 631 | + 60_000, | |
| 627 | 632 | ); |
| 628 | 633 | |
| 629 | 634 | it.effect("optimistically removes deleted sources and invalidates mounted source queries", () => |
| @@ -706,6 +711,7 @@ describe("executor-react source hooks", () => { · 76 unmodified lines | ||
| 706 | 711 | await apiServer.close(); |
| 707 | 712 | } |
| 708 | 713 | }), |
| 714 | + 60_000, | |
| 709 | 715 | ); |
| 710 | 716 | |
| 711 | 717 | it.effect("surfaces missing sources as errors instead of staying loading", () => |
| @@ -741,5 +747,9 @@ describe("executor-react source hooks", () => { · 29 unmodified lines | ||
| 741 | 747 | await apiServer.close(); |
| 742 | 748 | } |
| 743 | 749 | }), |
| 750 | + 60_000, | |
| 744 | 751 | ); |
| 745 | 752 | }); |
| 753 | +import { mkdtempSync } from "node:fs"; | |
| 754 | +import { tmpdir } from "node:os"; | |
| 755 | +import { join } from "node:path"; | |
| @@ -230,14 +230,16 @@ const logExecutorDevError = (label: string, details: Record<string, unknown>): v · 229 unmodified lines | ||
| 230 | 230 | }; |
| 231 | 231 | |
| 232 | 232 | const runControlPlane = async <A>(input: { |
| 233 | + baseUrl?: string; | |
| 233 | 234 | accountId?: string; |
| 234 | 235 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| 235 | 236 | }): Promise<A> => { |
| 237 | + const baseUrl = input.baseUrl ?? apiBaseUrl; | |
| 236 | 238 | const accountId = input.accountId; |
| 237 | 239 | |
| 238 | 240 | const exit = await Effect.runPromiseExit( |
| 239 | 241 | createControlPlaneClient({ |
| 240 | − baseUrl: apiBaseUrl, | |
| 242 | + baseUrl, | |
| 241 | 243 | ...(accountId !== undefined ? { accountId } : {}), |
| 242 | 244 | }).pipe(Effect.flatMap(input.execute)), |
| 243 | 245 | ); |
| @@ -248,7 +250,7 @@ const runControlPlane = async <A>(input: { · 4 unmodified lines | ||
| 248 | 250 | |
| 249 | 251 | const error = Cause.squash(exit.cause); |
| 250 | 252 | logExecutorDevError("control-plane request failed", { |
| 251 | − baseUrl: apiBaseUrl, | |
| 253 | + baseUrl, | |
| 252 | 254 | accountId, |
| 253 | 255 | error: describeExecutorDevError(error), |
| 254 | 256 | cause: Cause.pretty(exit.cause), |
| @@ -257,6 +259,7 @@ const runControlPlane = async <A>(input: { · 2 unmodified lines | ||
| 257 | 259 | }; |
| 258 | 260 | |
| 259 | 261 | const controlPlaneRequest = <A>(input: { |
| 262 | + baseUrl?: string; | |
| 260 | 263 | accountId?: string; |
| 261 | 264 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| 262 | 265 | }): Effect.Effect<A, Error> => |
| @@ -265,23 +268,32 @@ const controlPlaneRequest = <A>(input: { · 2 unmodified lines | ||
| 265 | 268 | catch: toError, |
| 266 | 269 | }); |
| 267 | 270 | |
| 268 | −const localInstallationAtom = Atom.make( | |
| 269 | − controlPlaneRequest({ | |
| 270 | − execute: (client) => client.local.installation({}), | |
| 271 | − }), | |
| 272 | −).pipe(Atom.keepAlive); | |
| 273 | − | |
| 274 | −const instanceConfigAtom = Atom.make( | |
| 275 | − controlPlaneRequest({ | |
| 276 | − execute: (client) => client.local.config({}), | |
| 277 | − }), | |
| 278 | −).pipe(Atom.keepAlive); | |
| 279 | − | |
| 280 | −const secretsAtom = Atom.make( | |
| 281 | − controlPlaneRequest({ | |
| 282 | − execute: (client) => client.local.listSecrets({}), | |
| 283 | − }), | |
| 284 | −).pipe(Atom.keepAlive); | |
| 271 | +const localInstallationAtom = Atom.family((baseUrl: string) => | |
| 272 | + Atom.make( | |
| 273 | + controlPlaneRequest({ | |
| 274 | + baseUrl, | |
| 275 | + execute: (client) => client.local.installation({}), | |
| 276 | + }), | |
| 277 | + ).pipe(Atom.keepAlive), | |
| 278 | +); | |
| 279 | + | |
| 280 | +const instanceConfigAtom = Atom.family((baseUrl: string) => | |
| 281 | + Atom.make( | |
| 282 | + controlPlaneRequest({ | |
| 283 | + baseUrl, | |
| 284 | + execute: (client) => client.local.config({}), | |
| 285 | + }), | |
| 286 | + ).pipe(Atom.keepAlive), | |
| 287 | +); | |
| 288 | + | |
| 289 | +const secretsAtom = Atom.family((baseUrl: string) => | |
| 290 | + Atom.make( | |
| 291 | + controlPlaneRequest({ | |
| 292 | + baseUrl, | |
| 293 | + execute: (client) => client.local.listSecrets({}), | |
| 294 | + }), | |
| 295 | + ).pipe(Atom.keepAlive), | |
| 296 | +); | |
| 285 | 297 | |
| 286 | 298 | const sourcesAtom = Atom.family((key: string) => { |
| 287 | 299 | const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key); |
| @@ -453,7 +465,7 @@ const useLoadableAtom = <T>(atom: Atom.Atom<Result.Result<T, Error>>): Loadable< · 165 unmodified lines | ||
| 453 | 465 | }; |
| 454 | 466 | |
| 455 | 467 | const useWorkspaceContext = (): Loadable<WorkspaceContext> => { |
| 456 | − const installation = useLoadableAtom(localInstallationAtom); | |
| 468 | + const installation = useLoadableAtom(localInstallationAtom(apiBaseUrl)); | |
| 457 | 469 | |
| 458 | 470 | return React.useMemo(() => { |
| 459 | 471 | if (installation.status !== "ready") { |
| @@ -810,18 +822,18 @@ export const useInvalidateExecutorQueries = (): (() => void) => { · 350 unmodified lines | ||
| 810 | 822 | }; |
| 811 | 823 | |
| 812 | 824 | export const useLocalInstallation = (): Loadable<LocalInstallation> => |
| 813 | − useLoadableAtom(localInstallationAtom); | |
| 825 | + useLoadableAtom(localInstallationAtom(apiBaseUrl)); | |
| 814 | 826 | |
| 815 | 827 | export const useInstanceConfig = (): Loadable<InstanceConfig> => |
| 816 | − useLoadableAtom(instanceConfigAtom); | |
| 828 | + useLoadableAtom(instanceConfigAtom(apiBaseUrl)); | |
| 817 | 829 | |
| 818 | 830 | export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => |
| 819 | − useLoadableAtom(secretsAtom); | |
| 831 | + useLoadableAtom(secretsAtom(apiBaseUrl)); | |
| 820 | 832 | |
| 821 | 833 | export const useRefreshSecrets = (): (() => void) => { |
| 822 | 834 | const registry = React.useContext(RegistryContext); |
| 823 | 835 | return React.useCallback(() => { |
| 824 | − registry.refresh(secretsAtom); | |
| 836 | + registry.refresh(secretsAtom(apiBaseUrl)); | |
| 825 | 837 | }, [registry]); |
| 826 | 838 | }; |
| 827 | 839 | |
| @@ -850,7 +862,7 @@ const useSecretMutation = <TInput, TOutput>( · 22 unmodified lines | ||
| 850 | 862 | |
| 851 | 863 | try { |
| 852 | 864 | const data = await execute(payload); |
| 853 | − registry.refresh(secretsAtom); | |
| 865 | + registry.refresh(secretsAtom(apiBaseUrl)); | |
| 854 | 866 | setState({ status: "success", data, error: null }); |
| 855 | 867 | return data; |
| 856 | 868 | } catch (cause) { |
| @@ -237,4 +237,3 @@ export const makeSesExecutor = ( · 236 unmodified lines | ||
| 237 | 237 | ): CodeExecutor => ({ |
| 238 | 238 | execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker), |
| 239 | 239 | }); |
| 240 | − | |
| @@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | const formatLogLine = (args) => args.map(formatLogArg).join(" "); |
| 26 | 26 | |
| 27 | +const toSerializableValue = (value) => { | |
| 28 | + if (typeof value === "undefined") { | |
| 29 | + return undefined; | |
| 30 | + } | |
| 31 | + | |
| 32 | + return JSON.parse(JSON.stringify(value)); | |
| 33 | +}; | |
| 34 | + | |
| 27 | 35 | const buildExecutionSource = (code) => { |
| 28 | 36 | const trimmed = code.trim(); |
| 29 | 37 | const looksLikeArrowFunction = |
| @@ -157,7 +165,7 @@ process.on("message", async (message) => { · 127 unmodified lines | ||
| 157 | 165 | process.send?.({ |
| 158 | 166 | type: "result", |
| 159 | 167 | id: message.id, |
| 160 | − value: result, | |
| 168 | + value: toSerializableValue(result), | |
| 161 | 169 | logs, |
| 162 | 170 | }); |
| 163 | 171 | } catch (error) { |
| @@ -11,10 +11,10 @@ import { Readable } from "node:stream"; · 10 unmodified lines | ||
| 11 | 11 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 12 | 12 | import { |
| 13 | 13 | createControlPlaneApiLayer, |
| 14 | − createSqlControlPlaneRuntime, | |
| 14 | + createControlPlaneRuntime, | |
| 15 | 15 | type ResolveExecutionEnvironment, |
| 16 | 16 | type ResolveSecretMaterial, |
| 17 | − type SqlControlPlaneRuntime, | |
| 17 | + type ControlPlaneRuntime, | |
| 18 | 18 | } from "@executor/control-plane"; |
| 19 | 19 | import { createExecutorMcpRequestHandler } from "@executor/executor-mcp"; |
| 20 | 20 | import * as Cause from "effect/Cause"; |
| @@ -58,7 +58,7 @@ type StaticUiOptions = { · 37 unmodified lines | ||
| 58 | 58 | }; |
| 59 | 59 | |
| 60 | 60 | export type LocalExecutorServer = { |
| 61 | − readonly runtime: SqlControlPlaneRuntime; | |
| 61 | + readonly runtime: ControlPlaneRuntime; | |
| 62 | 62 | readonly port: number; |
| 63 | 63 | readonly host: string; |
| 64 | 64 | readonly baseUrl: string; |
| @@ -68,6 +68,7 @@ export type StartLocalExecutorServerOptions = { · 3 unmodified lines | ||
| 68 | 68 | readonly port?: number; |
| 69 | 69 | readonly host?: string; |
| 70 | 70 | readonly localDataDir?: string; |
| 71 | + readonly workspaceRoot?: string; | |
| 71 | 72 | readonly migrationsFolder?: string; |
| 72 | 73 | readonly pidFile?: string; |
| 73 | 74 | readonly executionResolver?: ResolveExecutionEnvironment; |
| @@ -76,7 +77,7 @@ export type StartLocalExecutorServerOptions = { · 2 unmodified lines | ||
| 76 | 77 | }; |
| 77 | 78 | |
| 78 | 79 | export type LocalExecutorRequestHandler = { |
| 79 | − readonly runtime: SqlControlPlaneRuntime; | |
| 80 | + readonly runtime: ControlPlaneRuntime; | |
| 80 | 81 | readonly handleApiRequest: (request: Request) => Promise<Response>; |
| 81 | 82 | readonly getBaseUrl: () => string | undefined; |
| 82 | 83 | readonly setBaseUrl: (baseUrl: string) => void; |
| @@ -85,7 +86,7 @@ export type LocalExecutorRequestHandler = { · 2 unmodified lines | ||
| 85 | 86 | type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>; |
| 86 | 87 | type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; |
| 87 | 88 | |
| 88 | −const disposeRuntime = (runtime: SqlControlPlaneRuntime) => | |
| 89 | +const disposeRuntime = (runtime: ControlPlaneRuntime) => | |
| 89 | 90 | Effect.tryPromise({ |
| 90 | 91 | try: () => runtime.close(), |
| 91 | 92 | catch: (cause) => |
| @@ -97,9 +98,10 @@ const createRuntime = ( · 5 unmodified lines | ||
| 97 | 98 | getLocalServerBaseUrl: () => string | undefined, |
| 98 | 99 | options: StartLocalExecutorServerOptions, |
| 99 | 100 | ) => |
| 100 | − createSqlControlPlaneRuntime({ | |
| 101 | + createControlPlaneRuntime({ | |
| 101 | 102 | localDataDir, |
| 102 | 103 | migrationsFolder: options.migrationsFolder, |
| 104 | + workspaceRoot: options.workspaceRoot, | |
| 103 | 105 | executionResolver: options.executionResolver, |
| 104 | 106 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 105 | 107 | getLocalServerBaseUrl, |
| @@ -207,7 +209,7 @@ const createRuntimeWithLegacyMigration = ( · 101 unmodified lines | ||
| 207 | 209 | ); |
| 208 | 210 | }); |
| 209 | 211 | |
| 210 | −const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) => | |
| 212 | +const createControlPlaneWebHandler = (runtime: ControlPlaneRuntime) => | |
| 211 | 213 | Effect.acquireRelease( |
| 212 | 214 | Effect.sync(() => |
| 213 | 215 | HttpApiBuilder.toWebHandler( |