Unified · Split
| @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; · 2 unmodified lines | |||
| 3 | import { decideInteractionHandling } from "./interaction-handling"; | 3 | import { decideInteractionHandling } from "./interaction-handling"; |
| 4 | 4 | ||
| 5 | describe("interaction-handling", () => { | 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 | expect(decideInteractionHandling({ | 7 | expect(decideInteractionHandling({ |
| 8 | parsed: { | 8 | parsed: { |
| 9 | mode: "url", | 9 | mode: "url", |
| @@ -15,11 +15,11 @@ import { · 14 unmodified lines | |||
| 15 | ExecutionIdSchema, | 15 | ExecutionIdSchema, |
| 16 | RuntimeExecutionResolverService, | 16 | RuntimeExecutionResolverService, |
| 17 | createControlPlaneClient, | 17 | createControlPlaneClient, |
| 18 | createSqlControlPlaneRuntime, | 18 | createControlPlaneRuntime, |
| 19 | type ControlPlaneClient, | 19 | type ControlPlaneClient, |
| 20 | type ExecutionEnvelope, | 20 | type ExecutionEnvelope, |
| 21 | type ExecutionInteraction, | 21 | type ExecutionInteraction, |
| 22 | type SqlControlPlaneRuntime, | 22 | type ControlPlaneRuntime, |
| 23 | } from "@executor/control-plane"; | 23 | } from "@executor/control-plane"; |
| 24 | import type { ToolCatalog } from "@executor/codemode-core"; | 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 | : `Current workspace catalog unavailable: ${message}`; | 247 | : `Current workspace catalog unavailable: ${message}`; |
| 248 | }; | 248 | }; |
| 249 | 249 | ||
| 250 | const closeSqlRuntime = (runtime: SqlControlPlaneRuntime) => | 250 | const closeRuntime = (runtime: ControlPlaneRuntime) => |
| 251 | Effect.tryPromise({ | 251 | Effect.tryPromise({ |
| 252 | try: () => runtime.close(), | 252 | try: () => runtime.close(), |
| 253 | catch: toError, | 253 | catch: toError, |
| @@ -274,7 +274,7 @@ const buildRunWorkflowText = ( · 20 unmodified lines | |||
| 274 | 274 | ||
| 275 | const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => | 275 | const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => |
| 276 | Effect.acquireUseRelease( | 276 | Effect.acquireUseRelease( |
| 277 | createSqlControlPlaneRuntime({ | 277 | createControlPlaneRuntime({ |
| 278 | localDataDir: DEFAULT_LOCAL_DATA_DIR, | 278 | localDataDir: DEFAULT_LOCAL_DATA_DIR, |
| 279 | }).pipe(Effect.mapError(toError)), | 279 | }).pipe(Effect.mapError(toError)), |
| 280 | (runtime) => | 280 | (runtime) => |
| @@ -293,7 +293,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines | |||
| 293 | 293 | ||
| 294 | return yield* buildRunWorkflowText(environment.catalog); | 294 | return yield* buildRunWorkflowText(environment.catalog); |
| 295 | }), | 295 | }), |
| 296 | closeSqlRuntime, | 296 | closeRuntime, |
| 297 | ).pipe( | 297 | ).pipe( |
| 298 | Effect.catchAllCause((cause) => | 298 | Effect.catchAllCause((cause) => |
| 299 | Effect.succeed( | 299 | Effect.succeed( |
| @@ -100,7 +100,12 @@ describe("portable distribution flow", () => { · 99 unmodified lines | |||
| 100 | const distDir = join(tempRoot, "dist"); | 100 | const distDir = join(tempRoot, "dist"); |
| 101 | const homeDir = join(tempRoot, "home"); | 101 | const homeDir = join(tempRoot, "home"); |
| 102 | const binDir = join(homeDir, ".local", "bin"); | 102 | const binDir = join(homeDir, ".local", "bin"); |
| 103 | const installHome = join(homeDir, ".local", "share", "executor"); | 103 | const installHome = process.platform === "darwin" |
| 104 | ? join(homeDir, "Library", "Application Support", "Executor") | ||
| 105 | : join(homeDir, ".local", "share", "executor"); | ||
| 106 | const runHome = process.platform === "darwin" | ||
| 107 | ? join(installHome, "run") | ||
| 108 | : join(homeDir, ".local", "state", "executor", "run"); | ||
| 104 | const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor"); | 109 | const legacyExecutorBin = join(homeDir, ".executor", "bin", "executor"); |
| 105 | const baseUrl = `http://127.0.0.1:${await allocatePort()}`; | 110 | const baseUrl = `http://127.0.0.1:${await allocatePort()}`; |
| 106 | 111 | ||
| @@ -176,9 +181,9 @@ describe("portable distribution flow", () => { · 69 unmodified lines | |||
| 176 | expect(status.reachable).toBe(true); | 181 | expect(status.reachable).toBe(true); |
| 177 | expect(status.pidRunning).toBe(true); | 182 | expect(status.pidRunning).toBe(true); |
| 178 | expect(status.installation).not.toBeNull(); | 183 | expect(status.installation).not.toBeNull(); |
| 179 | expect(status.logFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true); | 184 | expect(status.logFile.startsWith(runHome)).toBe(true); |
| 180 | expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true); | 185 | expect(status.localDataDir.startsWith(join(installHome, "data"))).toBe(true); |
| 181 | expect(status.pidFile.startsWith(join(homeDir, ".local", "state", "executor", "run"))).toBe(true); | 186 | expect(status.pidFile.startsWith(runHome)).toBe(true); |
| 182 | 187 | ||
| 183 | const html = await fetch(new URL("/", baseUrl)); | 188 | const html = await fetch(new URL("/", baseUrl)); |
| 184 | expect(html.status).toBe(200); | 189 | expect(html.status).toBe(200); |
| @@ -7,7 +7,9 @@ import { · 6 unmodified lines | |||
| 7 | HttpApiGroup, | 7 | HttpApiGroup, |
| 8 | HttpApiSchema, | 8 | HttpApiSchema, |
| 9 | OpenApi, | 9 | OpenApi, |
| 10 | FileSystem, | ||
| 10 | } from "@effect/platform"; | 11 | } from "@effect/platform"; |
| 12 | import { NodeFileSystem } from "@effect/platform-node"; | ||
| 11 | import { describe, expect, it } from "@effect/vitest"; | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | import { assertTrue } from "@effect/vitest/utils"; | 14 | import { assertTrue } from "@effect/vitest/utils"; |
| 13 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| @@ -19,10 +21,15 @@ import { · 5 unmodified lines | |||
| 19 | createControlPlaneClient, | 21 | createControlPlaneClient, |
| 20 | controlPlaneOpenApiSpec, | 22 | controlPlaneOpenApiSpec, |
| 21 | type ControlPlaneClient, | 23 | type ControlPlaneClient, |
| 24 | buildLocalSourceArtifact, | ||
| 25 | deriveLocalInstallation, | ||
| 26 | materializationFromMcpManifestEntries, | ||
| 22 | type ResolveExecutionEnvironment, | 27 | type ResolveExecutionEnvironment, |
| 28 | resolveLocalWorkspaceContext, | ||
| 23 | SourceIdSchema, | 29 | SourceIdSchema, |
| 24 | SourceRecipeIdSchema, | ||
| 25 | SourceRecipeRevisionIdSchema, | 30 | SourceRecipeRevisionIdSchema, |
| 31 | writeLocalSourceArtifact, | ||
| 32 | writeProjectLocalExecutorConfig, | ||
| 26 | } from "@executor/control-plane"; | 33 | } from "@executor/control-plane"; |
| 27 | import { makeSesExecutor } from "@executor/runtime-ses"; | 34 | import { makeSesExecutor } from "@executor/runtime-ses"; |
| 28 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 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 | port: 0, | 177 | port: 0, |
| 63 | localDataDir: ":memory:", | 178 | localDataDir: ":memory:", |
| 64 | executionResolver, | 179 | executionResolver, |
| @@ -579,28 +694,6 @@ const extractUrlInteractionUrl = (payloadJson: string): string => { · 514 unmodified lines | |||
| 579 | return url; | 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 | describe("local-executor-server", () => { | 697 | describe("local-executor-server", () => { |
| 605 | it.scoped("serves the control-plane OpenAPI spec at /v1/openapi.json", () => | 698 | it.scoped("serves the control-plane OpenAPI spec at /v1/openapi.json", () => |
| 606 | Effect.gen(function* () { | 699 | Effect.gen(function* () { |
| @@ -644,6 +737,7 @@ describe("local-executor-server", () => { · 37 unmodified lines | |||
| 644 | expect(execution.execution.status).toBe("completed"); | 737 | expect(execution.execution.status).toBe("completed"); |
| 645 | expect(execution.execution.resultJson).toBe(JSON.stringify({ sum: 42 })); | 738 | expect(execution.execution.resultJson).toBe(JSON.stringify({ sum: 42 })); |
| 646 | }), | 739 | }), |
| 740 | 15_000, | ||
| 647 | ); | 741 | ); |
| 648 | 742 | ||
| 649 | it.scoped("includes completed MCP return values in text content", () => | 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 | it.scoped("serves only execute over MCP when elicitation is supported", () => | 776 | it.scoped("serves only execute over MCP when elicitation is supported", () => |
| 683 | Effect.gen(function* () { | 777 | Effect.gen(function* () { |
| 684 | const server = yield* createLocalExecutorServer({ | 778 | const server = yield* createIsolatedLocalExecutorServer({ |
| 685 | port: 0, | 779 | port: 0, |
| 686 | localDataDir: ":memory:", | 780 | localDataDir: ":memory:", |
| 687 | executionResolver: gatedExecutionResolver, | 781 | executionResolver: gatedExecutionResolver, |
| @@ -738,7 +832,7 @@ describe("local-executor-server", () => { · 50 unmodified lines | |||
| 738 | 832 | ||
| 739 | it.scoped("serves execute and resume over MCP when elicitation is unavailable", () => | 833 | it.scoped("serves execute and resume over MCP when elicitation is unavailable", () => |
| 740 | Effect.gen(function* () { | 834 | Effect.gen(function* () { |
| 741 | const server = yield* createLocalExecutorServer({ | 835 | const server = yield* createIsolatedLocalExecutorServer({ |
| 742 | port: 0, | 836 | port: 0, |
| 743 | localDataDir: ":memory:", | 837 | localDataDir: ":memory:", |
| 744 | executionResolver: gatedExecutionResolver, | 838 | executionResolver: gatedExecutionResolver, |
| @@ -808,6 +902,7 @@ describe("local-executor-server", () => { · 63 unmodified lines | |||
| 808 | expect(resumed.structuredContent?.result).toBe("approved:manual-mcp"); | 902 | expect(resumed.structuredContent?.result).toBe("approved:manual-mcp"); |
| 809 | expect(resumed.content?.find((item) => item.type === "text")?.text).toContain("approved:manual-mcp"); | 903 | expect(resumed.content?.find((item) => item.type === "text")?.text).toContain("approved:manual-mcp"); |
| 810 | }), | 904 | }), |
| 905 | 15_000, | ||
| 811 | ); | 906 | ); |
| 812 | 907 | ||
| 813 | it.scoped("loads MCP sources from control-plane state and resumes elicitation", () => | 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 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | 912 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 818 | ); | 913 | ); |
| 819 | 914 | ||
| 820 | const server = yield* createLocalExecutorServer({ | 915 | const server = yield* createIsolatedLocalExecutorServer({ |
| 821 | port: 0, | 916 | port: 0, |
| 822 | localDataDir: ":memory:", | 917 | localDataDir: ":memory:", |
| 823 | }); | 918 | }); |
| @@ -898,7 +993,7 @@ describe("local-executor-server", () => { · 74 unmodified lines | |||
| 898 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | 993 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 899 | ); | 994 | ); |
| 900 | 995 | ||
| 901 | const server = yield* createLocalExecutorServer({ | 996 | const server = yield* createIsolatedLocalExecutorServer({ |
| 902 | port: 0, | 997 | port: 0, |
| 903 | localDataDir: ":memory:", | 998 | localDataDir: ":memory:", |
| 904 | }); | 999 | }); |
| @@ -976,7 +1071,7 @@ describe("local-executor-server", () => { · 71 unmodified lines | |||
| 976 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | 1071 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 977 | ); | 1072 | ); |
| 978 | 1073 | ||
| 979 | const server = yield* createLocalExecutorServer({ | 1074 | const server = yield* createIsolatedLocalExecutorServer({ |
| 980 | port: 0, | 1075 | port: 0, |
| 981 | localDataDir: ":memory:", | 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 | port: 0, | 1164 | port: 0, |
| 1070 | localDataDir: ":memory:", | 1165 | localDataDir: ":memory:", |
| 1071 | }); | 1166 | }); |
| @@ -1111,7 +1206,7 @@ describe("local-executor-server", () => { · 39 unmodified lines | |||
| 1111 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | 1206 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1112 | ); | 1207 | ); |
| 1113 | 1208 | ||
| 1114 | const server = yield* createLocalExecutorServer({ | 1209 | const server = yield* createIsolatedLocalExecutorServer({ |
| 1115 | port: 0, | 1210 | port: 0, |
| 1116 | localDataDir: ":memory:", | 1211 | localDataDir: ":memory:", |
| 1117 | }); | 1212 | }); |
| @@ -1148,29 +1243,28 @@ describe("local-executor-server", () => { · 30 unmodified lines | |||
| 1148 | }), | 1243 | }), |
| 1149 | ); | 1244 | ); |
| 1150 | assertTrue(callbackResponse.ok); | 1245 | assertTrue(callbackResponse.ok); |
| 1151 | expect(yield* Effect.promise(() => callbackResponse.text())).toContain("Source connected:"); | 1246 | const callbackText = yield* Effect.promise(() => callbackResponse.text()); |
| 1152 | 1247 | expect(callbackText).toContain("Source connected:"); | |
| 1153 | const completed = yield* waitForExecutionCompletion({ | 1248 | |
| 1154 | client, | 1249 | const connectedSource = yield* Effect.gen(function* () { |
| 1155 | workspaceId: installation.workspaceId, | 1250 | while (true) { |
| 1156 | executionId: added.execution.id, | 1251 | const sources = yield* client.sources.list({ |
| 1157 | }); | 1252 | path: { |
| 1158 | 1253 | workspaceId: installation.workspaceId, | |
| 1159 | expect(completed.execution.status).toBe("completed"); | 1254 | }, |
| 1160 | expect(completed.pendingInteraction).toBeNull(); | 1255 | }); |
| 1161 | expect(completed.execution.resultJson).toContain('"name":"Axiom"'); | 1256 | const source = sources.find((entry) => entry.namespace === "axiom"); |
| 1162 | expect(completed.execution.resultJson).toContain('"status":"connected"'); | 1257 | if (source?.status === "connected" && source.auth.kind === "oauth2") { |
| 1258 | return source; | ||
| 1259 | } | ||
| 1163 | 1260 | ||
| 1164 | const sources = yield* client.sources.list({ | 1261 | yield* Effect.sleep("100 millis"); |
| 1165 | path: { | 1262 | } |
| 1166 | workspaceId: installation.workspaceId, | ||
| 1167 | }, | ||
| 1168 | }); | 1263 | }); |
| 1169 | 1264 | ||
| 1170 | expect(sources).toHaveLength(1); | 1265 | expect(connectedSource.name).toBe("Axiom"); |
| 1171 | expect(sources[0]?.name).toBe("Axiom"); | 1266 | expect(connectedSource.status).toBe("connected"); |
| 1172 | expect(sources[0]?.status).toBe("connected"); | 1267 | expect(connectedSource.auth.kind).toBe("oauth2"); |
| 1173 | expect(sources[0]?.auth.kind).toBe("oauth2"); | ||
| 1174 | 1268 | ||
| 1175 | const toolCall = yield* client.executions.create({ | 1269 | const toolCall = yield* client.executions.create({ |
| 1176 | path: { | 1270 | path: { |
| @@ -1188,13 +1282,13 @@ describe("local-executor-server", () => { · 11 unmodified lines | |||
| 1188 | 15_000, | 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 | Effect.gen(function* () { | 1286 | Effect.gen(function* () { |
| 1193 | const openApiServer = yield* Effect.acquireRelease( | 1287 | const openApiServer = yield* Effect.acquireRelease( |
| 1194 | Effect.promise(() => startMutatingOpenApiDemoServer()), | 1288 | Effect.promise(() => startMutatingOpenApiDemoServer()), |
| 1195 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | 1289 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1196 | ); | 1290 | ); |
| 1197 | const server = yield* createLocalExecutorServer({ | 1291 | const server = yield* createIsolatedLocalExecutorServer({ |
| 1198 | port: 0, | 1292 | port: 0, |
| 1199 | localDataDir: ":memory:", | 1293 | localDataDir: ":memory:", |
| 1200 | }); | 1294 | }); |
| @@ -1260,19 +1354,17 @@ describe("local-executor-server", () => { · 59 unmodified lines | |||
| 1260 | expect(approved.execution.status).toBe("completed"); | 1354 | expect(approved.execution.status).toBe("completed"); |
| 1261 | expect(openApiServer.createdBodies).toHaveLength(1); | 1355 | expect(openApiServer.createdBodies).toHaveLength(1); |
| 1262 | 1356 | ||
| 1263 | const policy = yield* client.policies.createOrganization({ | 1357 | const policy = yield* client.policies.create({ |
| 1264 | path: { | 1358 | path: { |
| 1265 | organizationId: installation.organizationId, | 1359 | workspaceId: installation.workspaceId, |
| 1266 | }, | 1360 | }, |
| 1267 | payload: { | 1361 | payload: { |
| 1268 | resourceType: "tool_path", | ||
| 1269 | resourcePattern: "dns.records.createRecord", | 1362 | resourcePattern: "dns.records.createRecord", |
| 1270 | matchType: "exact", | ||
| 1271 | effect: "allow", | 1363 | effect: "allow", |
| 1272 | approvalMode: "auto", | 1364 | approvalMode: "auto", |
| 1273 | }, | 1365 | }, |
| 1274 | }); | 1366 | }); |
| 1275 | expect(policy.scopeType).toBe("organization"); | 1367 | expect(policy.key).toBe("dns.records.createRecord"); |
| 1276 | 1368 | ||
| 1277 | const automatic = yield* client.executions.create({ | 1369 | const automatic = yield* client.executions.create({ |
| 1278 | path: { | 1370 | path: { |
| @@ -1287,6 +1379,7 @@ describe("local-executor-server", () => { · 8 unmodified lines | |||
| 1287 | expect(automatic.pendingInteraction).toBeNull(); | 1379 | expect(automatic.pendingInteraction).toBeNull(); |
| 1288 | expect(openApiServer.createdBodies).toHaveLength(2); | 1380 | expect(openApiServer.createdBodies).toHaveLength(2); |
| 1289 | }), | 1381 | }), |
| 1382 | 15_000, | ||
| 1290 | ); | 1383 | ); |
| 1291 | 1384 | ||
| 1292 | it.scoped("starts source OAuth without creating a source and stores secrets on callback", () => | 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 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), | 1389 | (server) => Effect.promise(() => server.close()).pipe(Effect.orDie), |
| 1297 | ); | 1390 | ); |
| 1298 | 1391 | ||
| 1299 | const server = yield* createLocalExecutorServer({ | 1392 | const server = yield* createIsolatedLocalExecutorServer({ |
| 1300 | port: 0, | 1393 | port: 0, |
| 1301 | localDataDir: ":memory:", | 1394 | localDataDir: ":memory:", |
| 1302 | }); | 1395 | }); |
| @@ -1360,11 +1453,12 @@ describe("local-executor-server", () => { · 57 unmodified lines | |||
| 1360 | expect(secrets.some((secret) => secret.purpose === "oauth_access_token")).toBe(true); | 1453 | expect(secrets.some((secret) => secret.purpose === "oauth_access_token")).toBe(true); |
| 1361 | expect(secrets.some((secret) => secret.purpose === "oauth_refresh_token")).toBe(true); | 1454 | expect(secrets.some((secret) => secret.purpose === "oauth_refresh_token")).toBe(true); |
| 1362 | }), | 1455 | }), |
| 1456 | 15_000, | ||
| 1363 | ); | 1457 | ); |
| 1364 | 1458 | ||
| 1365 | it.scoped("marks execution failed when a configured MCP endpoint is invalid", () => | 1459 | it.scoped("marks execution failed when a configured MCP endpoint is invalid", () => |
| 1366 | Effect.gen(function* () { | 1460 | Effect.gen(function* () { |
| 1367 | const server = yield* createLocalExecutorServer({ | 1461 | const server = yield* createIsolatedLocalExecutorServer({ |
| 1368 | port: 0, | 1462 | port: 0, |
| 1369 | localDataDir: ":memory:", | 1463 | localDataDir: ":memory:", |
| 1370 | }); | 1464 | }); |
| @@ -1377,123 +1471,12 @@ describe("local-executor-server", () => { · 6 unmodified lines | |||
| 1377 | baseUrl: server.baseUrl, | 1471 | baseUrl: server.baseUrl, |
| 1378 | accountId: installation.accountId, | 1472 | accountId: installation.accountId, |
| 1379 | }); | 1473 | }); |
| 1380 | 1474 | yield* writeConfiguredLocalMcpSource({ | |
| 1381 | const sourceId = SourceIdSchema.make("src_invalid_mcp_endpoint"); | 1475 | workspaceRoot: server.workspaceRoot, |
| 1382 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${sourceId}`); | 1476 | sourceId: "demo", |
| 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", | ||
| 1440 | endpoint: "http://127.0.0.1:PORT/mcp", | 1477 | endpoint: "http://127.0.0.1:PORT/mcp", |
| 1441 | status: "connected", | 1478 | name: "Demo", |
| 1442 | enabled: true, | ||
| 1443 | namespace: "demo", | 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 | const execution = yield* client.executions.create({ | 1482 | const execution = yield* client.executions.create({ |
| @@ -10,6 +10,9 @@ · 9 unmodified lines | |||
| 10 | "typescript": "^5.9.3", | 10 | "typescript": "^5.9.3", |
| 11 | }, | 11 | }, |
| 12 | }, | 12 | }, |
| 13 | "apps/docs": { | ||
| 14 | "name": "@executor/docs", | ||
| 15 | }, | ||
| 13 | "apps/executor": { | 16 | "apps/executor": { |
| 14 | "name": "executor", | 17 | "name": "executor", |
| 15 | "version": "1.1.9", | 18 | "version": "1.1.9", |
| @@ -79,9 +82,6 @@ · 63 unmodified lines | |||
| 79 | "vite": "^7.3.1", | 82 | "vite": "^7.3.1", |
| 80 | }, | 83 | }, |
| 81 | }, | 84 | }, |
| 82 | "docs": { | ||
| 83 | "name": "@executor/docs", | ||
| 84 | }, | ||
| 85 | "packages/ai-sdk-adapter": { | 85 | "packages/ai-sdk-adapter": { |
| 86 | "name": "@executor/ai-sdk-adapter", | 86 | "name": "@executor/ai-sdk-adapter", |
| 87 | "dependencies": { | 87 | "dependencies": { |
| @@ -171,6 +171,7 @@ · 83 unmodified lines | |||
| 171 | "drizzle-orm": "1.0.0-beta.15-859cf75", | 171 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 172 | "effect": "catalog:", | 172 | "effect": "catalog:", |
| 173 | "graphql": "^16.12.0", | 173 | "graphql": "^16.12.0", |
| 174 | "jsonc-parser": "3.3.1", | ||
| 174 | "postgres": "^3.4.7", | 175 | "postgres": "^3.4.7", |
| 175 | "prettier": "^3.8.1", | 176 | "prettier": "^3.8.1", |
| 176 | }, | 177 | }, |
| @@ -496,7 +497,7 @@ · 319 unmodified lines | |||
| 496 | 497 | ||
| 497 | "@executor/control-plane": ["@executor/control-plane@workspace:packages/control-plane"], | 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 | "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"], | 502 | "@executor/executor-mcp": ["@executor/executor-mcp@workspace:packages/executor-mcp"], |
| 502 | 503 | ||
| @@ -34,6 +34,7 @@ · 33 unmodified lines | |||
| 34 | "drizzle-orm": "1.0.0-beta.15-859cf75", | 34 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 35 | "effect": "catalog:", | 35 | "effect": "catalog:", |
| 36 | "graphql": "^16.12.0", | 36 | "graphql": "^16.12.0", |
| 37 | "jsonc-parser": "3.3.1", | ||
| 37 | "postgres": "^3.4.7", | 38 | "postgres": "^3.4.7", |
| 38 | "prettier": "^3.8.1" | 39 | "prettier": "^3.8.1" |
| 39 | }, | 40 | }, |
| @@ -2,26 +2,20 @@ import { HttpApi, OpenApi } from "@effect/platform"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { ExecutionsApi } from "./executions/api"; | 3 | import { ExecutionsApi } from "./executions/api"; |
| 4 | import { LocalApi } from "./local/api"; | 4 | import { LocalApi } from "./local/api"; |
| 5 | import { MembershipsApi } from "./memberships/api"; | ||
| 6 | import { OAuthApi } from "./oauth/api"; | 5 | import { OAuthApi } from "./oauth/api"; |
| 7 | import { OrganizationsApi } from "./organizations/api"; | ||
| 8 | import { PoliciesApi } from "./policies/api"; | 6 | import { PoliciesApi } from "./policies/api"; |
| 9 | import { SourcesApi } from "./sources/api"; | 7 | import { SourcesApi } from "./sources/api"; |
| 10 | import { WorkspacesApi } from "./workspaces/api"; | ||
| 11 | 8 | ||
| 12 | export class ControlPlaneApi extends HttpApi.make("controlPlane") | 9 | export class ControlPlaneApi extends HttpApi.make("controlPlane") |
| 13 | .add(LocalApi) | 10 | .add(LocalApi) |
| 14 | .add(OAuthApi) | 11 | .add(OAuthApi) |
| 15 | .add(OrganizationsApi) | ||
| 16 | .add(MembershipsApi) | ||
| 17 | .add(WorkspacesApi) | ||
| 18 | .add(SourcesApi) | 12 | .add(SourcesApi) |
| 19 | .add(PoliciesApi) | 13 | .add(PoliciesApi) |
| 20 | .add(ExecutionsApi) | 14 | .add(ExecutionsApi) |
| 21 | .annotateContext( | 15 | .annotateContext( |
| 22 | OpenApi.annotations({ | 16 | OpenApi.annotations({ |
| 23 | title: "Executor Control Plane API", | 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 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import type { WorkspaceId } from "#schema"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | |||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 3 | import { |
| 6 | createExecution, | 4 | createExecution, |
| 7 | getExecution, | 5 | getExecution, |
| @@ -9,13 +7,7 @@ import { · 1 unmodified line | |||
| 9 | } from "../../runtime/execution-service"; | 7 | } from "../../runtime/execution-service"; |
| 10 | 8 | ||
| 11 | import { ControlPlaneApi } from "../api"; | 9 | import { ControlPlaneApi } from "../api"; |
| 12 | import { withWorkspaceRequestActor } from "../http-auth"; | 10 | import { resolveRequestedLocalWorkspace } from "../local-context"; |
| 13 | |||
| 14 | const requireExecuteWorkspace = (workspaceId: WorkspaceId) => | ||
| 15 | requirePermission({ | ||
| 16 | permission: "workspace:read", | ||
| 17 | workspaceId, | ||
| 18 | }); | ||
| 19 | 11 | ||
| 20 | export const ControlPlaneExecutionsLive = HttpApiBuilder.group( | 12 | export const ControlPlaneExecutionsLive = HttpApiBuilder.group( |
| 21 | ControlPlaneApi, | 13 | ControlPlaneApi, |
| @@ -23,19 +15,19 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 1 unmodified line | |||
| 23 | (handlers) => | 15 | (handlers) => |
| 24 | handlers | 16 | handlers |
| 25 | .handle("create", ({ path, payload }) => | 17 | .handle("create", ({ path, payload }) => |
| 26 | withWorkspaceRequestActor("executions.create", path.workspaceId, (actor) => | 18 | resolveRequestedLocalWorkspace("executions.create", path.workspaceId).pipe( |
| 27 | withPolicy(requireExecuteWorkspace(path.workspaceId))( | 19 | Effect.flatMap((runtimeLocalWorkspace) => |
| 28 | createExecution({ | 20 | createExecution({ |
| 29 | workspaceId: path.workspaceId, | 21 | workspaceId: path.workspaceId, |
| 30 | payload, | 22 | payload, |
| 31 | createdByAccountId: actor.principal.accountId, | 23 | createdByAccountId: runtimeLocalWorkspace.installation.accountId, |
| 32 | }), | 24 | }) |
| 33 | ), | 25 | ), |
| 34 | ), | 26 | ), |
| 35 | ) | 27 | ) |
| 36 | .handle("get", ({ path }) => | 28 | .handle("get", ({ path }) => |
| 37 | withWorkspaceRequestActor("executions.get", path.workspaceId, () => | 29 | resolveRequestedLocalWorkspace("executions.get", path.workspaceId).pipe( |
| 38 | withPolicy(requireExecuteWorkspace(path.workspaceId))( | 30 | Effect.zipRight( |
| 39 | getExecution({ | 31 | getExecution({ |
| 40 | workspaceId: path.workspaceId, | 32 | workspaceId: path.workspaceId, |
| 41 | executionId: path.executionId, | 33 | executionId: path.executionId, |
| @@ -44,14 +36,14 @@ export const ControlPlaneExecutionsLive = HttpApiBuilder.group( · 2 unmodified lines | |||
| 44 | ), | 36 | ), |
| 45 | ) | 37 | ) |
| 46 | .handle("resume", ({ path, payload }) => | 38 | .handle("resume", ({ path, payload }) => |
| 47 | withWorkspaceRequestActor("executions.resume", path.workspaceId, (actor) => | 39 | resolveRequestedLocalWorkspace("executions.resume", path.workspaceId).pipe( |
| 48 | withPolicy(requireExecuteWorkspace(path.workspaceId))( | 40 | Effect.flatMap((runtimeLocalWorkspace) => |
| 49 | resumeExecution({ | 41 | resumeExecution({ |
| 50 | workspaceId: path.workspaceId, | 42 | workspaceId: path.workspaceId, |
| 51 | executionId: path.executionId, | 43 | executionId: path.executionId, |
| 52 | payload, | 44 | payload, |
| 53 | resumedByAccountId: actor.principal.accountId, | 45 | resumedByAccountId: runtimeLocalWorkspace.installation.accountId, |
| 54 | }), | 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 | import * as Layer from "effect/Layer"; | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | ||
| 4 | import { ControlPlaneApi } from "./api"; | 4 | import { ControlPlaneApi } from "./api"; |
| 5 | import { | ||
| 6 | ControlPlaneActorResolver, | ||
| 7 | type ControlPlaneActorResolverShape, | ||
| 8 | } from "./auth/actor-resolver"; | ||
| 9 | import { ControlPlaneExecutionsLive } from "./executions/http"; | 5 | import { ControlPlaneExecutionsLive } from "./executions/http"; |
| 10 | import { ControlPlaneLocalLive } from "./local/http"; | 6 | import { ControlPlaneLocalLive } from "./local/http"; |
| 11 | import { ControlPlaneMembershipsLive } from "./memberships/http"; | ||
| 12 | import { ControlPlaneOAuthLive } from "./oauth/http"; | 7 | import { ControlPlaneOAuthLive } from "./oauth/http"; |
| 13 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; | ||
| 14 | import { ControlPlanePoliciesLive } from "./policies/http"; | 8 | import { ControlPlanePoliciesLive } from "./policies/http"; |
| 15 | import { ControlPlaneSourcesLive } from "./sources/http"; | 9 | import { ControlPlaneSourcesLive } from "./sources/http"; |
| 16 | import { ControlPlaneWorkspacesLive } from "./workspaces/http"; | ||
| 17 | 10 | ||
| 18 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( | 11 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( |
| 19 | Layer.provide(ControlPlaneLocalLive), | 12 | Layer.provide(ControlPlaneLocalLive), |
| 20 | Layer.provide(ControlPlaneOAuthLive), | 13 | Layer.provide(ControlPlaneOAuthLive), |
| 21 | Layer.provide(ControlPlaneOrganizationsLive), | ||
| 22 | Layer.provide(ControlPlaneMembershipsLive), | ||
| 23 | Layer.provide(ControlPlaneWorkspacesLive), | ||
| 24 | Layer.provide(ControlPlaneSourcesLive), | 14 | Layer.provide(ControlPlaneSourcesLive), |
| 25 | Layer.provide(ControlPlanePoliciesLive), | 15 | Layer.provide(ControlPlanePoliciesLive), |
| 26 | Layer.provide(ControlPlaneExecutionsLive), | 16 | Layer.provide(ControlPlaneExecutionsLive), |
| @@ -40,7 +30,3 @@ export const createControlPlaneApiLayer = <ERuntime>( · 13 unmodified lines | |||
| 40 | ControlPlaneApiLive.pipe( | 30 | ControlPlaneApiLive.pipe( |
| 41 | Layer.provide(runtimeLayer), | 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 | ControlPlaneApiLive, | 17 | ControlPlaneApiLive, |
| 18 | type ControlPlaneApiRuntimeContext, | 18 | type ControlPlaneApiRuntimeContext, |
| 19 | type BuiltControlPlaneApiLayer, | 19 | type BuiltControlPlaneApiLayer, |
| 20 | ControlPlaneActorResolverLive, | ||
| 21 | createControlPlaneApiLayer, | 20 | createControlPlaneApiLayer, |
| 22 | } from "./http"; | 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 | export { | 23 | export { |
| 35 | CreateExecutionPayloadSchema, | 24 | CreateExecutionPayloadSchema, |
| 36 | ResumeExecutionPayloadSchema, | 25 | ResumeExecutionPayloadSchema, |
| @@ -60,27 +49,6 @@ export { · 23 unmodified lines | |||
| 60 | type CompleteSourceOAuthResult, | 49 | type CompleteSourceOAuthResult, |
| 61 | } from "./oauth/api"; | 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 | export { | 52 | export { |
| 85 | ConnectSourcePayloadSchema, | 53 | ConnectSourcePayloadSchema, |
| 86 | ConnectSourceResultSchema, | 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 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | ||
| 11 | import { requirePermission, withPolicy } from "#domain"; | ||
| 12 | import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service"; | 11 | import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service"; |
| 13 | import { ControlPlaneApi } from "../api"; | 12 | import { ControlPlaneApi } from "../api"; |
| 14 | import { | 13 | import { |
| 15 | ControlPlaneBadRequestError, | 14 | ControlPlaneBadRequestError, |
| 16 | ControlPlaneStorageError, | 15 | ControlPlaneStorageError, |
| 17 | } from "../errors"; | 16 | } from "../errors"; |
| 17 | import { resolveRequestedLocalWorkspace } from "../local-context"; | ||
| 18 | import { | 18 | import { |
| 19 | SourceOAuthPopupResultSchema, | 19 | SourceOAuthPopupResultSchema, |
| 20 | type SourceOAuthPopupResult, | 20 | type SourceOAuthPopupResult, |
| 21 | } from "./api"; | 21 | } from "./api"; |
| 22 | import { withWorkspaceRequestActor } from "../http-auth"; | ||
| 23 | 22 | ||
| 24 | const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | 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 | const escapeHtml = (value: string): string => | 25 | const escapeHtml = (value: string): string => |
| 33 | value | 26 | value |
| 34 | .replaceAll("&", "&") | 27 | .replaceAll("&", "&") |
| @@ -241,14 +234,14 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 206 unmodified lines | |||
| 241 | (handlers) => | 234 | (handlers) => |
| 242 | handlers | 235 | handlers |
| 243 | .handle("startSourceAuth", ({ path, payload }) => | 236 | .handle("startSourceAuth", ({ path, payload }) => |
| 244 | withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) => | 237 | resolveRequestedLocalWorkspace("oauth.start_source_auth", path.workspaceId).pipe( |
| 245 | withPolicy(requireWriteSources(path.workspaceId))( | 238 | Effect.flatMap((runtimeLocalWorkspace) => |
| 246 | Effect.gen(function* () { | 239 | Effect.gen(function* () { |
| 247 | const request = yield* HttpServerRequest.HttpServerRequest; | 240 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 248 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | 241 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 249 | return yield* sourceAuthService.startSourceOAuthSession({ | 242 | return yield* sourceAuthService.startSourceOAuthSession({ |
| 250 | workspaceId: path.workspaceId, | 243 | workspaceId: path.workspaceId, |
| 251 | actorAccountId: actor.principal.accountId, | 244 | actorAccountId: runtimeLocalWorkspace.installation.accountId, |
| 252 | baseUrl: resolveRequestOrigin(request), | 245 | baseUrl: resolveRequestOrigin(request), |
| 253 | displayName: payload.name, | 246 | displayName: payload.name, |
| 254 | provider: { | 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 | import { | 5 | import { |
| 6 | CreateExecutionPayloadSchema, | 6 | CreateExecutionPayloadSchema, |
| 7 | } from "./executions/api"; | 7 | } from "./executions/api"; |
| 8 | import { | ||
| 9 | CreateOrganizationPayloadSchema, | ||
| 10 | UpdateOrganizationPayloadSchema, | ||
| 11 | } from "./organizations/api"; | ||
| 12 | import { CreatePolicyPayloadSchema } from "./policies/api"; | 8 | import { CreatePolicyPayloadSchema } from "./policies/api"; |
| 13 | import { | 9 | import { |
| 14 | ConnectSourcePayloadSchema, | 10 | ConnectSourcePayloadSchema, |
| 15 | CreateSourcePayloadSchema, | 11 | CreateSourcePayloadSchema, |
| 16 | UpdateSourcePayloadSchema, | 12 | UpdateSourcePayloadSchema, |
| 17 | } from "./sources/api"; | 13 | } from "./sources/api"; |
| 18 | import { CreateWorkspacePayloadSchema } from "./workspaces/api"; | ||
| 19 | 14 | ||
| 20 | describe("control-plane payload schemas", () => { | 15 | describe("control-plane payload schemas", () => { |
| 21 | it("normalizes trimmed strings at decode time", () => { | 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 | expect( | 17 | expect( |
| 41 | Schema.decodeUnknownSync(CreateSourcePayloadSchema)({ | 18 | Schema.decodeUnknownSync(CreateSourcePayloadSchema)({ |
| 42 | name: " Github ", | 19 | name: " Github ", |
| @@ -101,18 +78,6 @@ describe("control-plane payload schemas", () => { · 58 unmodified lines | |||
| 101 | }); | 78 | }); |
| 102 | 79 | ||
| 103 | it("rejects blank strings for normalized string fields", () => { | 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 | throws(() => | 81 | throws(() => |
| 117 | Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({ | 82 | Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({ |
| 118 | endpoint: " ", | 83 | endpoint: " ", |
| @@ -125,5 +90,11 @@ describe("control-plane payload schemas", () => { · 6 unmodified lines | |||
| 125 | endpoint: " ", | 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 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 3 | AccountIdSchema, | 3 | LocalWorkspacePolicyApprovalModeSchema, |
| 4 | OrganizationIdSchema, | 4 | LocalWorkspacePolicyEffectSchema, |
| 5 | LocalWorkspacePolicySchema, | ||
| 5 | PolicyIdSchema, | 6 | PolicyIdSchema, |
| 6 | PolicyApprovalModeSchema, | ||
| 7 | PolicyEffectSchema, | ||
| 8 | PolicyMatchTypeSchema, | ||
| 9 | PolicyResourceTypeSchema, | ||
| 10 | PolicySchema, | ||
| 11 | WorkspaceIdSchema, | 7 | WorkspaceIdSchema, |
| 12 | } from "#schema"; | 8 | } from "#schema"; |
| 13 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| @@ -21,91 +17,29 @@ import { · 7 unmodified lines | |||
| 21 | } from "../errors"; | 17 | } from "../errors"; |
| 22 | import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas"; | 18 | import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas"; |
| 23 | 19 | ||
| 24 | export const CreatePolicyPayloadSchema = Schema.Struct({ | 20 | const LocalWorkspacePolicyPayloadSchema = Schema.Struct({ |
| 25 | resourceType: Schema.optional(PolicyResourceTypeSchema), | ||
| 26 | resourcePattern: OptionalTrimmedNonEmptyStringSchema, | 21 | resourcePattern: OptionalTrimmedNonEmptyStringSchema, |
| 27 | matchType: Schema.optional(PolicyMatchTypeSchema), | 22 | effect: Schema.optional(LocalWorkspacePolicyEffectSchema), |
| 28 | effect: Schema.optional(PolicyEffectSchema), | 23 | approvalMode: Schema.optional(LocalWorkspacePolicyApprovalModeSchema), |
| 29 | approvalMode: Schema.optional(PolicyApprovalModeSchema), | ||
| 30 | argumentConditionsJson: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 31 | priority: Schema.optional(Schema.Number), | 24 | priority: Schema.optional(Schema.Number), |
| 32 | enabled: Schema.optional(Schema.Boolean), | 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 | export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type; | 30 | export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type; |
| 38 | 31 | ||
| 39 | export const UpdatePolicyPayloadSchema = Schema.Struct({ | 32 | export const UpdatePolicyPayloadSchema = LocalWorkspacePolicyPayloadSchema; |
| 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 | }); | ||
| 51 | 33 | ||
| 52 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; | 34 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; |
| 53 | 35 | ||
| 54 | const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | ||
| 55 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | 36 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 56 | const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema); | 37 | const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema); |
| 57 | 38 | ||
| 58 | export class PoliciesApi extends HttpApiGroup.make("policies") | 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 | .add( | 40 | .add( |
| 107 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies` | 41 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies` |
| 108 | .addSuccess(Schema.Array(PolicySchema)) | 42 | .addSuccess(Schema.Array(LocalWorkspacePolicySchema)) |
| 109 | .addError(ControlPlaneBadRequestError) | 43 | .addError(ControlPlaneBadRequestError) |
| 110 | .addError(ControlPlaneUnauthorizedError) | 44 | .addError(ControlPlaneUnauthorizedError) |
| 111 | .addError(ControlPlaneForbiddenError) | 45 | .addError(ControlPlaneForbiddenError) |
| @@ -115,7 +49,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 3 unmodified lines | |||
| 115 | .add( | 49 | .add( |
| 116 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/policies` | 50 | HttpApiEndpoint.post("create")`/workspaces/${workspaceIdParam}/policies` |
| 117 | .setPayload(CreatePolicyPayloadSchema) | 51 | .setPayload(CreatePolicyPayloadSchema) |
| 118 | .addSuccess(PolicySchema) | 52 | .addSuccess(LocalWorkspacePolicySchema) |
| 119 | .addError(ControlPlaneBadRequestError) | 53 | .addError(ControlPlaneBadRequestError) |
| 120 | .addError(ControlPlaneUnauthorizedError) | 54 | .addError(ControlPlaneUnauthorizedError) |
| 121 | .addError(ControlPlaneForbiddenError) | 55 | .addError(ControlPlaneForbiddenError) |
| @@ -124,7 +58,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 2 unmodified lines | |||
| 124 | ) | 58 | ) |
| 125 | .add( | 59 | .add( |
| 126 | HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` | 60 | HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` |
| 127 | .addSuccess(PolicySchema) | 61 | .addSuccess(LocalWorkspacePolicySchema) |
| 128 | .addError(ControlPlaneBadRequestError) | 62 | .addError(ControlPlaneBadRequestError) |
| 129 | .addError(ControlPlaneUnauthorizedError) | 63 | .addError(ControlPlaneUnauthorizedError) |
| 130 | .addError(ControlPlaneForbiddenError) | 64 | .addError(ControlPlaneForbiddenError) |
| @@ -134,7 +68,7 @@ export class PoliciesApi extends HttpApiGroup.make("policies") · 3 unmodified lines | |||
| 134 | .add( | 68 | .add( |
| 135 | HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` | 69 | HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` |
| 136 | .setPayload(UpdatePolicyPayloadSchema) | 70 | .setPayload(UpdatePolicyPayloadSchema) |
| 137 | .addSuccess(PolicySchema) | 71 | .addSuccess(LocalWorkspacePolicySchema) |
| 138 | .addError(ControlPlaneBadRequestError) | 72 | .addError(ControlPlaneBadRequestError) |
| 139 | .addError(ControlPlaneUnauthorizedError) | 73 | .addError(ControlPlaneUnauthorizedError) |
| 140 | .addError(ControlPlaneForbiddenError) | 74 | .addError(ControlPlaneForbiddenError) |
| @@ -1,117 +1,38 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import type { OrganizationId, WorkspaceId } from "#schema"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | |||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | 3 | import { |
| 6 | createOrganizationPolicy, | ||
| 7 | createPolicy, | 4 | createPolicy, |
| 8 | getOrganizationPolicy, | ||
| 9 | getPolicy, | 5 | getPolicy, |
| 10 | listOrganizationPolicies, | ||
| 11 | listPolicies, | 6 | listPolicies, |
| 12 | removeOrganizationPolicy, | ||
| 13 | removePolicy, | 7 | removePolicy, |
| 14 | updateOrganizationPolicy, | ||
| 15 | updatePolicy, | 8 | updatePolicy, |
| 16 | } from "../../runtime/policies-operations"; | 9 | } from "../../runtime/policies-operations"; |
| 17 | 10 | ||
| 18 | import { ControlPlaneApi } from "../api"; | 11 | import { ControlPlaneApi } from "../api"; |
| 19 | import { withRequestActor, withWorkspaceRequestActor } from "../http-auth"; | 12 | import { resolveRequestedLocalWorkspace } from "../local-context"; |
| 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 | }); | ||
| 44 | 13 | ||
| 45 | export const ControlPlanePoliciesLive = HttpApiBuilder.group( | 14 | export const ControlPlanePoliciesLive = HttpApiBuilder.group( |
| 46 | ControlPlaneApi, | 15 | ControlPlaneApi, |
| 47 | "policies", | 16 | "policies", |
| 48 | (handlers) => | 17 | (handlers) => |
| 49 | handlers | 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 | .handle("list", ({ path }) => | 19 | .handle("list", ({ path }) => |
| 99 | withWorkspaceRequestActor("policies.list", path.workspaceId, () => | 20 | resolveRequestedLocalWorkspace("policies.list", path.workspaceId).pipe( |
| 100 | withPolicy(requireReadPolicies(path.workspaceId))( | 21 | Effect.zipRight( |
| 101 | listPolicies(path.workspaceId), | 22 | listPolicies(path.workspaceId), |
| 102 | ), | 23 | ), |
| 103 | ), | 24 | ), |
| 104 | ) | 25 | ) |
| 105 | .handle("create", ({ path, payload }) => | 26 | .handle("create", ({ path, payload }) => |
| 106 | withWorkspaceRequestActor("policies.create", path.workspaceId, () => | 27 | resolveRequestedLocalWorkspace("policies.create", path.workspaceId).pipe( |
| 107 | withPolicy(requireWritePolicies(path.workspaceId))( | 28 | Effect.zipRight( |
| 108 | createPolicy({ workspaceId: path.workspaceId, payload }), | 29 | createPolicy({ workspaceId: path.workspaceId, payload }), |
| 109 | ), | 30 | ), |
| 110 | ), | 31 | ), |
| 111 | ) | 32 | ) |
| 112 | .handle("get", ({ path }) => | 33 | .handle("get", ({ path }) => |
| 113 | withWorkspaceRequestActor("policies.get", path.workspaceId, () => | 34 | resolveRequestedLocalWorkspace("policies.get", path.workspaceId).pipe( |
| 114 | withPolicy(requireReadPolicies(path.workspaceId))( | 35 | Effect.zipRight( |
| 115 | getPolicy({ | 36 | getPolicy({ |
| 116 | workspaceId: path.workspaceId, | 37 | workspaceId: path.workspaceId, |
| 117 | policyId: path.policyId, | 38 | policyId: path.policyId, |
| @@ -120,8 +41,8 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 2 unmodified lines | |||
| 120 | ), | 41 | ), |
| 121 | ) | 42 | ) |
| 122 | .handle("update", ({ path, payload }) => | 43 | .handle("update", ({ path, payload }) => |
| 123 | withWorkspaceRequestActor("policies.update", path.workspaceId, () => | 44 | resolveRequestedLocalWorkspace("policies.update", path.workspaceId).pipe( |
| 124 | withPolicy(requireWritePolicies(path.workspaceId))( | 45 | Effect.zipRight( |
| 125 | updatePolicy({ | 46 | updatePolicy({ |
| 126 | workspaceId: path.workspaceId, | 47 | workspaceId: path.workspaceId, |
| 127 | policyId: path.policyId, | 48 | policyId: path.policyId, |
| @@ -131,8 +52,8 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 3 unmodified lines | |||
| 131 | ), | 52 | ), |
| 132 | ) | 53 | ) |
| 133 | .handle("remove", ({ path }) => | 54 | .handle("remove", ({ path }) => |
| 134 | withWorkspaceRequestActor("policies.remove", path.workspaceId, () => | 55 | resolveRequestedLocalWorkspace("policies.remove", path.workspaceId).pipe( |
| 135 | withPolicy(requireWritePolicies(path.workspaceId))( | 56 | Effect.zipRight( |
| 136 | removePolicy({ | 57 | removePolicy({ |
| 137 | workspaceId: path.workspaceId, | 58 | workspaceId: path.workspaceId, |
| 138 | policyId: path.policyId, | 59 | policyId: path.policyId, |
| @@ -3,14 +3,9 @@ import { · 2 unmodified lines | |||
| 3 | HttpServerRequest, | 3 | HttpServerRequest, |
| 4 | HttpServerResponse, | 4 | HttpServerResponse, |
| 5 | } from "@effect/platform"; | 5 | } from "@effect/platform"; |
| 6 | import type { | 6 | import type { ExecutionInteraction, Source, WorkspaceId } from "#schema"; |
| 7 | ExecutionInteraction, | ||
| 8 | Source, | ||
| 9 | WorkspaceId, | ||
| 10 | } from "#schema"; | ||
| 11 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 12 | 8 | ||
| 13 | import { requirePermission, withPolicy } from "#domain"; | ||
| 14 | import { | 9 | import { |
| 15 | createSource, | 10 | createSource, |
| 16 | getSource, | 11 | getSource, |
| @@ -39,19 +34,7 @@ import { · 22 unmodified lines | |||
| 39 | ControlPlaneStorageError, | 34 | ControlPlaneStorageError, |
| 40 | } from "../errors"; | 35 | } from "../errors"; |
| 41 | import { ControlPlaneApi } from "../api"; | 36 | import { ControlPlaneApi } from "../api"; |
| 42 | import { withRequestActor, withWorkspaceRequestActor } from "../http-auth"; | 37 | import { resolveRequestedLocalWorkspace } from "../local-context"; |
| 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 | }); | ||
| 55 | 38 | ||
| 56 | const readHeader = (headers: unknown, name: string): string | null => { | 39 | const readHeader = (headers: unknown, name: string): string | null => { |
| 57 | if (headers == null || typeof headers !== "object") { | 40 | if (headers == null || typeof headers !== "object") { |
| @@ -665,20 +648,18 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 607 unmodified lines | |||
| 665 | (handlers) => | 648 | (handlers) => |
| 666 | handlers | 649 | handlers |
| 667 | .handle("discover", ({ payload }) => | 650 | .handle("discover", ({ payload }) => |
| 668 | withRequestActor("sources.discover", () => | 651 | discoverSource({ |
| 669 | discoverSource({ | 652 | url: payload.url, |
| 670 | url: payload.url, | 653 | probeAuth: payload.probeAuth, |
| 671 | probeAuth: payload.probeAuth, | 654 | }).pipe( |
| 672 | }).pipe( | 655 | Effect.catchAll((cause) => |
| 673 | Effect.catchAll((cause) => | 656 | Effect.fail(toBadRequestError("sources.discover", cause)), |
| 674 | Effect.fail(toBadRequestError("sources.discover", cause)), | ||
| 675 | ), | ||
| 676 | ), | 657 | ), |
| 677 | ), | 658 | ), |
| 678 | ) | 659 | ) |
| 679 | .handle("connect", ({ path, payload }) => | 660 | .handle("connect", ({ path, payload }) => |
| 680 | withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) => | 661 | resolveRequestedLocalWorkspace("sources.connect", path.workspaceId).pipe( |
| 681 | withPolicy(requireWriteSources(path.workspaceId))( | 662 | Effect.flatMap((runtimeLocalWorkspace) => |
| 682 | Effect.gen(function* () { | 663 | Effect.gen(function* () { |
| 683 | const request = yield* HttpServerRequest.HttpServerRequest; | 664 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 684 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | 665 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| @@ -687,7 +668,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 2 unmodified lines | |||
| 687 | if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) { | 668 | if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) { |
| 688 | return yield* sourceAuthService.connectMcpSource({ | 669 | return yield* sourceAuthService.connectMcpSource({ |
| 689 | workspaceId: path.workspaceId, | 670 | workspaceId: path.workspaceId, |
| 690 | actorAccountId: actor.principal.accountId, | 671 | actorAccountId: runtimeLocalWorkspace.installation.accountId, |
| 691 | endpoint: payload.endpoint, | 672 | endpoint: payload.endpoint, |
| 692 | name: payload.name, | 673 | name: payload.name, |
| 693 | namespace: payload.namespace, | 674 | namespace: payload.namespace, |
| @@ -701,7 +682,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 7 unmodified lines | |||
| 701 | return yield* sourceAuthService.addExecutorSource( | 682 | return yield* sourceAuthService.addExecutorSource( |
| 702 | { | 683 | { |
| 703 | workspaceId: path.workspaceId, | 684 | workspaceId: path.workspaceId, |
| 704 | actorAccountId: actor.principal.accountId, | 685 | actorAccountId: runtimeLocalWorkspace.installation.accountId, |
| 705 | executionId: null, | 686 | executionId: null, |
| 706 | interactionId: null, | 687 | interactionId: null, |
| 707 | ...payload, | 688 | ...payload, |
| @@ -712,37 +693,45 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 4 unmodified lines | |||
| 712 | Effect.catchAll((cause) => | 693 | Effect.catchAll((cause) => |
| 713 | Effect.fail(toBadRequestError("sources.connect", cause)), | 694 | Effect.fail(toBadRequestError("sources.connect", cause)), |
| 714 | ), | 695 | ), |
| 715 | ), | 696 | ) |
| 716 | ), | 697 | ), |
| 717 | ), | 698 | ), |
| 718 | ) | 699 | ) |
| 719 | .handle("list", ({ path }) => | 700 | .handle("list", ({ path }) => |
| 720 | withWorkspaceRequestActor("sources.list", path.workspaceId, () => | 701 | resolveRequestedLocalWorkspace("sources.list", path.workspaceId).pipe( |
| 721 | withPolicy(requireReadSources(path.workspaceId))( | 702 | Effect.flatMap((runtimeLocalWorkspace) => |
| 722 | listSources(path.workspaceId), | 703 | listSources({ |
| 704 | workspaceId: path.workspaceId, | ||
| 705 | accountId: runtimeLocalWorkspace.installation.accountId, | ||
| 706 | }) | ||
| 723 | ), | 707 | ), |
| 724 | ), | 708 | ), |
| 725 | ) | 709 | ) |
| 726 | .handle("create", ({ path, payload }) => | 710 | .handle("create", ({ path, payload }) => |
| 727 | withWorkspaceRequestActor("sources.create", path.workspaceId, () => | 711 | resolveRequestedLocalWorkspace("sources.create", path.workspaceId).pipe( |
| 728 | withPolicy(requireWriteSources(path.workspaceId))( | 712 | Effect.flatMap((runtimeLocalWorkspace) => |
| 729 | createSource({ workspaceId: path.workspaceId, payload }), | 713 | createSource({ |
| 714 | workspaceId: path.workspaceId, | ||
| 715 | accountId: runtimeLocalWorkspace.installation.accountId, | ||
| 716 | payload, | ||
| 717 | }) | ||
| 730 | ), | 718 | ), |
| 731 | ), | 719 | ), |
| 732 | ) | 720 | ) |
| 733 | .handle("get", ({ path }) => | 721 | .handle("get", ({ path }) => |
| 734 | withWorkspaceRequestActor("sources.get", path.workspaceId, () => | 722 | resolveRequestedLocalWorkspace("sources.get", path.workspaceId).pipe( |
| 735 | withPolicy(requireReadSources(path.workspaceId))( | 723 | Effect.flatMap((runtimeLocalWorkspace) => |
| 736 | getSource({ | 724 | getSource({ |
| 737 | workspaceId: path.workspaceId, | 725 | workspaceId: path.workspaceId, |
| 738 | sourceId: path.sourceId, | 726 | sourceId: path.sourceId, |
| 739 | }), | 727 | accountId: runtimeLocalWorkspace.installation.accountId, |
| 728 | }) | ||
| 740 | ), | 729 | ), |
| 741 | ), | 730 | ), |
| 742 | ) | 731 | ) |
| 743 | .handle("inspection", ({ path }) => | 732 | .handle("inspection", ({ path }) => |
| 744 | withWorkspaceRequestActor("sources.inspection", path.workspaceId, () => | 733 | resolveRequestedLocalWorkspace("sources.inspection", path.workspaceId).pipe( |
| 745 | withPolicy(requireReadSources(path.workspaceId))( | 734 | Effect.zipRight( |
| 746 | getSourceInspection({ | 735 | getSourceInspection({ |
| 747 | workspaceId: path.workspaceId, | 736 | workspaceId: path.workspaceId, |
| 748 | sourceId: path.sourceId, | 737 | sourceId: path.sourceId, |
| @@ -751,8 +740,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 2 unmodified lines | |||
| 751 | ), | 740 | ), |
| 752 | ) | 741 | ) |
| 753 | .handle("inspectionTool", ({ path }) => | 742 | .handle("inspectionTool", ({ path }) => |
| 754 | withWorkspaceRequestActor("sources.inspection_tool", path.workspaceId, () => | 743 | resolveRequestedLocalWorkspace("sources.inspection_tool", path.workspaceId).pipe( |
| 755 | withPolicy(requireReadSources(path.workspaceId))( | 744 | Effect.zipRight( |
| 756 | getSourceInspectionToolDetail({ | 745 | getSourceInspectionToolDetail({ |
| 757 | workspaceId: path.workspaceId, | 746 | workspaceId: path.workspaceId, |
| 758 | sourceId: path.sourceId, | 747 | sourceId: path.sourceId, |
| @@ -762,8 +751,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | |||
| 762 | ), | 751 | ), |
| 763 | ) | 752 | ) |
| 764 | .handle("inspectionSchemaBundle", ({ path }) => | 753 | .handle("inspectionSchemaBundle", ({ path }) => |
| 765 | withWorkspaceRequestActor("sources.inspection_schema_bundle", path.workspaceId, () => | 754 | resolveRequestedLocalWorkspace("sources.inspection_schema_bundle", path.workspaceId).pipe( |
| 766 | withPolicy(requireReadSources(path.workspaceId))( | 755 | Effect.zipRight( |
| 767 | getSourceInspectionSchemaBundle({ | 756 | getSourceInspectionSchemaBundle({ |
| 768 | workspaceId: path.workspaceId, | 757 | workspaceId: path.workspaceId, |
| 769 | sourceId: path.sourceId, | 758 | sourceId: path.sourceId, |
| @@ -773,8 +762,8 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | |||
| 773 | ), | 762 | ), |
| 774 | ) | 763 | ) |
| 775 | .handle("inspectionDiscover", ({ path, payload }) => | 764 | .handle("inspectionDiscover", ({ path, payload }) => |
| 776 | withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () => | 765 | resolveRequestedLocalWorkspace("sources.inspection_discover", path.workspaceId).pipe( |
| 777 | withPolicy(requireReadSources(path.workspaceId))( | 766 | Effect.zipRight( |
| 778 | discoverSourceInspectionTools({ | 767 | discoverSourceInspectionTools({ |
| 779 | workspaceId: path.workspaceId, | 768 | workspaceId: path.workspaceId, |
| 780 | sourceId: path.sourceId, | 769 | sourceId: path.sourceId, |
| @@ -784,19 +773,20 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | |||
| 784 | ), | 773 | ), |
| 785 | ) | 774 | ) |
| 786 | .handle("update", ({ path, payload }) => | 775 | .handle("update", ({ path, payload }) => |
| 787 | withWorkspaceRequestActor("sources.update", path.workspaceId, () => | 776 | resolveRequestedLocalWorkspace("sources.update", path.workspaceId).pipe( |
| 788 | withPolicy(requireWriteSources(path.workspaceId))( | 777 | Effect.flatMap((runtimeLocalWorkspace) => |
| 789 | updateSource({ | 778 | updateSource({ |
| 790 | workspaceId: path.workspaceId, | 779 | workspaceId: path.workspaceId, |
| 791 | sourceId: path.sourceId, | 780 | sourceId: path.sourceId, |
| 781 | accountId: runtimeLocalWorkspace.installation.accountId, | ||
| 792 | payload, | 782 | payload, |
| 793 | }), | 783 | }) |
| 794 | ), | 784 | ), |
| 795 | ), | 785 | ), |
| 796 | ) | 786 | ) |
| 797 | .handle("remove", ({ path }) => | 787 | .handle("remove", ({ path }) => |
| 798 | withWorkspaceRequestActor("sources.remove", path.workspaceId, () => | 788 | resolveRequestedLocalWorkspace("sources.remove", path.workspaceId).pipe( |
| 799 | withPolicy(requireWriteSources(path.workspaceId))( | 789 | Effect.zipRight( |
| 800 | removeSource({ | 790 | removeSource({ |
| 801 | workspaceId: path.workspaceId, | 791 | workspaceId: path.workspaceId, |
| 802 | sourceId: path.sourceId, | 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 | import { | 1 | import { |
| 2 | FetchHttpClient, | 2 | FetchHttpClient, |
| 3 | HttpApiClient, | 3 | HttpApiClient, |
| 4 | HttpClient, | ||
| 5 | HttpClientRequest, | ||
| 6 | } from "@effect/platform"; | 4 | } from "@effect/platform"; |
| 7 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 8 | 6 | ||
| 9 | import { ControlPlaneApi } from "./api/api"; | 7 | import { ControlPlaneApi } from "./api/api"; |
| 10 | import { ControlPlaneAuthHeaders } from "./auth-headers"; | ||
| 11 | 8 | ||
| 12 | export const createControlPlaneClient = (input: { | 9 | export const createControlPlaneClient = (input: { |
| 13 | baseUrl: string; | 10 | baseUrl: string; |
| 14 | accountId?: string; | 11 | accountId?: string; |
| 15 | }) => { | 12 | }) => |
| 16 | const accountId = input.accountId; | 13 | HttpApiClient.make(ControlPlaneApi, { |
| 17 | |||
| 18 | return HttpApiClient.make(ControlPlaneApi, { | ||
| 19 | baseUrl: input.baseUrl, | 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 | }).pipe(Effect.provide(FetchHttpClient.layer)); | 15 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| 32 | }; | ||
| 33 | 16 | ||
| 34 | export type ControlPlaneClient = Effect.Effect.Success< | 17 | export type ControlPlaneClient = Effect.Effect.Success< |
| 35 | ReturnType<typeof createControlPlaneClient> | 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 | export * from "./api/index"; | 3 | export * from "./api/index"; |
| 4 | export * from "./runtime/index"; | 4 | export * from "./runtime/index"; |
| 5 | export * from "./client"; | 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 | createCodeMigrationsRepo, | 11 | createCodeMigrationsRepo, |
| 12 | createExecutionInteractionsRepo, | 12 | createExecutionInteractionsRepo, |
| 13 | createExecutionsRepo, | 13 | createExecutionsRepo, |
| 14 | createLocalInstallationsRepo, | ||
| 15 | createOrganizationMembershipsRepo, | ||
| 16 | createOrganizationsRepo, | ||
| 17 | createPoliciesRepo, | ||
| 18 | createSecretMaterialsRepo, | 14 | createSecretMaterialsRepo, |
| 19 | createSourceAuthSessionsRepo, | 15 | createSourceAuthSessionsRepo, |
| 20 | createSourceOauthClientsRepo, | 16 | createSourceOauthClientsRepo, |
| @@ -24,7 +20,6 @@ import { · 3 unmodified lines | |||
| 24 | createSourceRecipeRevisionsRepo, | 20 | createSourceRecipeRevisionsRepo, |
| 25 | createSourceRecipesRepo, | 21 | createSourceRecipesRepo, |
| 26 | createSourcesRepo, | 22 | createSourcesRepo, |
| 27 | createWorkspacesRepo, | ||
| 28 | } from "./repos"; | 23 | } from "./repos"; |
| 29 | import { drizzleSchema, tableNames, type DrizzleTables } from "./schema"; | 24 | import { drizzleSchema, tableNames, type DrizzleTables } from "./schema"; |
| 30 | import { | 25 | import { |
| @@ -55,9 +50,6 @@ export { · 24 unmodified lines | |||
| 55 | 50 | ||
| 56 | const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema) => ({ | 51 | const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema) => ({ |
| 57 | accounts: createAccountsRepo(client, tables), | 52 | accounts: createAccountsRepo(client, tables), |
| 58 | organizations: createOrganizationsRepo(client, tables), | ||
| 59 | organizationMemberships: createOrganizationMembershipsRepo(client, tables), | ||
| 60 | workspaces: createWorkspacesRepo(client, tables), | ||
| 61 | sources: createSourcesRepo(client, tables), | 53 | sources: createSourcesRepo(client, tables), |
| 62 | sourceRecipes: createSourceRecipesRepo(client, tables), | 54 | sourceRecipes: createSourceRecipesRepo(client, tables), |
| 63 | sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), | 55 | sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), |
| @@ -70,8 +62,6 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 6 unmodified lines | |||
| 70 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), | 62 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), |
| 71 | secretMaterials: createSecretMaterialsRepo(client, tables), | 63 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 72 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), | 64 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| 73 | policies: createPoliciesRepo(client, tables), | ||
| 74 | localInstallations: createLocalInstallationsRepo(client, tables), | ||
| 75 | executions: createExecutionsRepo(client, tables), | 65 | executions: createExecutionsRepo(client, tables), |
| 76 | executionInteractions: createExecutionInteractionsRepo(client, tables), | 66 | executionInteractions: createExecutionInteractionsRepo(client, tables), |
| 77 | }); | 67 | }); |
| @@ -7,7 +7,6 @@ import { describe, expect, it } from "@effect/vitest"; · 6 unmodified lines | |||
| 7 | import { | 7 | import { |
| 8 | AccountIdSchema, | 8 | AccountIdSchema, |
| 9 | McpSourceAuthSessionDataJsonSchema, | 9 | McpSourceAuthSessionDataJsonSchema, |
| 10 | OrganizationIdSchema, | ||
| 11 | SourceAuthSessionIdSchema, | 10 | SourceAuthSessionIdSchema, |
| 12 | decodeAuthLeasePlacementTemplates, | 11 | decodeAuthLeasePlacementTemplates, |
| 13 | SourceIdSchema, | 12 | SourceIdSchema, |
| @@ -27,7 +26,6 @@ import { · 13 unmodified lines | |||
| 27 | type SqlControlPlanePersistence, | 26 | type SqlControlPlanePersistence, |
| 28 | } from "./index"; | 27 | } from "./index"; |
| 29 | import { runCodeMigrations } from "./code-migrations"; | 28 | import { runCodeMigrations } from "./code-migrations"; |
| 30 | import { loadSourceById } from "../runtime/source-store"; | ||
| 31 | 29 | ||
| 32 | const openApiBindingConfigJson = (specUrl: string): string => | 30 | const openApiBindingConfigJson = (specUrl: string): string => |
| 33 | JSON.stringify({ | 31 | JSON.stringify({ |
| @@ -124,29 +122,11 @@ const seedMigratedSourceRecipe = (input: { · 90 unmodified lines | |||
| 124 | Effect.gen(function* () { | 122 | Effect.gen(function* () { |
| 125 | const now = Date.now(); | 123 | const now = Date.now(); |
| 126 | const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); | 124 | const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); |
| 127 | const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`); | ||
| 128 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); | 125 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); |
| 129 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( | 126 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( |
| 130 | `src_recipe_rev_${input.sourceId}`, | 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 | yield* input.persistence.rows.sourceRecipes.upsert({ | 130 | yield* input.persistence.rows.sourceRecipes.upsert({ |
| 151 | id: recipeId, | 131 | id: recipeId, |
| 152 | kind: "http_api", | 132 | kind: "http_api", |
| @@ -318,6 +298,7 @@ describe("code-migrations", () => { · 165 unmodified lines | |||
| 318 | )).length, | 298 | )).length, |
| 319 | ).toBeGreaterThan(0); | 299 | ).toBeGreaterThan(0); |
| 320 | }), | 300 | }), |
| 301 | 60_000, | ||
| 321 | ); | 302 | ); |
| 322 | 303 | ||
| 323 | it.scoped("repairs migrated GraphQL recipes from stored documents", () => | 304 | it.scoped("repairs migrated GraphQL recipes from stored documents", () => |
| @@ -376,6 +357,7 @@ describe("code-migrations", () => { · 52 unmodified lines | |||
| 376 | )).length, | 357 | )).length, |
| 377 | ).toBeGreaterThan(0); | 358 | ).toBeGreaterThan(0); |
| 378 | }), | 359 | }), |
| 360 | 60_000, | ||
| 379 | ); | 361 | ); |
| 380 | 362 | ||
| 381 | it.scoped("repairs legacy MCP source auth session payloads", () => | 363 | it.scoped("repairs legacy MCP source auth session payloads", () => |
| @@ -450,7 +432,6 @@ describe("code-migrations", () => { · 68 unmodified lines | |||
| 450 | ); | 432 | ); |
| 451 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_source_bindings"); | 433 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_source_bindings"); |
| 452 | const accountId = AccountIdSchema.make("acc_legacy_source_bindings"); | 434 | const accountId = AccountIdSchema.make("acc_legacy_source_bindings"); |
| 453 | const organizationId = OrganizationIdSchema.make("org_legacy_source_bindings"); | ||
| 454 | const now = Date.now(); | 435 | const now = Date.now(); |
| 455 | let legacyPersistence: SqlControlPlanePersistence | null = null; | 436 | let legacyPersistence: SqlControlPlanePersistence | null = null; |
| 456 | let upgradedPersistence: SqlControlPlanePersistence | null = null; | 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 | const seedRecipe = async (input: { | 448 | const seedRecipe = async (input: { |
| 486 | sourceId: string; | 449 | sourceId: string; |
| 487 | kind: "openapi" | "graphql" | "mcp"; | 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( | 659 | const openApiSource = await Effect.runPromise( |
| 697 | upgradedPersistence.rows, | 660 | upgradedPersistence.rows.sources.getByWorkspaceAndId( |
| 698 | { | ||
| 699 | workspaceId, | 661 | workspaceId, |
| 700 | sourceId: SourceIdSchema.make("src_legacy_openapi"), | 662 | SourceIdSchema.make("src_legacy_openapi"), |
| 701 | }, | 663 | ), |
| 702 | )); | 664 | ); |
| 703 | expect(openApiSource.binding).toEqual({ | 665 | expect( |
| 704 | specUrl: "https://api.example.com/openapi.json", | 666 | JSON.parse(Option.getOrThrow(openApiSource).bindingConfigJson), |
| 705 | defaultHeaders: { | 667 | ).toEqual({ |
| 706 | accept: "application/json", | 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( | 678 | const graphqlSource = await Effect.runPromise( |
| 712 | upgradedPersistence.rows, | 679 | upgradedPersistence.rows.sources.getByWorkspaceAndId( |
| 713 | { | ||
| 714 | workspaceId, | 680 | workspaceId, |
| 715 | sourceId: SourceIdSchema.make("src_legacy_graphql"), | 681 | SourceIdSchema.make("src_legacy_graphql"), |
| 716 | }, | 682 | ), |
| 717 | )); | 683 | ); |
| 718 | expect(graphqlSource.binding).toEqual({ | 684 | expect( |
| 719 | defaultHeaders: { | 685 | JSON.parse(Option.getOrThrow(graphqlSource).bindingConfigJson), |
| 720 | accept: "application/json", | 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 | expect(Option.getOrNull(graphqlRecipe)?.adapterKey).toBe("graphql"); | 699 | expect(Option.getOrNull(graphqlRecipe)?.adapterKey).toBe("graphql"); |
| 728 | 700 | ||
| 729 | const mcpSource = await Effect.runPromise(loadSourceById( | 701 | const mcpSource = await Effect.runPromise( |
| 730 | upgradedPersistence.rows, | 702 | upgradedPersistence.rows.sources.getByWorkspaceAndId( |
| 731 | { | ||
| 732 | workspaceId, | 703 | workspaceId, |
| 733 | sourceId: SourceIdSchema.make("src_legacy_mcp"), | 704 | SourceIdSchema.make("src_legacy_mcp"), |
| 734 | }, | 705 | ), |
| 735 | )); | 706 | ); |
| 736 | expect(mcpSource.binding).toEqual({ | 707 | expect( |
| 737 | transport: "streamable-http", | 708 | JSON.parse(Option.getOrThrow(mcpSource).bindingConfigJson), |
| 738 | queryParams: { | 709 | ).toEqual({ |
| 739 | tenant: "acme", | 710 | adapterKey: "mcp", |
| 740 | }, | 711 | version: 1, |
| 741 | headers: { | 712 | payload: { |
| 742 | "x-tenant": "acme", | 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 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_auth_lease"); | 740 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_auth_lease"); |
| 764 | const accountId = AccountIdSchema.make("acc_legacy_auth_lease"); | 741 | const accountId = AccountIdSchema.make("acc_legacy_auth_lease"); |
| 765 | const organizationId = OrganizationIdSchema.make("org_legacy_auth_lease"); | ||
| 766 | const sourceId = SourceIdSchema.make("src_legacy_auth_lease"); | 742 | const sourceId = SourceIdSchema.make("src_legacy_auth_lease"); |
| 767 | const recipeId = SourceRecipeIdSchema.make("src_recipe_legacy_auth_lease"); | 743 | const recipeId = SourceRecipeIdSchema.make("src_recipe_legacy_auth_lease"); |
| 768 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( | 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 | await Effect.runPromise(legacyPersistence.rows.sourceRecipes.upsert({ | 761 | await Effect.runPromise(legacyPersistence.rows.sourceRecipes.upsert({ |
| 803 | id: recipeId, | 762 | id: recipeId, |
| 804 | kind: "http_api", | 763 | kind: "http_api", |
| @@ -823,24 +782,43 @@ describe("code-migrations", () => { · 18 unmodified lines | |||
| 823 | createdAt: now, | 782 | createdAt: now, |
| 824 | updatedAt: now, | 783 | updatedAt: now, |
| 825 | }))); | 784 | }))); |
| 826 | await Effect.runPromise(legacyPersistence.rows.sources.insert({ | 785 | await legacyPersistence.db.execute(sql` |
| 827 | id: sourceId, | 786 | INSERT INTO "sources" ( |
| 828 | workspaceId, | 787 | "source_id", |
| 829 | recipeId, | 788 | "workspace_id", |
| 830 | recipeRevisionId, | 789 | "recipe_id", |
| 831 | name: "Legacy OpenAPI", | 790 | "recipe_revision_id", |
| 832 | kind: "openapi", | 791 | "name", |
| 833 | endpoint: "https://api.example.com", | 792 | "kind", |
| 834 | status: "connected", | 793 | "endpoint", |
| 835 | enabled: true, | 794 | "status", |
| 836 | namespace: "legacy.openapi", | 795 | "enabled", |
| 837 | importAuthPolicy: "reuse_runtime", | 796 | "namespace", |
| 838 | bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"), | 797 | "import_auth_policy", |
| 839 | sourceHash: null, | 798 | "binding_config_json", |
| 840 | lastError: null, | 799 | "source_hash", |
| 841 | createdAt: now, | 800 | "last_error", |
| 842 | updatedAt: now, | 801 | "created_at", |
| 843 | })); | 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 | await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({ | 822 | await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({ |
| 845 | id: authArtifactId as any, | 823 | id: authArtifactId as any, |
| 846 | workspaceId, | 824 | workspaceId, |
| @@ -1,7 +1,4 @@ | |||
| 1 | export { createAccountsRepo } from "./accounts-repo"; | 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 | export { createSourcesRepo } from "./sources-repo"; | 2 | export { createSourcesRepo } from "./sources-repo"; |
| 6 | export { createSourceRecipesRepo } from "./source-recipes-repo"; | 3 | export { createSourceRecipesRepo } from "./source-recipes-repo"; |
| 7 | export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; | 4 | export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; |
| @@ -14,7 +11,5 @@ export { createAuthLeasesRepo } from "./auth-leases-repo"; · 6 unmodified lines | |||
| 14 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; | 11 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; |
| 15 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; | 12 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 16 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; | 13 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| 17 | export { createPoliciesRepo } from "./policies-repo"; | ||
| 18 | export { createLocalInstallationsRepo } from "./local-installations-repo"; | ||
| 19 | export { createExecutionsRepo } from "./executions-repo"; | 14 | export { createExecutionsRepo } from "./executions-repo"; |
| 20 | export { createExecutionInteractionsRepo } from "./execution-interactions-repo"; | 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 | export const tableNames = { | 13 | export const tableNames = { |
| 14 | accounts: "accounts", | 14 | accounts: "accounts", |
| 15 | organizations: "organizations", | ||
| 16 | organizationMemberships: "organization_memberships", | ||
| 17 | workspaces: "workspaces", | ||
| 18 | sources: "sources", | 15 | sources: "sources", |
| 19 | sourceRecipes: "source_recipes", | 16 | sourceRecipes: "source_recipes", |
| 20 | sourceRecipeRevisions: "source_recipe_revisions", | 17 | sourceRecipeRevisions: "source_recipe_revisions", |
| @@ -27,8 +24,6 @@ export const tableNames = { · 6 unmodified lines | |||
| 27 | workspaceSourceOauthClients: "workspace_source_oauth_clients", | 24 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 28 | secretMaterials: "secret_materials", | 25 | secretMaterials: "secret_materials", |
| 29 | sourceAuthSessions: "source_auth_sessions", | 26 | sourceAuthSessions: "source_auth_sessions", |
| 30 | policies: "policies", | ||
| 31 | localInstallations: "local_installations", | ||
| 32 | executions: "executions", | 27 | executions: "executions", |
| 33 | executionInteractions: "execution_interactions", | 28 | executionInteractions: "execution_interactions", |
| 34 | } as const; | 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 | export const sourcesTable = pgTable( | 52 | export const sourcesTable = pgTable( |
| 135 | tableNames.sources, | 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 | export const executionsTable = pgTable( | 441 | export const executionsTable = pgTable( |
| 604 | tableNames.executions, | 442 | tableNames.executions, |
| 605 | { | 443 | { |
| @@ -653,9 +491,6 @@ export const executionInteractionsTable = pgTable( · 47 unmodified lines | |||
| 653 | 491 | ||
| 654 | export const drizzleSchema = { | 492 | export const drizzleSchema = { |
| 655 | accountsTable, | 493 | accountsTable, |
| 656 | organizationsTable, | ||
| 657 | organizationMembershipsTable, | ||
| 658 | workspacesTable, | ||
| 659 | sourcesTable, | 494 | sourcesTable, |
| 660 | sourceRecipesTable, | 495 | sourceRecipesTable, |
| 661 | sourceRecipeRevisionsTable, | 496 | sourceRecipeRevisionsTable, |
| @@ -668,8 +503,6 @@ export const drizzleSchema = { · 6 unmodified lines | |||
| 668 | workspaceSourceOauthClientsTable, | 503 | workspaceSourceOauthClientsTable, |
| 669 | secretMaterialsTable, | 504 | secretMaterialsTable, |
| 670 | sourceAuthSessionsTable, | 505 | sourceAuthSessionsTable, |
| 671 | policiesTable, | ||
| 672 | localInstallationsTable, | ||
| 673 | executionsTable, | 506 | executionsTable, |
| 674 | executionInteractionsTable, | 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 | import { createServer } from "node:http"; | 1 | import { createServer } from "node:http"; |
| 2 | import { mkdtempSync, readFileSync } from "node:fs"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 2 | 5 | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import { assertTrue } from "@effect/vitest/utils"; | 7 | import { assertTrue } from "@effect/vitest/utils"; |
| @@ -12,33 +15,26 @@ import { · 7 unmodified lines | |||
| 12 | ExecutionInteractionIdSchema, | 15 | ExecutionInteractionIdSchema, |
| 13 | SecretMaterialIdSchema, | 16 | SecretMaterialIdSchema, |
| 14 | SourceIdSchema, | 17 | SourceIdSchema, |
| 15 | SourceRecipeIdSchema, | ||
| 16 | SourceRecipeRevisionIdSchema, | ||
| 17 | } from "#schema"; | 18 | } from "#schema"; |
| 18 | import type { ToolPath } from "@executor/codemode-core"; | 19 | import type { ToolPath } from "@executor/codemode-core"; |
| 19 | 20 | ||
| 20 | import { | 21 | import { |
| 21 | createSqlControlPlaneRuntime, | 22 | createControlPlaneRuntime, |
| 22 | LiveExecutionManagerService, | 23 | LiveExecutionManagerService, |
| 23 | } from "./index"; | 24 | } from "./index"; |
| 25 | import { createSourceFromPayload } from "./source-definitions"; | ||
| 24 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; | 26 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; |
| 27 | import { persistSource } from "./source-store"; | ||
| 25 | import { withControlPlaneClient } from "./test-http-client"; | 28 | import { withControlPlaneClient } from "./test-http-client"; |
| 26 | 29 | ||
| 27 | const makeRuntime = Effect.acquireRelease( | 30 | const makeRuntime = Effect.acquireRelease( |
| 28 | createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | 31 | createControlPlaneRuntime({ |
| 32 | localDataDir: ":memory:", | ||
| 33 | workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")), | ||
| 34 | }), | ||
| 29 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | 35 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 30 | ); | 36 | ); |
| 31 | 37 | ||
| 32 | const openApiBindingConfigJson = (specUrl: string): string => | ||
| 33 | JSON.stringify({ | ||
| 34 | adapterKey: "openapi", | ||
| 35 | version: 1, | ||
| 36 | payload: { | ||
| 37 | specUrl, | ||
| 38 | defaultHeaders: null, | ||
| 39 | }, | ||
| 40 | }); | ||
| 41 | |||
| 42 | type OpenApiSpecServer = { | 38 | type OpenApiSpecServer = { |
| 43 | baseUrl: string; | 39 | baseUrl: string; |
| 44 | specUrl: string; | 40 | specUrl: string; |
| @@ -123,155 +119,72 @@ const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) => · 78 unmodified lines | |||
| 123 | ); | 119 | ); |
| 124 | 120 | ||
| 125 | describe("control-plane-runtime", () => { | 121 | describe("control-plane-runtime", () => { |
| 126 | it.scoped("supports full CRUD flow over HTTP API", () => | 122 | it.scoped("writes local source changes through executor.jsonc", () => |
| 127 | Effect.gen(function* () { | 123 | Effect.gen(function* () { |
| 128 | const runtime = yield* makeRuntime; | 124 | const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-local-config-runtime-")); |
| 129 | const openApiServer = yield* makeOpenApiSpecServer; | 125 | const runtime = yield* Effect.acquireRelease( |
| 130 | 126 | createControlPlaneRuntime({ | |
| 131 | const createOrg = yield* withControlPlaneClient( | 127 | localDataDir: ":memory:", |
| 132 | { runtime, accountId: "acc_1" }, | 128 | workspaceRoot, |
| 133 | (client) => | 129 | }), |
| 134 | client.organizations.create({ | 130 | (createdRuntime) => Effect.promise(() => createdRuntime.close()).pipe(Effect.orDie), |
| 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 | }), | ||
| 149 | ); | 131 | ); |
| 150 | expect(createWorkspace.createdByAccountId).toBe("acc_1"); | 132 | const openApiServer = yield* makeOpenApiSpecServer; |
| 151 | const workspaceId = createWorkspace.id; | 133 | const installation = runtime.localInstallation; |
| 152 | 134 | ||
| 153 | yield* withControlPlaneClient( | 135 | const createdSource = yield* withControlPlaneClient( |
| 154 | { runtime, accountId: "acc_1" }, | 136 | { runtime, accountId: installation.accountId }, |
| 155 | (client) => | 137 | (client) => |
| 156 | client.sources.create({ | 138 | client.sources.create({ |
| 157 | path: { workspaceId }, | 139 | path: { workspaceId: installation.workspaceId }, |
| 158 | payload: { | 140 | payload: { |
| 159 | name: "Github", | 141 | name: "GitHub", |
| 160 | kind: "openapi", | 142 | kind: "openapi", |
| 161 | endpoint: openApiServer.baseUrl, | 143 | endpoint: openApiServer.baseUrl, |
| 144 | namespace: "github", | ||
| 162 | binding: { | 145 | binding: { |
| 163 | specUrl: openApiServer.specUrl, | 146 | specUrl: openApiServer.specUrl, |
| 164 | defaultHeaders: null, | 147 | defaultHeaders: null, |
| 165 | }, | 148 | }, |
| 166 | auth: { | 149 | auth: { kind: "none" }, |
| 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, | ||
| 186 | }, | 150 | }, |
| 187 | }), | 151 | }), |
| 188 | ); | 152 | ); |
| 189 | 153 | ||
| 190 | const listSources = yield* withControlPlaneClient( | 154 | const configPath = join(workspaceRoot, ".executor", "executor.jsonc"); |
| 191 | { runtime, accountId: "acc_1" }, | 155 | const createdConfig = JSON.parse(readFileSync(configPath, "utf8")) as { |
| 192 | (client) => | 156 | sources?: Record<string, { kind: string; connection: { endpoint: string } }>; |
| 193 | client.sources.list({ | 157 | }; |
| 194 | path: { workspaceId }, | 158 | expect(createdConfig.sources?.github?.kind).toBe("openapi"); |
| 195 | }), | 159 | expect(createdConfig.sources?.github?.connection.endpoint).toBe(openApiServer.baseUrl); |
| 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; | ||
| 214 | 160 | ||
| 215 | const organization = yield* withControlPlaneClient( | 161 | const removed = yield* withControlPlaneClient( |
| 216 | { runtime, accountId: "acc_discover" }, | 162 | { runtime, accountId: installation.accountId }, |
| 217 | (client) => | ||
| 218 | client.organizations.create({ | ||
| 219 | payload: { name: "Discovery Org" }, | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | const workspace = yield* withControlPlaneClient( | ||
| 223 | { runtime, accountId: "acc_discover" }, | ||
| 224 | (client) => | 163 | (client) => |
| 225 | client.workspaces.create({ | 164 | client.sources.remove({ |
| 226 | path: { organizationId: organization.id }, | 165 | path: { |
| 227 | payload: { name: "Discovery Workspace" }, | 166 | workspaceId: installation.workspaceId, |
| 228 | }), | 167 | sourceId: createdSource.id, |
| 229 | ); | ||
| 230 | |||
| 231 | const discovered = yield* withControlPlaneClient( | ||
| 232 | { runtime, accountId: "acc_discover" }, | ||
| 233 | (client) => | ||
| 234 | client.sources.discover({ | ||
| 235 | payload: { | ||
| 236 | url: openApiServer.specUrl, | ||
| 237 | }, | 168 | }, |
| 238 | }), | 169 | }), |
| 239 | ); | 170 | ); |
| 240 | expect(discovered.detectedKind).toBe("openapi"); | 171 | expect(removed.removed).toBe(true); |
| 241 | expect(discovered.specUrl).toBe(openApiServer.specUrl); | ||
| 242 | expect(discovered.endpoint).toBe(openApiServer.baseUrl); | ||
| 243 | 172 | ||
| 244 | const connected = yield* withControlPlaneClient( | 173 | const removedConfig = JSON.parse(readFileSync(configPath, "utf8")) as { |
| 245 | { runtime, accountId: "acc_discover" }, | 174 | sources?: Record<string, unknown>; |
| 246 | (client) => | 175 | }; |
| 247 | client.sources.connect({ | 176 | expect(removedConfig.sources?.github).toBeUndefined(); |
| 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 | }); | ||
| 266 | }), | 177 | }), |
| 178 | 60_000, | ||
| 267 | ); | 179 | ); |
| 268 | 180 | ||
| 181 | |||
| 269 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => | 182 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => |
| 270 | Effect.gen(function* () { | 183 | Effect.gen(function* () { |
| 271 | const runtime = yield* makeRuntime; | 184 | const runtime = yield* makeRuntime; |
| 272 | const installation = runtime.localInstallation; | 185 | const installation = runtime.localInstallation; |
| 273 | const executionId = ExecutionIdSchema.make("exec_local_credential"); | 186 | const executionId = ExecutionIdSchema.make("exec_local_credential"); |
| 274 | const sourceId = SourceIdSchema.make("src_local_credential"); | 187 | const sourceId = SourceIdSchema.make("github"); |
| 275 | const interactionSuffix = "executor.sources.add:test"; | 188 | const interactionSuffix = "executor.sources.add:test"; |
| 276 | const interactionId = ExecutionInteractionIdSchema.make( | 189 | const interactionId = ExecutionInteractionIdSchema.make( |
| 277 | `${executionId}:${interactionSuffix}`, | 190 | `${executionId}:${interactionSuffix}`, |
| @@ -293,24 +206,32 @@ describe("control-plane-runtime", () => { · 15 unmodified lines | |||
| 293 | updatedAt: now, | 206 | updatedAt: now, |
| 294 | }); | 207 | }); |
| 295 | 208 | ||
| 296 | yield* runtime.persistence.rows.sources.insert({ | 209 | const localSource = yield* createSourceFromPayload({ |
| 297 | id: sourceId, | ||
| 298 | workspaceId: installation.workspaceId, | 210 | workspaceId: installation.workspaceId, |
| 299 | recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`), | 211 | sourceId, |
| 300 | recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`), | 212 | payload: { |
| 301 | name: "GitHub", | 213 | name: "GitHub", |
| 302 | kind: "openapi", | 214 | kind: "openapi", |
| 303 | endpoint: "https://api.github.com", | 215 | endpoint: "https://api.github.com", |
| 304 | status: "auth_required", | 216 | status: "auth_required", |
| 305 | enabled: true, | 217 | enabled: true, |
| 306 | namespace: "github", | 218 | namespace: "github", |
| 307 | importAuthPolicy: "reuse_runtime", | 219 | importAuthPolicy: "reuse_runtime", |
| 308 | bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), | 220 | binding: { |
| 309 | sourceHash: null, | 221 | specUrl: "https://example.com/github-openapi.yaml", |
| 310 | lastError: null, | 222 | defaultHeaders: null, |
| 311 | createdAt: now, | 223 | }, |
| 312 | updatedAt: now, | 224 | importAuth: { kind: "none" }, |
| 313 | }); | 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 | const interactionFiber = yield* Effect.gen(function* () { | 236 | const interactionFiber = yield* Effect.gen(function* () { |
| 316 | const liveExecutionManager = yield* LiveExecutionManagerService; | 237 | const liveExecutionManager = yield* LiveExecutionManagerService; |
| @@ -426,7 +347,7 @@ describe("control-plane-runtime", () => { · 109 unmodified lines | |||
| 426 | const runtime = yield* makeRuntime; | 347 | const runtime = yield* makeRuntime; |
| 427 | const installation = runtime.localInstallation; | 348 | const installation = runtime.localInstallation; |
| 428 | const executionId = ExecutionIdSchema.make("exec_local_credential_continue"); | 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 | const interactionSuffix = "executor.sources.add:continue"; | 351 | const interactionSuffix = "executor.sources.add:continue"; |
| 431 | const interactionId = ExecutionInteractionIdSchema.make( | 352 | const interactionId = ExecutionInteractionIdSchema.make( |
| 432 | `${executionId}:${interactionSuffix}`, | 353 | `${executionId}:${interactionSuffix}`, |
| @@ -448,24 +369,32 @@ describe("control-plane-runtime", () => { · 15 unmodified lines | |||
| 448 | updatedAt: now, | 369 | updatedAt: now, |
| 449 | }); | 370 | }); |
| 450 | 371 | ||
| 451 | yield* runtime.persistence.rows.sources.insert({ | 372 | const localSource = yield* createSourceFromPayload({ |
| 452 | id: sourceId, | ||
| 453 | workspaceId: installation.workspaceId, | 373 | workspaceId: installation.workspaceId, |
| 454 | recipeId: SourceRecipeIdSchema.make(`src_recipe_${sourceId}`), | 374 | sourceId, |
| 455 | recipeRevisionId: SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`), | 375 | payload: { |
| 456 | name: "GitHub", | 376 | name: "GitHub", |
| 457 | kind: "openapi", | 377 | kind: "openapi", |
| 458 | endpoint: "https://api.github.com", | 378 | endpoint: "https://api.github.com", |
| 459 | status: "auth_required", | 379 | status: "auth_required", |
| 460 | enabled: true, | 380 | enabled: true, |
| 461 | namespace: "github", | 381 | namespace: "github", |
| 462 | importAuthPolicy: "reuse_runtime", | 382 | importAuthPolicy: "reuse_runtime", |
| 463 | bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), | 383 | binding: { |
| 464 | sourceHash: null, | 384 | specUrl: "https://example.com/github-openapi.yaml", |
| 465 | lastError: null, | 385 | defaultHeaders: null, |
| 466 | createdAt: now, | 386 | }, |
| 467 | updatedAt: now, | 387 | importAuth: { kind: "none" }, |
| 468 | }); | 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 | const interactionFiber = yield* Effect.gen(function* () { | 399 | const interactionFiber = yield* Effect.gen(function* () { |
| 471 | const liveExecutionManager = yield* LiveExecutionManagerService; | 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 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| @@ -6,7 +9,7 @@ import { makeToolInvokerFromTools } from "@executor/codemode-core"; · 2 unmodified lines | |||
| 6 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; | 9 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; |
| 7 | 10 | ||
| 8 | import { | 11 | import { |
| 9 | createSqlControlPlaneRuntime, | 12 | createControlPlaneRuntime, |
| 10 | } from "./index"; | 13 | } from "./index"; |
| 11 | import { withControlPlaneClient } from "./test-http-client"; | 14 | import { withControlPlaneClient } from "./test-http-client"; |
| 12 | 15 | ||
| @@ -37,8 +40,9 @@ const makeExecutionResolver = () => { · 24 unmodified lines | |||
| 37 | }; | 40 | }; |
| 38 | 41 | ||
| 39 | const makeRuntime = Effect.acquireRelease( | 42 | const makeRuntime = Effect.acquireRelease( |
| 40 | createSqlControlPlaneRuntime({ | 43 | createControlPlaneRuntime({ |
| 41 | localDataDir: ":memory:", | 44 | localDataDir: ":memory:", |
| 45 | workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")), | ||
| 42 | executionResolver: makeExecutionResolver(), | 46 | executionResolver: makeExecutionResolver(), |
| 43 | }), | 47 | }), |
| 44 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | 48 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| @@ -88,5 +92,6 @@ describe("execution-http", () => { · 43 unmodified lines | |||
| 88 | expect(getExecution.execution.status).toBe("completed"); | 92 | expect(getExecution.execution.status).toBe("completed"); |
| 89 | expect(getExecution.pendingInteraction).toBeNull(); | 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 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 5 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 6 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | 7 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 8 | import { mkdtempSync } from "node:fs"; | ||
| 9 | import { tmpdir } from "node:os"; | ||
| 10 | import { join } from "node:path"; | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import { assertTrue } from "@effect/vitest/utils"; | 12 | import { assertTrue } from "@effect/vitest/utils"; |
| 10 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| @@ -15,7 +18,7 @@ import { discoverMcpToolsFromConnector } from "@executor/codemode-mcp"; · 4 unmodified lines | |||
| 15 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; | 18 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; |
| 16 | 19 | ||
| 17 | import { | 20 | import { |
| 18 | createSqlControlPlaneRuntime, | 21 | createControlPlaneRuntime, |
| 19 | type ResolveExecutionEnvironment, | 22 | type ResolveExecutionEnvironment, |
| 20 | } from "./index"; | 23 | } from "./index"; |
| 21 | import { withControlPlaneClient } from "./test-http-client"; | 24 | import { withControlPlaneClient } from "./test-http-client"; |
| @@ -281,8 +284,9 @@ describe("execution-mcp-resume", () => { · 259 unmodified lines | |||
| 281 | Effect.gen(function* () { | 284 | Effect.gen(function* () { |
| 282 | const mcpServer = yield* makeFormElicitationServer; | 285 | const mcpServer = yield* makeFormElicitationServer; |
| 283 | const runtime = yield* Effect.acquireRelease( | 286 | const runtime = yield* Effect.acquireRelease( |
| 284 | createSqlControlPlaneRuntime({ | 287 | createControlPlaneRuntime({ |
| 285 | localDataDir: ":memory:", | 288 | localDataDir: ":memory:", |
| 289 | workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")), | ||
| 286 | executionResolver: makeMcpExecutionResolver(mcpServer.endpoint), | 290 | executionResolver: makeMcpExecutionResolver(mcpServer.endpoint), |
| 287 | }), | 291 | }), |
| 288 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | 292 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| @@ -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 | ControlPlaneBadRequestError, | 2 | ControlPlaneBadRequestError, |
| 3 | ControlPlaneNotFoundError, | 3 | ControlPlaneNotFoundError, |
| 4 | ControlPlaneStorageError, | 4 | ControlPlaneStorageError, |
| 5 | } from "#api"; | 5 | } from "../api/errors"; |
| 6 | import type { | 6 | import type { |
| 7 | CreateExecutionPayload, | 7 | CreateExecutionPayload, |
| 8 | ResumeExecutionPayload, | 8 | ResumeExecutionPayload, |
| @@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines | |||
| 19 | import * as Cause from "effect/Cause"; | 19 | import * as Cause from "effect/Cause"; |
| 20 | import * as Exit from "effect/Exit"; | 20 | import * as Exit from "effect/Exit"; |
| 21 | import * as Schema from "effect/Schema"; | 21 | import * as Schema from "effect/Schema"; |
| 22 | import type { RuntimeLocalWorkspaceState } from "./local-runtime-context"; | ||
| 23 | import { RuntimeLocalWorkspaceService } from "./local-runtime-context"; | ||
| 22 | 24 | ||
| 23 | /** Run an Effect as a Promise, preserving the original error (not FiberFailure). */ | 25 | /** Run an Effect as a Promise, preserving the original error (not FiberFailure). */ |
| 24 | const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => { | 26 | const runEffect = async <A>( |
| 25 | const exit = await Effect.runPromiseExit(effect); | 27 | effect: Effect.Effect<A, unknown, never>, |
| 28 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null = null, | ||
| 29 | ): Promise<A> => { | ||
| 30 | const provided = | ||
| 31 | runtimeLocalWorkspace === null | ||
| 32 | ? effect | ||
| 33 | : effect.pipe( | ||
| 34 | Effect.provideService( | ||
| 35 | RuntimeLocalWorkspaceService, | ||
| 36 | runtimeLocalWorkspace, | ||
| 37 | ), | ||
| 38 | ); | ||
| 39 | const exit = await Effect.runPromiseExit(provided); | ||
| 26 | if (Exit.isSuccess(exit)) return exit.value; | 40 | if (Exit.isSuccess(exit)) return exit.value; |
| 27 | throw Cause.squash(exit.cause); | 41 | throw Cause.squash(exit.cause); |
| 28 | }; | 42 | }; |
| @@ -106,6 +120,9 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 77 unmodified lines | |||
| 106 | return trimmed.length > 0 ? trimmed : null; | 120 | return trimmed.length > 0 ? trimmed : null; |
| 107 | }; | 121 | }; |
| 108 | 122 | ||
| 123 | const toSerializableValue = <A>(value: A): A => | ||
| 124 | JSON.parse(JSON.stringify(value)) as A; | ||
| 125 | |||
| 109 | const resolveLocalCredentialUrl = (input: { | 126 | const resolveLocalCredentialUrl = (input: { |
| 110 | baseUrl: string; | 127 | baseUrl: string; |
| 111 | workspaceId: WorkspaceId; | 128 | workspaceId: WorkspaceId; |
| @@ -205,6 +222,7 @@ export const createExecutorToolMap = (input: { · 93 unmodified lines | |||
| 205 | workspaceId: WorkspaceId; | 222 | workspaceId: WorkspaceId; |
| 206 | accountId: AccountId; | 223 | accountId: AccountId; |
| 207 | sourceAuthService: RuntimeSourceAuthService; | 224 | sourceAuthService: RuntimeSourceAuthService; |
| 225 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | ||
| 208 | }): ToolMap => ({ | 226 | }): ToolMap => ({ |
| 209 | "executor.sources.add": toTool({ | 227 | "executor.sources.add": toTool({ |
| 210 | tool: { | 228 | tool: { |
| @@ -281,10 +299,11 @@ export const createExecutorToolMap = (input: { · 70 unmodified lines | |||
| 281 | } | 299 | } |
| 282 | : undefined, | 300 | : undefined, |
| 283 | ), | 301 | ), |
| 302 | input.runtimeLocalWorkspace, | ||
| 284 | ); | 303 | ); |
| 285 | 304 | ||
| 286 | if (result.kind === "connected") { | 305 | if (result.kind === "connected") { |
| 287 | return result.source; | 306 | return toSerializableValue(result.source); |
| 288 | } | 307 | } |
| 289 | 308 | ||
| 290 | if (result.kind === "credential_required") { | 309 | if (result.kind === "credential_required") { |
| @@ -313,6 +332,7 @@ export const createExecutorToolMap = (input: { · 22 unmodified lines | |||
| 313 | invocation: context?.invocation, | 332 | invocation: context?.invocation, |
| 314 | onElicitation: context?.onElicitation, | 333 | onElicitation: context?.onElicitation, |
| 315 | }), | 334 | }), |
| 335 | input.runtimeLocalWorkspace, | ||
| 316 | ); | 336 | ); |
| 317 | 337 | ||
| 318 | pendingArgs = pendingResult.credentialSlot === "import" | 338 | pendingArgs = pendingResult.credentialSlot === "import" |
| @@ -342,10 +362,11 @@ export const createExecutorToolMap = (input: { · 23 unmodified lines | |||
| 342 | } | 362 | } |
| 343 | : undefined, | 363 | : undefined, |
| 344 | ), | 364 | ), |
| 365 | input.runtimeLocalWorkspace, | ||
| 345 | ); | 366 | ); |
| 346 | 367 | ||
| 347 | if (completed.kind === "connected") { | 368 | if (completed.kind === "connected") { |
| 348 | return completed.source; | 369 | return toSerializableValue(completed.source); |
| 349 | } | 370 | } |
| 350 | 371 | ||
| 351 | if (completed.kind === "credential_required") { | 372 | if (completed.kind === "credential_required") { |
| @@ -385,19 +406,22 @@ export const createExecutorToolMap = (input: { · 33 unmodified lines | |||
| 385 | elicitationId: result.sessionId, | 406 | elicitationId: result.sessionId, |
| 386 | }, | 407 | }, |
| 387 | }), | 408 | }), |
| 409 | input.runtimeLocalWorkspace, | ||
| 388 | ); | 410 | ); |
| 389 | 411 | ||
| 390 | if (response.action !== "accept") { | 412 | if (response.action !== "accept") { |
| 391 | throw new Error(`Source add was not completed for ${result.source.id}`); | 413 | throw new Error(`Source add was not completed for ${result.source.id}`); |
| 392 | } | 414 | } |
| 393 | 415 | ||
| 394 | return await runEffect( | 416 | const connected = await runEffect( |
| 395 | input.sourceAuthService.getSourceById({ | 417 | input.sourceAuthService.getSourceById({ |
| 396 | workspaceId: input.workspaceId, | 418 | workspaceId: input.workspaceId, |
| 397 | sourceId: result.source.id, | 419 | sourceId: result.source.id, |
| 398 | actorAccountId: input.accountId, | 420 | actorAccountId: input.accountId, |
| 399 | }), | 421 | }), |
| 422 | input.runtimeLocalWorkspace, | ||
| 400 | ); | 423 | ); |
| 424 | return toSerializableValue(connected); | ||
| 401 | }, | 425 | }, |
| 402 | }, | 426 | }, |
| 403 | metadata: { | 427 | metadata: { |
| @@ -6,13 +6,12 @@ import * as Scope from "effect/Scope"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | type ControlPlaneApiRuntimeContext, | 8 | type ControlPlaneApiRuntimeContext, |
| 9 | ControlPlaneActorResolver, | ||
| 10 | type ControlPlaneActorResolverShape, | ||
| 11 | } from "#api"; | 9 | } from "#api"; |
| 12 | import { | 10 | import { |
| 13 | SqlControlPlanePersistenceLive, | 11 | SqlControlPlanePersistenceLive, |
| 14 | SqlControlPlanePersistenceService, | 12 | SqlControlPlanePersistenceService, |
| 15 | SqlControlPlaneRowsLive, | 13 | SqlControlPlaneRowsLive, |
| 14 | SqlControlPlaneRowsService, | ||
| 16 | SqlPersistenceBootstrapError, | 15 | SqlPersistenceBootstrapError, |
| 17 | type CreateSqlRuntimeOptions, | 16 | type CreateSqlRuntimeOptions, |
| 18 | type SqlControlPlanePersistence, | 17 | type SqlControlPlanePersistence, |
| @@ -20,43 +19,48 @@ import { · 1 unmodified line | |||
| 20 | import type { LocalInstallation } from "#schema"; | 19 | import type { LocalInstallation } from "#schema"; |
| 21 | 20 | ||
| 22 | import { | 21 | import { |
| 23 | ControlPlaneAuthHeaders, | 22 | type ResolveExecutionEnvironment, |
| 24 | RuntimeActorResolverLive, | 23 | } from "./execution-state"; |
| 25 | createHeaderActorResolver, | ||
| 26 | } from "./actor-resolver"; | ||
| 27 | import { type ResolveExecutionEnvironment } from "./execution-state"; | ||
| 28 | import { | 24 | import { |
| 25 | createLiveExecutionManager, | ||
| 29 | LiveExecutionManagerLive, | 26 | LiveExecutionManagerLive, |
| 30 | LiveExecutionManagerService, | 27 | LiveExecutionManagerService, |
| 31 | } from "./live-execution"; | 28 | } from "./live-execution"; |
| 32 | import { getOrProvisionLocalInstallation } from "./local-installation"; | 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 | import { | 36 | import { |
| 34 | ControlPlaneStore, | 37 | ControlPlaneStore, |
| 35 | ControlPlaneStoreLive, | 38 | ControlPlaneStoreLive, |
| 36 | } from "./store"; | 39 | } from "./store"; |
| 37 | import { | 40 | import { |
| 41 | createRuntimeSourceAuthService, | ||
| 38 | RuntimeSourceAuthServiceLive, | 42 | RuntimeSourceAuthServiceLive, |
| 39 | RuntimeSourceAuthServiceTag, | 43 | RuntimeSourceAuthServiceTag, |
| 40 | } from "./source-auth-service"; | 44 | } from "./source-auth-service"; |
| 41 | import type { ResolveSecretMaterial } from "./secret-material-providers"; | 45 | import type { ResolveSecretMaterial } from "./secret-material-providers"; |
| 46 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | ||
| 42 | import { | 47 | import { |
| 48 | createWorkspaceExecutionEnvironmentResolver, | ||
| 43 | RuntimeExecutionResolverLive, | 49 | RuntimeExecutionResolverLive, |
| 44 | RuntimeExecutionResolverService, | 50 | RuntimeExecutionResolverService, |
| 45 | } from "./workspace-execution-environment"; | 51 | } from "./workspace-execution-environment"; |
| 46 | 52 | ||
| 47 | export { | ||
| 48 | ControlPlaneAuthHeaders, | ||
| 49 | createHeaderActorResolver, | ||
| 50 | }; | ||
| 51 | |||
| 52 | export * from "./execution-state"; | 53 | export * from "./execution-state"; |
| 53 | export * from "./executor-tools"; | 54 | export * from "./executor-tools"; |
| 54 | export * from "./live-execution"; | 55 | export * from "./live-execution"; |
| 56 | export * from "./local-config"; | ||
| 55 | export * from "./local-installation"; | 57 | export * from "./local-installation"; |
| 58 | export * from "./local-source-artifacts"; | ||
| 56 | export * from "./schema-type-signature"; | 59 | export * from "./schema-type-signature"; |
| 57 | export * from "./source-auth-service"; | 60 | export * from "./source-auth-service"; |
| 58 | export * from "./secret-material-providers"; | 61 | export * from "./secret-material-providers"; |
| 59 | export * from "./source-credential-interactions"; | 62 | export * from "./source-credential-interactions"; |
| 63 | export * from "./source-adapters/mcp"; | ||
| 60 | export * from "./store"; | 64 | export * from "./store"; |
| 61 | export * from "./workspace-execution-environment"; | 65 | export * from "./workspace-execution-environment"; |
| 62 | export * from "./source-inspection"; | 66 | export * from "./source-inspection"; |
| @@ -64,21 +68,21 @@ export * from "./source-discovery"; · 1 unmodified line | |||
| 64 | export * from "./execution-service"; | 68 | export * from "./execution-service"; |
| 65 | 69 | ||
| 66 | export type RuntimeControlPlaneOptions = { | 70 | export type RuntimeControlPlaneOptions = { |
| 67 | actorResolver?: ControlPlaneActorResolverShape; | ||
| 68 | executionResolver?: ResolveExecutionEnvironment; | 71 | executionResolver?: ResolveExecutionEnvironment; |
| 69 | resolveSecretMaterial?: ResolveSecretMaterial; | 72 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 70 | getLocalServerBaseUrl?: () => string | undefined; | 73 | getLocalServerBaseUrl?: () => string | undefined; |
| 74 | workspaceRoot?: string; | ||
| 71 | }; | 75 | }; |
| 72 | 76 | ||
| 73 | const detailsFromCause = (cause: unknown): string => | 77 | const detailsFromCause = (cause: unknown): string => |
| 74 | cause instanceof Error ? cause.message : String(cause); | 78 | cause instanceof Error ? cause.message : String(cause); |
| 75 | 79 | ||
| 76 | const toLocalInstallationBootstrapError = ( | 80 | const toLocalRuntimeBootstrapError = ( |
| 77 | cause: unknown, | 81 | cause: unknown, |
| 78 | ): SqlPersistenceBootstrapError => { | 82 | ): SqlPersistenceBootstrapError => { |
| 79 | const details = detailsFromCause(cause); | 83 | const details = detailsFromCause(cause); |
| 80 | return new SqlPersistenceBootstrapError({ | 84 | return new SqlPersistenceBootstrapError({ |
| 81 | message: `Failed provisioning local installation: ${details}`, | 85 | message: `Failed initializing local runtime: ${details}`, |
| 82 | details, | 86 | details, |
| 83 | }); | 87 | }); |
| 84 | }; | 88 | }; |
| @@ -93,18 +97,18 @@ export type RuntimeControlPlaneLayer = Layer.Layer< · 8 unmodified lines | |||
| 93 | >; | 97 | >; |
| 94 | 98 | ||
| 95 | const runtimeContextTags = [ | 99 | const runtimeContextTags = [ |
| 96 | ControlPlaneActorResolver, | ||
| 97 | ControlPlaneStore, | 100 | ControlPlaneStore, |
| 98 | LiveExecutionManagerService, | 101 | LiveExecutionManagerService, |
| 102 | RuntimeLocalWorkspaceService, | ||
| 99 | RuntimeSourceAuthServiceTag, | 103 | RuntimeSourceAuthServiceTag, |
| 100 | RuntimeExecutionResolverService, | 104 | RuntimeExecutionResolverService, |
| 101 | ] as const; | 105 | ] as const; |
| 102 | 106 | ||
| 103 | const createRuntimeLayerFromContext = ( | 107 | const createRuntimeLayerFromContext = ( |
| 104 | context: Context.Context< | 108 | context: Context.Context< |
| 105 | ControlPlaneActorResolver | 109 | ControlPlaneStore |
| 106 | | ControlPlaneStore | ||
| 107 | | LiveExecutionManagerService | 110 | | LiveExecutionManagerService |
| 111 | | RuntimeLocalWorkspaceService | ||
| 108 | | RuntimeSourceAuthServiceTag | 112 | | RuntimeSourceAuthServiceTag |
| 109 | | RuntimeExecutionResolverService | 113 | | RuntimeExecutionResolverService |
| 110 | >, | 114 | >, |
| @@ -112,9 +116,9 @@ const createRuntimeLayerFromContext = ( · 1 unmodified line | |||
| 112 | const runtimeContext = context.pipe( | 116 | const runtimeContext = context.pipe( |
| 113 | Context.pick(...runtimeContextTags), | 117 | Context.pick(...runtimeContextTags), |
| 114 | ) as Context.Context< | 118 | ) as Context.Context< |
| 115 | ControlPlaneActorResolver | 119 | ControlPlaneStore |
| 116 | | ControlPlaneStore | ||
| 117 | | LiveExecutionManagerService | 120 | | LiveExecutionManagerService |
| 121 | | RuntimeLocalWorkspaceService | ||
| 118 | | RuntimeSourceAuthServiceTag | 122 | | RuntimeSourceAuthServiceTag |
| 119 | | RuntimeExecutionResolverService | 123 | | RuntimeExecutionResolverService |
| 120 | >; | 124 | >; |
| @@ -140,54 +144,133 @@ export const createRuntimeControlPlaneLayer = ( · 19 unmodified lines | |||
| 140 | 144 | ||
| 141 | return Layer.mergeAll( | 145 | return Layer.mergeAll( |
| 142 | ControlPlaneStoreLive, | 146 | ControlPlaneStoreLive, |
| 143 | RuntimeActorResolverLive(options.actorResolver), | ||
| 144 | liveExecutionManagerLayer, | 147 | liveExecutionManagerLayer, |
| 145 | sourceAuthLayer, | 148 | sourceAuthLayer, |
| 146 | executionResolverLayer, | 149 | executionResolverLayer, |
| 147 | ); | 150 | ); |
| 148 | }; | 151 | }; |
| 149 | 152 | ||
| 150 | export type SqlControlPlaneRuntime = { | 153 | export type ControlPlaneRuntime = { |
| 151 | persistence: SqlControlPlanePersistence; | 154 | persistence: SqlControlPlanePersistence; |
| 152 | localInstallation: LocalInstallation; | 155 | localInstallation: LocalInstallation; |
| 153 | runtimeLayer: RuntimeControlPlaneLayer; | 156 | runtimeLayer: RuntimeControlPlaneLayer; |
| 154 | close: () => Promise<void>; | 157 | close: () => Promise<void>; |
| 155 | }; | 158 | }; |
| 156 | 159 | ||
| 157 | export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions | 160 | export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions |
| 158 | & RuntimeControlPlaneOptions; | 161 | & RuntimeControlPlaneOptions; |
| 159 | 162 | ||
| 160 | export const createSqlControlPlaneRuntime = ( | 163 | export const createControlPlaneRuntime = ( |
| 161 | options: CreateSqlControlPlaneRuntimeOptions, | 164 | options: CreateControlPlaneRuntimeOptions, |
| 162 | ): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> => | 165 | ): Effect.Effect<ControlPlaneRuntime, SqlPersistenceBootstrapError> => |
| 163 | Effect.gen(function* () { | 166 | Effect.gen(function* () { |
| 164 | const scope = yield* Scope.make(); | 167 | const scope = yield* Scope.make(); |
| 165 | const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe( | 168 | const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe( |
| 166 | Layer.provideMerge(SqlControlPlanePersistenceLive(options)), | 169 | Layer.provideMerge(SqlControlPlanePersistenceLive(options)), |
| 167 | ); | 170 | ); |
| 168 | const runtimeLayer = createRuntimeControlPlaneLayer(options).pipe( | 171 | const baseContext = yield* Layer.buildWithScope(persistenceAndRowsLayer, scope).pipe( |
| 169 | Layer.provideMerge(persistenceAndRowsLayer), | 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 | Effect.catchAll((error) => | 185 | Effect.catchAll((error) => |
| 174 | closeScope(scope).pipe( | 186 | closeScope(scope).pipe( |
| 175 | Effect.zipRight(Effect.fail(error)), | 187 | Effect.zipRight(Effect.fail(error)), |
| 176 | )), | 188 | )), |
| 177 | ); | 189 | ); |
| 178 | 190 | ||
| 179 | const persistence = Context.get(context, SqlControlPlanePersistenceService); | 191 | const localInstallation = yield* getOrProvisionLocalInstallation({ |
| 180 | const store = Context.get(context, ControlPlaneStore); | 192 | context: localWorkspaceContext, |
| 181 | const concreteRuntimeLayer = createRuntimeLayerFromContext(context); | 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( | 201 | const loadedLocalConfig = yield* loadLocalExecutorConfig(localWorkspaceContext).pipe( |
| 184 | Effect.mapError(toLocalInstallationBootstrapError), | 202 | Effect.mapError(toLocalRuntimeBootstrapError), |
| 185 | Effect.catchAll((error) => | 203 | Effect.catchAll((error) => |
| 186 | closeScope(scope).pipe( | 204 | closeScope(scope).pipe( |
| 187 | Effect.zipRight(Effect.fail(error)), | 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 | return { | 274 | return { |
| 192 | persistence, | 275 | persistence, |
| 193 | localInstallation, | 276 | localInstallation, |
| @@ -1,9 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import type { Policy } from "#schema"; | 3 | import type { LocalWorkspacePolicy } from "#schema"; |
| 4 | import { | 4 | import { |
| 5 | AccountIdSchema, | ||
| 6 | OrganizationIdSchema, | ||
| 7 | PolicyIdSchema, | 5 | PolicyIdSchema, |
| 8 | SourceIdSchema, | 6 | SourceIdSchema, |
| 9 | WorkspaceIdSchema, | 7 | WorkspaceIdSchema, |
| @@ -15,7 +13,6 @@ import { · 5 unmodified lines | |||
| 15 | } from "./invocation-policy-engine"; | 13 | } from "./invocation-policy-engine"; |
| 16 | 14 | ||
| 17 | const workspaceId = WorkspaceIdSchema.make("ws_policy_engine"); | 15 | const workspaceId = WorkspaceIdSchema.make("ws_policy_engine"); |
| 18 | const organizationId = OrganizationIdSchema.make("org_policy_engine"); | ||
| 19 | const sourceId = SourceIdSchema.make("src_policy_engine"); | 16 | const sourceId = SourceIdSchema.make("src_policy_engine"); |
| 20 | 17 | ||
| 21 | const now = 1_700_000_000_000; | 18 | const now = 1_700_000_000_000; |
| @@ -32,20 +29,14 @@ const baseDescriptor: InvocationDescriptor = { · 10 unmodified lines | |||
| 32 | }; | 29 | }; |
| 33 | 30 | ||
| 34 | const basePolicy = ( | 31 | const basePolicy = ( |
| 35 | patch: Partial<Policy> = {}, | 32 | patch: Partial<LocalWorkspacePolicy> = {}, |
| 36 | ): Policy => ({ | 33 | ): LocalWorkspacePolicy => ({ |
| 37 | id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`), | 34 | id: PolicyIdSchema.make(`pol_${Math.random().toString(36).slice(2, 8)}`), |
| 38 | scopeType: "workspace", | 35 | key: "vercel-dns", |
| 39 | organizationId, | ||
| 40 | workspaceId, | 36 | workspaceId, |
| 41 | targetAccountId: null, | ||
| 42 | clientId: null, | ||
| 43 | resourceType: "tool_path", | ||
| 44 | resourcePattern: "vercel.api.dns.createRecord", | 37 | resourcePattern: "vercel.api.dns.createRecord", |
| 45 | matchType: "exact", | ||
| 46 | effect: "allow", | 38 | effect: "allow", |
| 47 | approvalMode: "auto", | 39 | approvalMode: "auto", |
| 48 | argumentConditionsJson: null, | ||
| 49 | priority: 0, | 40 | priority: 0, |
| 50 | enabled: true, | 41 | enabled: true, |
| 51 | createdAt: now, | 42 | createdAt: now, |
| @@ -54,7 +45,7 @@ const basePolicy = ( · 2 unmodified lines | |||
| 54 | }); | 45 | }); |
| 55 | 46 | ||
| 56 | describe("invocation-policy-engine", () => { | 47 | describe("invocation-policy-engine", () => { |
| 57 | it("allows GET requests by default", () => { | 48 | it("allows read requests by default", () => { |
| 58 | const decision = evaluateInvocationPolicy({ | 49 | const decision = evaluateInvocationPolicy({ |
| 59 | descriptor: { | 50 | descriptor: { |
| 60 | ...baseDescriptor, | 51 | ...baseDescriptor, |
| @@ -67,107 +58,83 @@ describe("invocation-policy-engine", () => { · 6 unmodified lines | |||
| 67 | policies: [], | 58 | policies: [], |
| 68 | context: { | 59 | context: { |
| 69 | workspaceId, | 60 | workspaceId, |
| 70 | organizationId, | ||
| 71 | accountId: null, | ||
| 72 | clientId: null, | ||
| 73 | }, | 61 | }, |
| 74 | }); | 62 | }); |
| 75 | 63 | ||
| 76 | expect(decision.kind).toBe("allow"); | 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 | const decision = evaluateInvocationPolicy({ | 68 | const decision = evaluateInvocationPolicy({ |
| 81 | descriptor: baseDescriptor, | 69 | descriptor: baseDescriptor, |
| 82 | args: { domain: "testing.executor.sh" }, | 70 | args: { domain: "testing.executor.sh" }, |
| 83 | policies: [], | 71 | policies: [], |
| 84 | context: { | 72 | context: { |
| 85 | workspaceId, | 73 | workspaceId, |
| 86 | organizationId, | ||
| 87 | accountId: null, | ||
| 88 | clientId: null, | ||
| 89 | }, | 74 | }, |
| 90 | }); | 75 | }); |
| 91 | 76 | ||
| 92 | expect(decision.kind).toBe("require_interaction"); | 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 | const decision = evaluateInvocationPolicy({ | 81 | const decision = evaluateInvocationPolicy({ |
| 97 | descriptor: baseDescriptor, | 82 | descriptor: baseDescriptor, |
| 98 | args: { domain: "testing.executor.sh" }, | 83 | args: { domain: "testing.executor.sh" }, |
| 99 | policies: [basePolicy({ | 84 | policies: [basePolicy({ |
| 100 | id: PolicyIdSchema.make("pol_org_allow"), | 85 | id: PolicyIdSchema.make("pol_allow"), |
| 101 | scopeType: "organization", | ||
| 102 | workspaceId: null, | ||
| 103 | })], | 86 | })], |
| 104 | context: { | 87 | context: { |
| 105 | workspaceId, | 88 | workspaceId, |
| 106 | organizationId, | ||
| 107 | accountId: null, | ||
| 108 | clientId: null, | ||
| 109 | }, | 89 | }, |
| 110 | }); | 90 | }); |
| 111 | 91 | ||
| 112 | expect(decision.kind).toBe("allow"); | 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 | const decision = evaluateInvocationPolicy({ | 97 | const decision = evaluateInvocationPolicy({ |
| 118 | descriptor: baseDescriptor, | 98 | descriptor: baseDescriptor, |
| 119 | args: { domain: "testing.executor.sh" }, | 99 | args: { domain: "testing.executor.sh" }, |
| 120 | policies: [ | 100 | policies: [ |
| 121 | basePolicy({ | 101 | basePolicy({ |
| 122 | id: PolicyIdSchema.make("pol_org_allow"), | 102 | id: PolicyIdSchema.make("pol_allow_all_vercel"), |
| 123 | scopeType: "organization", | 103 | key: "allow-all-vercel", |
| 124 | workspaceId: null, | 104 | resourcePattern: "vercel.api.*", |
| 125 | approvalMode: "auto", | ||
| 126 | effect: "allow", | 105 | effect: "allow", |
| 106 | priority: 0, | ||
| 127 | }), | 107 | }), |
| 128 | basePolicy({ | 108 | basePolicy({ |
| 129 | id: PolicyIdSchema.make("pol_ws_deny"), | 109 | id: PolicyIdSchema.make("pol_deny_create_record"), |
| 110 | key: "deny-create-record", | ||
| 130 | effect: "deny", | 111 | effect: "deny", |
| 131 | priority: 10, | 112 | priority: 1, |
| 132 | }), | 113 | }), |
| 133 | ], | 114 | ], |
| 134 | context: { | 115 | context: { |
| 135 | workspaceId, | 116 | workspaceId, |
| 136 | organizationId, | ||
| 137 | accountId: null, | ||
| 138 | clientId: null, | ||
| 139 | }, | 117 | }, |
| 140 | }); | 118 | }); |
| 141 | 119 | ||
| 142 | expect(decision.kind).toBe("deny"); | 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 | const decision = evaluateInvocationPolicy({ | 125 | const decision = evaluateInvocationPolicy({ |
| 148 | descriptor: baseDescriptor, | 126 | descriptor: baseDescriptor, |
| 149 | args: { domain: "testing.executor.sh" }, | 127 | args: { domain: "testing.executor.sh" }, |
| 150 | policies: [ | 128 | policies: [basePolicy({ |
| 151 | basePolicy({ | 129 | id: PolicyIdSchema.make("pol_disabled"), |
| 152 | id: PolicyIdSchema.make("pol_ws_gate"), | 130 | enabled: false, |
| 153 | approvalMode: "required", | 131 | })], |
| 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 | ], | ||
| 162 | context: { | 132 | context: { |
| 163 | workspaceId, | 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"); | 137 | expect(decision.kind).toBe("require_interaction"); |
| 171 | expect(decision.matchedPolicyId).toBe("pol_ws_user_allow"); | 138 | expect(decision.matchedPolicyId).toBeNull(); |
| 172 | }); | 139 | }); |
| 173 | }); | 140 | }); |
| @@ -1,6 +1,5 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | Policy, | 2 | LocalWorkspacePolicy, |
| 3 | PolicyArgumentCondition, | ||
| 4 | Source, | 3 | Source, |
| 5 | } from "#schema"; | 4 | } from "#schema"; |
| 6 | 5 | ||
| @@ -23,201 +22,24 @@ export type InvocationDescriptor = { · 16 unmodified lines | |||
| 23 | }; | 22 | }; |
| 24 | 23 | ||
| 25 | export type InvocationPolicyContext = { | 24 | export type InvocationPolicyContext = { |
| 26 | workspaceId: Exclude<Policy["workspaceId"], null>; | 25 | workspaceId: LocalWorkspacePolicy["workspaceId"]; |
| 27 | organizationId: Policy["organizationId"]; | ||
| 28 | accountId?: Policy["targetAccountId"]; | ||
| 29 | clientId?: Policy["clientId"]; | ||
| 30 | }; | 26 | }; |
| 31 | 27 | ||
| 32 | export type InvocationAuthorizationDecision = { | 28 | export type InvocationAuthorizationDecision = { |
| 33 | kind: "allow" | "deny" | "require_interaction"; | 29 | kind: "allow" | "deny" | "require_interaction"; |
| 34 | reason: string; | 30 | reason: string; |
| 35 | matchedPolicyId: Policy["id"] | null; | 31 | matchedPolicyId: LocalWorkspacePolicy["id"] | null; |
| 36 | }; | 32 | }; |
| 37 | 33 | ||
| 38 | const namespaceFromToolPath = (toolPath: string): string | null => { | 34 | const matchesGlob = (pattern: string, value: string): boolean => { |
| 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 | |||
| 56 | const escaped = pattern | 35 | const escaped = pattern |
| 57 | .replace(/[.+?^${}()|[\]\\]/g, "\\$&") | 36 | .replace(/[.+?^${}()|[\]\\]/g, "\\$&") |
| 58 | .replace(/\*/g, ".*"); | 37 | .replace(/\*/g, ".*"); |
| 59 | return new RegExp(`^${escaped}$`).test(value); | 38 | return new RegExp(`^${escaped}$`).test(value); |
| 60 | }; | 39 | }; |
| 61 | 40 | ||
| 62 | const parseArgumentConditions = ( | 41 | const policySpecificity = (policy: LocalWorkspacePolicy): number => |
| 63 | argumentConditionsJson: Policy["argumentConditionsJson"], | 42 | policy.priority + Math.max(1, policy.resourcePattern.replace(/\*/g, "").length); |
| 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 | }; | ||
| 221 | 43 | ||
| 222 | const defaultDecisionForInvocation = ( | 44 | const defaultDecisionForInvocation = ( |
| 223 | descriptor: InvocationDescriptor, | 45 | descriptor: InvocationDescriptor, |
| @@ -238,7 +60,7 @@ const defaultDecisionForInvocation = ( · 14 unmodified lines | |||
| 238 | }; | 60 | }; |
| 239 | 61 | ||
| 240 | const resolvePolicyDecision = ( | 62 | const resolvePolicyDecision = ( |
| 241 | policy: Policy, | 63 | policy: LocalWorkspacePolicy, |
| 242 | ): InvocationAuthorizationDecision => { | 64 | ): InvocationAuthorizationDecision => { |
| 243 | if (policy.effect === "deny") { | 65 | if (policy.effect === "deny") { |
| 244 | return { | 66 | return { |
| @@ -266,18 +88,19 @@ const resolvePolicyDecision = ( · 21 unmodified lines | |||
| 266 | export const evaluateInvocationPolicy = (input: { | 88 | export const evaluateInvocationPolicy = (input: { |
| 267 | descriptor: InvocationDescriptor; | 89 | descriptor: InvocationDescriptor; |
| 268 | args: unknown; | 90 | args: unknown; |
| 269 | policies: ReadonlyArray<Policy>; | 91 | policies: ReadonlyArray<LocalWorkspacePolicy>; |
| 270 | context: InvocationPolicyContext; | 92 | context: InvocationPolicyContext; |
| 271 | }): InvocationAuthorizationDecision => { | 93 | }): InvocationAuthorizationDecision => { |
| 272 | const candidates = input.policies | 94 | const matchingPolicies = input.policies |
| 273 | .filter((policy) => policy.enabled) | 95 | .filter((policy) => |
| 274 | .filter((policy) => matchesScope(policy, input.context)) | 96 | policy.enabled |
| 275 | .filter((policy) => matchesResource(policy, input.descriptor)) | 97 | && policy.workspaceId === input.context.workspaceId |
| 276 | .filter((policy) => matchesArgumentConditions(policy, input.args)) | 98 | && matchesGlob(policy.resourcePattern, input.descriptor.toolPath)) |
| 277 | .sort((left, right) => | 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] | 104 | const matched = matchingPolicies[0]; |
| 281 | ? resolvePolicyDecision(candidates[0]) | 105 | return matched ? resolvePolicyDecision(matched) : defaultDecisionForInvocation(input.descriptor); |
| 282 | : 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 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { assertTrue } from "@effect/vitest/utils"; | ||
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 4 | 6 | ||
| 5 | import { createSqlControlPlaneRuntime } from "./index"; | 7 | import { createControlPlaneRuntime } from "./index"; |
| 6 | import { getOrProvisionLocalInstallation } from "./local-installation"; | 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 | const makeRuntime = Effect.acquireRelease( | 13 | const makeRuntime = Effect.acquireRelease( |
| 9 | createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | 14 | createControlPlaneRuntime({ |
| 15 | localDataDir: ":memory:", | ||
| 16 | workspaceRoot: TEST_WORKSPACE_ROOT, | ||
| 17 | }), | ||
| 10 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | 18 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 11 | ); | 19 | ); |
| 12 | 20 | ||
| 13 | describe("local-installation", () => { | 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 | Effect.gen(function* () { | 23 | Effect.gen(function* () { |
| 16 | const runtime = yield* makeRuntime; | 24 | const runtime = yield* makeRuntime; |
| 17 | const installation = runtime.localInstallation; | 25 | const installation = runtime.localInstallation; |
| 18 | 26 | ||
| 19 | const account = yield* runtime.persistence.rows.accounts.getById(installation.accountId); | 27 | expect(installation.accountId).toBe("acc_local_default"); |
| 20 | const organization = yield* runtime.persistence.rows.organizations.getById( | 28 | expect(installation.workspaceId.startsWith("ws_local_")).toBe(true); |
| 21 | installation.organizationId, | 29 | expect(existsSync(join(TEST_WORKSPACE_ROOT, ".executor", "executor.jsonc"))).toBe(false); |
| 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"); | ||
| 30 | }), | 30 | }), |
| 31 | 60_000, | ||
| 31 | ); | 32 | ); |
| 32 | 33 | ||
| 33 | it.scoped("is idempotent when loading the default local installation", () => | 34 | it.scoped("is idempotent when loading the default local installation", () => |
| 34 | Effect.gen(function* () { | 35 | Effect.gen(function* () { |
| 35 | const runtime = yield* makeRuntime; | 36 | const runtime = yield* makeRuntime; |
| 37 | const context = yield* resolveLocalWorkspaceContext({ | ||
| 38 | workspaceRoot: TEST_WORKSPACE_ROOT, | ||
| 39 | }); | ||
| 36 | 40 | ||
| 37 | const first = runtime.localInstallation; | 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 | expect(second.accountId).toBe(first.accountId); | 46 | expect(second.accountId).toBe(first.accountId); |
| 42 | expect(second.organizationId).toBe(first.organizationId); | ||
| 43 | expect(second.workspaceId).toBe(first.workspaceId); | 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 | import { | 3 | import { |
| 3 | AccountIdSchema, | 4 | AccountIdSchema, |
| 4 | InstallationIdSchema, | ||
| 5 | OrganizationIdSchema, | ||
| 6 | OrganizationMemberIdSchema, | ||
| 7 | WorkspaceIdSchema, | 5 | WorkspaceIdSchema, |
| 8 | type Account, | ||
| 9 | type LocalInstallation, | 6 | type LocalInstallation, |
| 10 | type Organization, | ||
| 11 | type OrganizationMembership, | ||
| 12 | type Workspace, | ||
| 13 | } from "#schema"; | 7 | } from "#schema"; |
| 14 | import * as Effect from "effect/Effect"; | 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 => { | 12 | const LOCAL_ACCOUNT_ID = AccountIdSchema.make("acc_local_default"); |
| 20 | const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`); | ||
| 21 | 13 | ||
| 22 | return { | 14 | const stableHash = (value: string): string => |
| 23 | id, | 15 | createHash("sha256").update(value).digest("hex").slice(0, 16); |
| 24 | provider: "local", | ||
| 25 | subject: `local:${id}`, | ||
| 26 | email: null, | ||
| 27 | displayName: "Local User", | ||
| 28 | createdAt: now, | ||
| 29 | updatedAt: now, | ||
| 30 | }; | ||
| 31 | }; | ||
| 32 | 16 | ||
| 33 | const buildOrganization = ( | 17 | const normalizeSlashPath = (value: string): string => |
| 34 | accountId: Account["id"], | 18 | value.replaceAll("\\", "/"); |
| 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 | }); | ||
| 45 | 19 | ||
| 46 | const buildOwnerMembership = ( | 20 | const deriveWorkspaceId = (context: ResolvedLocalWorkspaceContext) => |
| 47 | organizationId: Organization["id"], | 21 | WorkspaceIdSchema.make( |
| 48 | accountId: Account["id"], | 22 | `ws_local_${stableHash(normalizeSlashPath(context.workspaceRoot))}`, |
| 49 | now: number, | 23 | ); |
| 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 | }); | ||
| 62 | 24 | ||
| 63 | const buildWorkspace = ( | 25 | export const deriveLocalInstallation = ( |
| 64 | organizationId: Organization["id"], | 26 | context: ResolvedLocalWorkspaceContext, |
| 65 | accountId: Account["id"], | 27 | ): LocalInstallation => ({ |
| 66 | now: number, | 28 | accountId: LOCAL_ACCOUNT_ID, |
| 67 | ): Workspace => ({ | 29 | workspaceId: deriveWorkspaceId(context), |
| 68 | id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`), | ||
| 69 | organizationId, | ||
| 70 | name: "Default", | ||
| 71 | createdByAccountId: accountId, | ||
| 72 | createdAt: now, | ||
| 73 | updatedAt: now, | ||
| 74 | }); | 30 | }); |
| 75 | 31 | ||
| 76 | export const loadLocalInstallation = ( | 32 | export const loadLocalInstallation = ( |
| 77 | rows: SqlControlPlaneRows, | 33 | context: ResolvedLocalWorkspaceContext, |
| 78 | ): Effect.Effect<LocalInstallation | null, unknown> => | 34 | ): Effect.Effect<LocalInstallation, never> => |
| 79 | rows.localInstallations.getById(LOCAL_INSTALLATION_ID).pipe( | 35 | Effect.succeed(deriveLocalInstallation(context)); |
| 80 | Effect.map((result) => (Option.isSome(result) ? result.value : null)), | 36 | |
| 81 | ); | 37 | export const getOrProvisionLocalInstallation = (input: { |
| 82 | 38 | context: ResolvedLocalWorkspaceContext; | |
| 83 | export const provisionLocalInstallation = ( | 39 | }): Effect.Effect<LocalInstallation, never> => |
| 84 | rows: SqlControlPlaneRows, | 40 | loadLocalInstallation(input.context); |
| 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 | ); | ||
| @@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines | |||
| 17 | import { | 17 | import { |
| 18 | operationErrors, | 18 | operationErrors, |
| 19 | } from "./operation-errors"; | 19 | } from "./operation-errors"; |
| 20 | import { resolveLocalWorkspaceContext } from "./local-config"; | ||
| 21 | import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context"; | ||
| 20 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; | 22 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; |
| 21 | import { | 23 | import { |
| 22 | createSourceCredentialSelectionBearerContent, | 24 | createSourceCredentialSelectionBearerContent, |
| @@ -180,29 +182,18 @@ const loadSourceCredentialInteraction = (input: { · 157 unmodified lines | |||
| 180 | 182 | ||
| 181 | export const getLocalInstallation = () => | 183 | export const getLocalInstallation = () => |
| 182 | Effect.gen(function* () { | 184 | Effect.gen(function* () { |
| 183 | const store = yield* ControlPlaneStore; | 185 | const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); |
| 184 | 186 | const context = runtimeLocalWorkspace?.context | |
| 185 | const installation = yield* loadLocalInstallation(store).pipe( | 187 | ?? (yield* resolveLocalWorkspaceContext().pipe( |
| 186 | Effect.mapError((error) => | 188 | Effect.mapError((error) => |
| 187 | error instanceof ControlPlanePersistenceError | 189 | localOps.installation.unknownStorage( |
| 188 | ? localOps.installation.storage(error) | 190 | error, |
| 189 | : localOps.installation.unknownStorage( | 191 | "Failed resolving local workspace context", |
| 190 | error, | 192 | ), |
| 191 | "Failed loading local installation", | 193 | ), |
| 192 | ), | 194 | )); |
| 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 | } | ||
| 204 | 195 | ||
| 205 | return installation; | 196 | return yield* loadLocalInstallation(context); |
| 206 | }); | 197 | }); |
| 207 | 198 | ||
| 208 | export const getSourceCredentialInteraction = (input: { | 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 | ControlPlaneBadRequestError, | 2 | ControlPlaneBadRequestError, |
| 3 | ControlPlaneNotFoundError, | 3 | ControlPlaneNotFoundError, |
| 4 | ControlPlaneStorageError, | 4 | ControlPlaneStorageError, |
| 5 | } from "#api"; | 5 | } from "../api/errors"; |
| 6 | import { ControlPlanePersistenceError } from "#persistence"; | 6 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | 8 | ||
| @@ -1,21 +1,14 @@ | |||
| 1 | import { | 1 | import { |
| 2 | ControlPlaneBadRequestError, | 2 | ControlPlaneBadRequestError, |
| 3 | ControlPlaneNotFoundError, | ||
| 4 | ControlPlaneStorageError, | 3 | ControlPlaneStorageError, |
| 5 | } from "#api"; | 4 | } from "../api/errors"; |
| 6 | import { ControlPlanePersistenceError } from "#persistence"; | 5 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | import type { Organization } from "#schema"; | ||
| 8 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 9 | import * as Option from "effect/Option"; | ||
| 10 | 7 | ||
| 11 | import { | 8 | import { |
| 12 | asOperationErrors, | 9 | asOperationErrors, |
| 13 | operationErrors, | ||
| 14 | type OperationErrorsLike, | 10 | type OperationErrorsLike, |
| 15 | } from "./operation-errors"; | 11 | } from "./operation-errors"; |
| 16 | import type { ControlPlaneStoreShape } from "./store"; | ||
| 17 | import { slugify } from "./slug"; | ||
| 18 | |||
| 19 | export type Mutable<T> = { -readonly [K in keyof T]: T[K] }; | 12 | export type Mutable<T> = { -readonly [K in keyof T]: T[K] }; |
| 20 | 13 | ||
| 21 | const isUniqueViolation = (error: ControlPlanePersistenceError): boolean => | 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 | import type { | 3 | import type { |
| 2 | CreatePolicyPayload, | 4 | CreatePolicyPayload, |
| 3 | UpdatePolicyPayload, | 5 | UpdatePolicyPayload, |
| 4 | } from "../api/policies/api"; | 6 | } from "../api/policies/api"; |
| 5 | import { | 7 | import { |
| 6 | PolicyIdSchema, | 8 | PolicyIdSchema, |
| 7 | type OrganizationId, | 9 | type LocalExecutorConfig, |
| 8 | type Policy, | 10 | type LocalWorkspacePolicy, |
| 9 | type PolicyId, | 11 | type PolicyId, |
| 10 | type WorkspaceId, | 12 | type WorkspaceId, |
| 11 | } from "#schema"; | 13 | } from "#schema"; |
| 12 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 13 | import * as Option from "effect/Option"; | ||
| 14 | 15 | ||
| 15 | import { | 16 | import { |
| 16 | mapPersistenceError, | 17 | loadLocalExecutorConfig, |
| 17 | parseJsonString, | 18 | writeProjectLocalExecutorConfig, |
| 18 | type Mutable, | 19 | } from "./local-config"; |
| 19 | } from "./operations-shared"; | 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 | import { | 31 | import { |
| 21 | type OperationErrors, | 32 | type OperationErrors, |
| 22 | operationErrors, | 33 | operationErrors, |
| 23 | } from "./operation-errors"; | 34 | } from "./operation-errors"; |
| 24 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; | ||
| 25 | 35 | ||
| 26 | const policyOps = { | 36 | const policyOps = { |
| 27 | list: operationErrors("policies.list"), | 37 | list: operationErrors("policies.list"), |
| @@ -31,336 +41,317 @@ const policyOps = { · 3 unmodified lines | |||
| 31 | remove: operationErrors("policies.remove"), | 41 | remove: operationErrors("policies.remove"), |
| 32 | } as const; | 42 | } as const; |
| 33 | 43 | ||
| 34 | type PolicyScopeContext = { | 44 | const cloneJson = <T>(value: T): T => |
| 35 | scopeType: Policy["scopeType"]; | 45 | JSON.parse(JSON.stringify(value)) as T; |
| 36 | organizationId: Policy["organizationId"]; | ||
| 37 | workspaceId: Policy["workspaceId"]; | ||
| 38 | }; | ||
| 39 | 46 | ||
| 40 | const loadWorkspacePolicyContext = ( | 47 | const localPolicyIdForKey = (input: { |
| 41 | store: ControlPlaneStoreShape, | 48 | workspaceRoot: string; |
| 42 | operation: OperationErrors, | 49 | key: string; |
| 43 | workspaceId: WorkspaceId, | 50 | }): PolicyId => |
| 44 | ) => | 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 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 46 | const workspace = yield* operation.child("workspace").mapStorage( | 79 | const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId); |
| 47 | store.workspaces.getById(workspaceId), | 80 | const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context); |
| 48 | ); | 81 | const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); |
| 49 | if (Option.isNone(workspace)) { | 82 | |
| 50 | return yield* Effect.fail( | 83 | const policies = Object.entries(loadedConfig.config?.policies ?? {}).map(([key, policyConfig]) => |
| 51 | operation.notFound( | 84 | toLocalWorkspacePolicy({ |
| 52 | "Workspace not found", | 85 | workspaceId, |
| 53 | `workspaceId=${workspaceId}`, | 86 | workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, |
| 54 | ), | 87 | key, |
| 55 | ); | 88 | policyConfig, |
| 56 | } | 89 | state: workspaceState.policies[key], |
| 90 | })); | ||
| 57 | 91 | ||
| 58 | return { | 92 | return { |
| 59 | scopeType: "workspace", | 93 | runtimeLocalWorkspace, |
| 60 | organizationId: workspace.value.organizationId, | 94 | loadedConfig, |
| 61 | workspaceId, | 95 | workspaceState, |
| 62 | } satisfies PolicyScopeContext; | 96 | policies, |
| 97 | }; | ||
| 63 | }); | 98 | }); |
| 64 | 99 | ||
| 65 | const loadOrganizationPolicyContext = ( | 100 | const writeLocalPolicyFiles = (input: { |
| 66 | store: ControlPlaneStoreShape, | 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 | operation: OperationErrors, | 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 | Effect.gen(function* () { | 138 | Effect.gen(function* () { |
| 71 | const organization = yield* operation.child("organization").mapStorage( | 139 | yield* loadWorkspacePolicyContext(policyOps.list, workspaceId); |
| 72 | store.organizations.getById(organizationId), | 140 | const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(workspaceId).pipe( |
| 73 | ); | 141 | Effect.mapError((cause) => |
| 74 | if (Option.isNone(organization)) { | 142 | policyOps.list.unknownStorage( |
| 75 | return yield* Effect.fail( | 143 | cause, |
| 76 | operation.notFound( | 144 | "Failed loading local workspace policies", |
| 77 | "Organization not found", | ||
| 78 | `organizationId=${organizationId}`, | ||
| 79 | ), | 145 | ), |
| 80 | ); | 146 | ), |
| 81 | } | 147 | ); |
| 82 | 148 | return localWorkspace.policies; | |
| 83 | return { | ||
| 84 | scopeType: "organization", | ||
| 85 | organizationId, | ||
| 86 | workspaceId: null, | ||
| 87 | } satisfies PolicyScopeContext; | ||
| 88 | }); | 149 | }); |
| 89 | 150 | ||
| 90 | const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean => | 151 | export const createPolicy = (input: { |
| 91 | policy.scopeType === scope.scopeType | 152 | workspaceId: WorkspaceId; |
| 92 | && policy.organizationId === scope.organizationId | ||
| 93 | && policy.workspaceId === scope.workspaceId; | ||
| 94 | |||
| 95 | const createScopedPolicy = (input: { | ||
| 96 | scope: PolicyScopeContext; | ||
| 97 | payload: CreatePolicyPayload; | 153 | payload: CreatePolicyPayload; |
| 98 | }) => | 154 | }) => |
| 99 | Effect.flatMap(ControlPlaneStore, (store) => | 155 | Effect.gen(function* () { |
| 100 | Effect.gen(function* () { | 156 | const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.create, input.workspaceId); |
| 101 | const now = Date.now(); | 157 | const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( |
| 102 | const operation = policyOps.create; | 158 | Effect.mapError((cause) => |
| 103 | 159 | policyOps.create.unknownStorage( | |
| 104 | const policy: Policy = { | 160 | cause, |
| 105 | id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`), | 161 | "Failed loading local workspace policies", |
| 106 | scopeType: input.scope.scopeType, | 162 | ), |
| 107 | organizationId: input.scope.organizationId, | 163 | ), |
| 108 | workspaceId: input.scope.workspaceId, | 164 | ); |
| 109 | targetAccountId: input.payload.targetAccountId ?? null, | 165 | const now = Date.now(); |
| 110 | clientId: input.payload.clientId ?? null, | 166 | const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); |
| 111 | resourceType: input.payload.resourceType ?? "tool_path", | 167 | const policies = { ...(projectConfig.policies ?? {}) }; |
| 168 | const key = derivePolicyConfigKey( | ||
| 169 | { | ||
| 112 | resourcePattern: input.payload.resourcePattern ?? "*", | 170 | resourcePattern: input.payload.resourcePattern ?? "*", |
| 113 | matchType: input.payload.matchType ?? "glob", | ||
| 114 | effect: input.payload.effect ?? "allow", | 171 | effect: input.payload.effect ?? "allow", |
| 115 | approvalMode: input.payload.approvalMode ?? "auto", | 172 | approvalMode: input.payload.approvalMode ?? "auto", |
| 116 | argumentConditionsJson: input.payload.argumentConditionsJson ?? null, | 173 | }, |
| 117 | priority: input.payload.priority ?? 0, | 174 | new Set(Object.keys(policies)), |
| 118 | enabled: input.payload.enabled ?? true, | 175 | ); |
| 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 | )); | ||
| 297 | 176 | ||
| 298 | export const getOrganizationPolicy = (input: { | 177 | policies[key] = { |
| 299 | organizationId: OrganizationId; | 178 | match: input.payload.resourcePattern ?? "*", |
| 300 | policyId: PolicyId; | 179 | action: input.payload.effect ?? "allow", |
| 301 | }) => | 180 | approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto", |
| 302 | Effect.flatMap(ControlPlaneStore, (store) => | 181 | ...(input.payload.enabled === false ? { enabled: false } : {}), |
| 303 | Effect.flatMap( | 182 | ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}), |
| 304 | loadOrganizationPolicyContext(store, policyOps.get, input.organizationId), | 183 | }; |
| 305 | (scope) => getScopedPolicy({ scope, policyId: input.policyId }), | 184 | |
| 306 | )); | 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 | export const getPolicy = (input: { | 220 | export const getPolicy = (input: { |
| 309 | workspaceId: WorkspaceId; | 221 | workspaceId: WorkspaceId; |
| 310 | policyId: PolicyId; | 222 | policyId: PolicyId; |
| 311 | }) => | 223 | }) => |
| 312 | Effect.flatMap(ControlPlaneStore, (store) => | 224 | Effect.gen(function* () { |
| 313 | Effect.flatMap( | 225 | yield* loadWorkspacePolicyContext(policyOps.get, input.workspaceId); |
| 314 | loadWorkspacePolicyContext(store, policyOps.get, input.workspaceId), | 226 | const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( |
| 315 | (scope) => getScopedPolicy({ scope, policyId: input.policyId }), | 227 | Effect.mapError((cause) => |
| 316 | )); | 228 | policyOps.get.unknownStorage( |
| 317 | 229 | cause, | |
| 318 | export const updateOrganizationPolicy = (input: { | 230 | "Failed loading local workspace policies", |
| 319 | organizationId: OrganizationId; | 231 | ), |
| 320 | policyId: PolicyId; | 232 | ), |
| 321 | payload: UpdatePolicyPayload; | 233 | ); |
| 322 | }) => | 234 | const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null; |
| 323 | Effect.flatMap(ControlPlaneStore, (store) => | 235 | if (policy === null) { |
| 324 | Effect.flatMap( | 236 | return yield* Effect.fail( |
| 325 | loadOrganizationPolicyContext(store, policyOps.update, input.organizationId), | 237 | policyOps.get.notFound( |
| 326 | (scope) => updateScopedPolicy({ | 238 | "Policy not found", |
| 327 | scope, | 239 | `workspaceId=${input.workspaceId} policyId=${input.policyId}`, |
| 328 | policyId: input.policyId, | 240 | ), |
| 329 | payload: input.payload, | 241 | ); |
| 330 | }), | 242 | } |
| 331 | )); | 243 | return policy; |
| 244 | }); | ||
| 332 | 245 | ||
| 333 | export const updatePolicy = (input: { | 246 | export const updatePolicy = (input: { |
| 334 | workspaceId: WorkspaceId; | 247 | workspaceId: WorkspaceId; |
| 335 | policyId: PolicyId; | 248 | policyId: PolicyId; |
| 336 | payload: UpdatePolicyPayload; | 249 | payload: UpdatePolicyPayload; |
| 337 | }) => | 250 | }) => |
| 338 | Effect.flatMap(ControlPlaneStore, (store) => | 251 | Effect.gen(function* () { |
| 339 | Effect.flatMap( | 252 | const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.update, input.workspaceId); |
| 340 | loadWorkspacePolicyContext(store, policyOps.update, input.workspaceId), | 253 | const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( |
| 341 | (scope) => updateScopedPolicy({ | 254 | Effect.mapError((cause) => |
| 342 | scope, | 255 | policyOps.update.unknownStorage( |
| 343 | policyId: input.policyId, | 256 | cause, |
| 344 | payload: input.payload, | 257 | "Failed loading local workspace policies", |
| 345 | }), | 258 | ), |
| 346 | )); | 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: { | 271 | const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); |
| 349 | organizationId: OrganizationId; | 272 | const policies = { ...(projectConfig.policies ?? {}) }; |
| 350 | policyId: PolicyId; | 273 | const existingConfig = policies[existing.key]!; |
| 351 | }) => | 274 | |
| 352 | Effect.flatMap(ControlPlaneStore, (store) => | 275 | policies[existing.key] = { |
| 353 | Effect.flatMap( | 276 | ...existingConfig, |
| 354 | loadOrganizationPolicyContext(store, policyOps.remove, input.organizationId), | 277 | ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}), |
| 355 | (scope) => removeScopedPolicy({ scope, policyId: input.policyId }), | 278 | ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}), |
| 356 | )); | 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 | export const removePolicy = (input: { | 319 | export const removePolicy = (input: { |
| 359 | workspaceId: WorkspaceId; | 320 | workspaceId: WorkspaceId; |
| 360 | policyId: PolicyId; | 321 | policyId: PolicyId; |
| 361 | }) => | 322 | }) => |
| 362 | Effect.flatMap(ControlPlaneStore, (store) => | 323 | Effect.gen(function* () { |
| 363 | Effect.flatMap( | 324 | const runtimeLocalWorkspace = yield* loadWorkspacePolicyContext(policyOps.remove, input.workspaceId); |
| 364 | loadWorkspacePolicyContext(store, policyOps.remove, input.workspaceId), | 325 | const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( |
| 365 | (scope) => removeScopedPolicy({ scope, policyId: input.policyId }), | 326 | Effect.mapError((cause) => |
| 366 | )); | 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 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { spawn } from "node:child_process"; | 2 | import { spawn } from "node:child_process"; |
| 3 | import { promises as fs } from "node:fs"; | ||
| 4 | import { lstatSync, realpathSync } from "node:fs"; | ||
| 5 | import { isAbsolute } from "node:path"; | ||
| 3 | 6 | ||
| 4 | import type { SqlControlPlaneRows } from "#persistence"; | 7 | import type { SqlControlPlaneRows } from "#persistence"; |
| 5 | import { | 8 | import { |
| 9 | type LocalConfigSecretProvider, | ||
| 10 | type LocalExecutorConfig, | ||
| 6 | type SecretMaterialPurpose, | 11 | type SecretMaterialPurpose, |
| 7 | SecretMaterialIdSchema, | 12 | SecretMaterialIdSchema, |
| 8 | type SecretRef, | 13 | type SecretRef, |
| @@ -10,6 +15,9 @@ import { · 1 unmodified line | |||
| 10 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 11 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 12 | 17 | ||
| 18 | import { resolveConfigRelativePath } from "./local-config"; | ||
| 19 | import { fromConfigSecretProviderId } from "./local-config-secrets"; | ||
| 20 | |||
| 13 | export const ENV_SECRET_PROVIDER_ID = "env"; | 21 | export const ENV_SECRET_PROVIDER_ID = "env"; |
| 14 | export const PARAMS_SECRET_PROVIDER_ID = "params"; | 22 | export const PARAMS_SECRET_PROVIDER_ID = "params"; |
| 15 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; | 23 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; |
| @@ -43,6 +51,8 @@ type SecretMaterialProviderRuntime = { · 27 unmodified lines | |||
| 43 | env: NodeJS.ProcessEnv; | 51 | env: NodeJS.ProcessEnv; |
| 44 | dangerouslyAllowEnvSecrets: boolean; | 52 | dangerouslyAllowEnvSecrets: boolean; |
| 45 | keychainServiceName: string; | 53 | keychainServiceName: string; |
| 54 | localConfig: LocalExecutorConfig | null; | ||
| 55 | workspaceRoot: string | null; | ||
| 46 | }; | 56 | }; |
| 47 | 57 | ||
| 48 | type SecretMaterialProvider = { | 58 | type SecretMaterialProvider = { |
| @@ -125,6 +135,7 @@ const runCommand = (input: { · 76 unmodified lines | |||
| 125 | command: string; | 135 | command: string; |
| 126 | args: ReadonlyArray<string>; | 136 | args: ReadonlyArray<string>; |
| 127 | stdin?: string; | 137 | stdin?: string; |
| 138 | env?: NodeJS.ProcessEnv; | ||
| 128 | operation: string; | 139 | operation: string; |
| 129 | }): Effect.Effect<SpawnResult, Error, never> => | 140 | }): Effect.Effect<SpawnResult, Error, never> => |
| 130 | Effect.tryPromise({ | 141 | Effect.tryPromise({ |
| @@ -132,6 +143,7 @@ const runCommand = (input: { · 1 unmodified line | |||
| 132 | new Promise<SpawnResult>((resolve, reject) => { | 143 | new Promise<SpawnResult>((resolve, reject) => { |
| 133 | const child = spawn(input.command, [...input.args], { | 144 | const child = spawn(input.command, [...input.args], { |
| 134 | stdio: "pipe", | 145 | stdio: "pipe", |
| 146 | env: input.env, | ||
| 135 | }); | 147 | }); |
| 136 | 148 | ||
| 137 | let stdout = ""; | 149 | let stdout = ""; |
| @@ -474,17 +486,169 @@ const createSecretMaterialProviderRuntime = (input: { · 336 unmodified lines | |||
| 474 | rows: SqlControlPlaneRows; | 486 | rows: SqlControlPlaneRows; |
| 475 | dangerouslyAllowEnvSecrets?: boolean; | 487 | dangerouslyAllowEnvSecrets?: boolean; |
| 476 | keychainServiceName?: string; | 488 | keychainServiceName?: string; |
| 489 | localConfig?: LocalExecutorConfig | null; | ||
| 490 | workspaceRoot?: string | null; | ||
| 477 | }): SecretMaterialProviderRuntime => ({ | 491 | }): SecretMaterialProviderRuntime => ({ |
| 478 | rows: input.rows, | 492 | rows: input.rows, |
| 479 | env: process.env, | 493 | env: process.env, |
| 480 | dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets), | 494 | dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets), |
| 481 | keychainServiceName: resolveKeychainServiceName(input.keychainServiceName), | 495 | keychainServiceName: resolveKeychainServiceName(input.keychainServiceName), |
| 496 | localConfig: input.localConfig ?? null, | ||
| 497 | workspaceRoot: input.workspaceRoot ?? null, | ||
| 482 | }); | 498 | }); |
| 483 | 499 | ||
| 500 | const isRegularFilePath = (path: string, allowSymlink: boolean): boolean => { | ||
| 501 | const stat = lstatSync(path); | ||
| 502 | if (stat.isSymbolicLink()) { | ||
| 503 | if (!allowSymlink) { | ||
| 504 | return false; | ||
| 505 | } | ||
| 506 | const targetStat = lstatSync(realpathSync(path)); | ||
| 507 | return targetStat.isFile(); | ||
| 508 | } | ||
| 509 | return stat.isFile(); | ||
| 510 | }; | ||
| 511 | |||
| 512 | const ensureTrustedDir = (path: string, trustedDirs: readonly string[] | undefined): boolean => { | ||
| 513 | if (!trustedDirs || trustedDirs.length === 0) { | ||
| 514 | return true; | ||
| 515 | } | ||
| 516 | |||
| 517 | const real = realpathSync(path); | ||
| 518 | return trustedDirs.some((dir) => { | ||
| 519 | const trusted = realpathSync(dir); | ||
| 520 | return real === trusted || real.startsWith(`${trusted}/`); | ||
| 521 | }); | ||
| 522 | }; | ||
| 523 | |||
| 524 | const readFileSecretValue = (input: { | ||
| 525 | provider: Extract<LocalConfigSecretProvider, { source: "file" }>; | ||
| 526 | ref: SecretRef; | ||
| 527 | workspaceRoot: string; | ||
| 528 | }): Effect.Effect<string, Error, never> => | ||
| 529 | Effect.tryPromise({ | ||
| 530 | try: async () => { | ||
| 531 | const resolvedPath = resolveConfigRelativePath({ | ||
| 532 | path: input.provider.path, | ||
| 533 | workspaceRoot: input.workspaceRoot, | ||
| 534 | }); | ||
| 535 | const raw = await fs.readFile(resolvedPath, "utf8"); | ||
| 536 | const mode = input.provider.mode ?? "singleValue"; | ||
| 537 | if (mode === "singleValue") { | ||
| 538 | return raw.trim(); | ||
| 539 | } | ||
| 540 | |||
| 541 | const parsed = JSON.parse(raw) as unknown; | ||
| 542 | if (input.ref.handle.startsWith("/")) { | ||
| 543 | const segments = input.ref.handle | ||
| 544 | .split("/") | ||
| 545 | .slice(1) | ||
| 546 | .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); | ||
| 547 | let current: unknown = parsed; | ||
| 548 | for (const segment of segments) { | ||
| 549 | if (typeof current !== "object" || current === null || !(segment in current)) { | ||
| 550 | throw new Error(`Secret path not found in ${resolvedPath}: ${input.ref.handle}`); | ||
| 551 | } | ||
| 552 | current = (current as Record<string, unknown>)[segment]; | ||
| 553 | } | ||
| 554 | if (typeof current !== "string" || current.trim().length === 0) { | ||
| 555 | throw new Error(`Secret path did not resolve to a string: ${input.ref.handle}`); | ||
| 556 | } | ||
| 557 | return current; | ||
| 558 | } | ||
| 559 | |||
| 560 | if (typeof parsed !== "object" || parsed === null) { | ||
| 561 | throw new Error(`JSON secret provider must resolve to an object: ${resolvedPath}`); | ||
| 562 | } | ||
| 563 | const value = (parsed as Record<string, unknown>)[input.ref.handle]; | ||
| 564 | if (typeof value !== "string" || value.trim().length === 0) { | ||
| 565 | throw new Error(`Secret key not found in ${resolvedPath}: ${input.ref.handle}`); | ||
| 566 | } | ||
| 567 | return value; | ||
| 568 | }, | ||
| 569 | catch: (cause) => | ||
| 570 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 571 | }); | ||
| 572 | |||
| 573 | const resolveConfiguredSecretProvider = (input: { | ||
| 574 | ref: SecretRef; | ||
| 575 | runtime: SecretMaterialProviderRuntime; | ||
| 576 | }): Effect.Effect<string, Error, never> => { | ||
| 577 | const providerAlias = fromConfigSecretProviderId(input.ref.providerId); | ||
| 578 | if (providerAlias === null) { | ||
| 579 | return Effect.fail(new Error(`Unsupported secret provider: ${input.ref.providerId}`)); | ||
| 580 | } | ||
| 581 | |||
| 582 | const provider = input.runtime.localConfig?.secrets?.providers?.[providerAlias]; | ||
| 583 | if (!provider) { | ||
| 584 | return Effect.fail( | ||
| 585 | new Error(`Config secret provider "${providerAlias}" is not configured`), | ||
| 586 | ); | ||
| 587 | } | ||
| 588 | if (input.runtime.workspaceRoot === null) { | ||
| 589 | return Effect.fail( | ||
| 590 | new Error(`Config secret provider "${providerAlias}" requires a workspace root`), | ||
| 591 | ); | ||
| 592 | } | ||
| 593 | |||
| 594 | if (provider.source === "env") { | ||
| 595 | const value = ensureNonEmptyString(input.runtime.env[input.ref.handle]); | ||
| 596 | return value === null | ||
| 597 | ? Effect.fail(new Error(`Environment variable ${input.ref.handle} is not set`)) | ||
| 598 | : Effect.succeed(value); | ||
| 599 | } | ||
| 600 | |||
| 601 | if (provider.source === "file") { | ||
| 602 | return readFileSecretValue({ | ||
| 603 | provider, | ||
| 604 | ref: input.ref, | ||
| 605 | workspaceRoot: input.runtime.workspaceRoot, | ||
| 606 | }); | ||
| 607 | } | ||
| 608 | |||
| 609 | const command = provider.command.trim(); | ||
| 610 | if (!isAbsolute(command)) { | ||
| 611 | return Effect.fail( | ||
| 612 | new Error(`Exec secret provider command must be absolute: ${command}`), | ||
| 613 | ); | ||
| 614 | } | ||
| 615 | if (!isRegularFilePath(command, provider.allowSymlinkCommand ?? false)) { | ||
| 616 | return Effect.fail( | ||
| 617 | new Error(`Exec secret provider command is not an allowed regular file: ${command}`), | ||
| 618 | ); | ||
| 619 | } | ||
| 620 | if (!ensureTrustedDir(command, provider.trustedDirs)) { | ||
| 621 | return Effect.fail( | ||
| 622 | new Error(`Exec secret provider command is outside trustedDirs: ${command}`), | ||
| 623 | ); | ||
| 624 | } | ||
| 625 | |||
| 626 | return runCommand({ | ||
| 627 | command, | ||
| 628 | args: [...(provider.args ?? []), input.ref.handle], | ||
| 629 | env: { | ||
| 630 | ...input.runtime.env, | ||
| 631 | ...(provider.env ?? {}), | ||
| 632 | }, | ||
| 633 | operation: `config-secret.get:${providerAlias}`, | ||
| 634 | }).pipe( | ||
| 635 | Effect.flatMap((result) => | ||
| 636 | ensureCommandSuccess({ | ||
| 637 | result, | ||
| 638 | operation: `config-secret.get:${providerAlias}`, | ||
| 639 | message: "Failed resolving configured exec secret", | ||
| 640 | }), | ||
| 641 | ), | ||
| 642 | Effect.map((result) => result.stdout.trimEnd()), | ||
| 643 | ); | ||
| 644 | }; | ||
| 645 | |||
| 484 | export const createDefaultSecretMaterialResolver = (input: { | 646 | export const createDefaultSecretMaterialResolver = (input: { |
| 485 | rows: SqlControlPlaneRows; | 647 | rows: SqlControlPlaneRows; |
| 486 | dangerouslyAllowEnvSecrets?: boolean; | 648 | dangerouslyAllowEnvSecrets?: boolean; |
| 487 | keychainServiceName?: string; | 649 | keychainServiceName?: string; |
| 650 | localConfig?: LocalExecutorConfig | null; | ||
| 651 | workspaceRoot?: string | null; | ||
| 488 | }): ResolveSecretMaterial => { | 652 | }): ResolveSecretMaterial => { |
| 489 | const providers = createSecretMaterialProviderRegistry(); | 653 | const providers = createSecretMaterialProviderRegistry(); |
| 490 | const runtime = createSecretMaterialProviderRuntime(input); | 654 | const runtime = createSecretMaterialProviderRuntime(input); |
| @@ -494,7 +658,20 @@ export const createDefaultSecretMaterialResolver = (input: { · 3 unmodified lines | |||
| 494 | const provider = yield* getSecretMaterialProvider({ | 658 | const provider = yield* getSecretMaterialProvider({ |
| 495 | providers, | 659 | providers, |
| 496 | providerId: ref.providerId, | 660 | providerId: ref.providerId, |
| 497 | }); | 661 | }).pipe( |
| 662 | Effect.catchAll(() => | ||
| 663 | fromConfigSecretProviderId(ref.providerId) !== null | ||
| 664 | ? Effect.succeed(null) | ||
| 665 | : Effect.fail(new Error(`Unsupported secret provider: ${ref.providerId}`)), | ||
| 666 | ), | ||
| 667 | ); | ||
| 668 | |||
| 669 | if (provider === null) { | ||
| 670 | return yield* resolveConfiguredSecretProvider({ | ||
| 671 | ref, | ||
| 672 | runtime, | ||
| 673 | }); | ||
| 674 | } | ||
| 498 | 675 | ||
| 499 | return yield* provider.resolve({ | 676 | return yield* provider.resolve({ |
| 500 | ref, | 677 | ref, |
| @@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines | |||
| 187 | }); | 187 | }); |
| 188 | }); | 188 | }); |
| 189 | 189 | ||
| 190 | const materializationFromMcpManifestEntries = (input: { | 190 | export const materializationFromMcpManifestEntries = (input: { |
| 191 | recipeRevisionId: SourceRecipeRevisionId; | 191 | recipeRevisionId: SourceRecipeRevisionId; |
| 192 | endpoint: string; | 192 | endpoint: string; |
| 193 | manifestEntries: readonly McpToolManifestEntry[]; | 193 | manifestEntries: readonly McpToolManifestEntry[]; |
| @@ -1,4 +1,7 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 1 | import { createServer } from "node:http"; | 2 | import { createServer } from "node:http"; |
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 2 | 5 | ||
| 3 | import { describe, expect, it } from "vitest"; | 6 | import { describe, expect, it } from "vitest"; |
| 4 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| @@ -26,6 +29,17 @@ import { · 21 unmodified lines | |||
| 26 | createTerminalSourceAuthSessionPatch, | 29 | createTerminalSourceAuthSessionPatch, |
| 27 | } from "./source-auth-service"; | 30 | } from "./source-auth-service"; |
| 28 | import { createLiveExecutionManager } from "./live-execution"; | 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 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | 43 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; |
| 30 | import { resolveSourceAuthMaterial } from "./source-auth-material"; | 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 | describe("source-auth-service", () => { | 203 | describe("source-auth-service", () => { |
| 167 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | 204 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 168 | 205 | ||
| @@ -225,36 +262,20 @@ describe("source-auth-service", () => { · 56 unmodified lines | |||
| 225 | 262 | ||
| 226 | const persistence = await makePersistence(); | 263 | const persistence = await makePersistence(); |
| 227 | try { | 264 | try { |
| 228 | const now = Date.now(); | ||
| 229 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth"); | 265 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth"); |
| 230 | const accountId = AccountIdSchema.make("acc_google_oauth"); | 266 | const accountId = AccountIdSchema.make("acc_google_oauth"); |
| 231 | 267 | const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState( | |
| 232 | await Effect.runPromise( | 268 | workspaceId, |
| 233 | persistence.rows.organizations.insert({ | 269 | accountId, |
| 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 | }), | ||
| 252 | ); | 270 | ); |
| 253 | 271 | ||
| 254 | const service = createRuntimeSourceAuthService({ | 272 | const service = createRuntimeSourceAuthService({ |
| 255 | rows: persistence.rows, | 273 | rows: persistence.rows, |
| 256 | liveExecutionManager: createLiveExecutionManager(), | 274 | liveExecutionManager: createLiveExecutionManager(), |
| 257 | getLocalServerBaseUrl: () => "http://localhost:8788", | 275 | getLocalServerBaseUrl: () => "http://localhost:8788", |
| 276 | localConfig: runtimeLocalWorkspace.loadedConfig.config, | ||
| 277 | workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot, | ||
| 278 | localWorkspaceState: runtimeLocalWorkspace, | ||
| 258 | }); | 279 | }); |
| 259 | 280 | ||
| 260 | await withGoogleAuthTestServer({ | 281 | await withGoogleAuthTestServer({ |
| @@ -290,7 +311,12 @@ describe("source-auth-service", () => { · 29 unmodified lines | |||
| 290 | clientId: "google-test-client", | 311 | clientId: "google-test-client", |
| 291 | clientSecret: "google-test-secret", | 312 | clientSecret: "google-test-secret", |
| 292 | }, | 313 | }, |
| 293 | }), | 314 | }).pipe( |
| 315 | Effect.provideService( | ||
| 316 | RuntimeLocalWorkspaceService, | ||
| 317 | runtimeLocalWorkspace, | ||
| 318 | ), | ||
| 319 | ), | ||
| 294 | ); | 320 | ); |
| 295 | 321 | ||
| 296 | expect(addResult.kind).toBe("oauth_required"); | 322 | expect(addResult.kind).toBe("oauth_required"); |
| @@ -461,22 +487,14 @@ describe("source-auth-service", () => { · 164 unmodified lines | |||
| 461 | expect(Option.isNone(oldSecret)).toBe(true); | 487 | expect(Option.isNone(oldSecret)).toBe(true); |
| 462 | } | 488 | } |
| 463 | 489 | ||
| 464 | const recipeOperations = await Effect.runPromise( | 490 | const localArtifact = await Effect.runPromise(readLocalSourceArtifact({ |
| 465 | persistence.rows.sources.getByWorkspaceAndId( | 491 | context: runtimeLocalWorkspace.context, |
| 466 | workspaceId, | 492 | sourceId: connectedSource.id, |
| 467 | connectedSource.id, | 493 | })); |
| 468 | ), | 494 | expect(localArtifact).not.toBeNull(); |
| 469 | ); | 495 | expect(localArtifact?.operations.length).toBeGreaterThan(0); |
| 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); | ||
| 478 | expect( | 496 | expect( |
| 479 | materializedOperations.some( | 497 | localArtifact?.operations.some( |
| 480 | (operation) => operation.toolId === "spreadsheets.values.get", | 498 | (operation) => operation.toolId === "spreadsheets.values.get", |
| 481 | ), | 499 | ), |
| 482 | ).toBe(true); | 500 | ).toBe(true); |
| @@ -495,36 +513,20 @@ describe("source-auth-service", () => { · 12 unmodified lines | |||
| 495 | 513 | ||
| 496 | const persistence = await makePersistence(); | 514 | const persistence = await makePersistence(); |
| 497 | try { | 515 | try { |
| 498 | const now = Date.now(); | ||
| 499 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web"); | 516 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web"); |
| 500 | const accountId = AccountIdSchema.make("acc_google_oauth_web"); | 517 | const accountId = AccountIdSchema.make("acc_google_oauth_web"); |
| 501 | 518 | const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState( | |
| 502 | await Effect.runPromise( | 519 | workspaceId, |
| 503 | persistence.rows.organizations.insert({ | 520 | accountId, |
| 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 | }), | ||
| 522 | ); | 521 | ); |
| 523 | 522 | ||
| 524 | const service = createRuntimeSourceAuthService({ | 523 | const service = createRuntimeSourceAuthService({ |
| 525 | rows: persistence.rows, | 524 | rows: persistence.rows, |
| 526 | liveExecutionManager: createLiveExecutionManager(), | 525 | liveExecutionManager: createLiveExecutionManager(), |
| 527 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", | 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 | await withGoogleAuthTestServer({ | 532 | await withGoogleAuthTestServer({ |
| @@ -565,6 +567,11 @@ describe("source-auth-service", () => { · 34 unmodified lines | |||
| 565 | { | 567 | { |
| 566 | baseUrl: "https://app.executor.dev", | 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 | AccountId, | 11 | AccountId, |
| 12 | type CredentialSlot, | 12 | type CredentialSlot, |
| 13 | ExecutionIdSchema, | 13 | ExecutionIdSchema, |
| 14 | type LocalExecutorConfig, | ||
| 14 | McpSourceAuthSessionDataJsonSchema, | 15 | McpSourceAuthSessionDataJsonSchema, |
| 15 | type McpSourceAuthSessionData, | 16 | type McpSourceAuthSessionData, |
| 16 | OAuth2PkceSourceAuthSessionDataJsonSchema, | 17 | OAuth2PkceSourceAuthSessionDataJsonSchema, |
| @@ -43,6 +44,10 @@ import { · 26 unmodified lines | |||
| 43 | LiveExecutionManagerService, | 44 | LiveExecutionManagerService, |
| 44 | type LiveExecutionManager, | 45 | type LiveExecutionManager, |
| 45 | } from "./live-execution"; | 46 | } from "./live-execution"; |
| 47 | import { | ||
| 48 | RuntimeLocalWorkspaceService, | ||
| 49 | type RuntimeLocalWorkspaceState, | ||
| 50 | } from "./local-runtime-context"; | ||
| 46 | import { | 51 | import { |
| 47 | exchangeMcpOAuthAuthorizationCode, | 52 | exchangeMcpOAuthAuthorizationCode, |
| 48 | startMcpOAuthAuthorization, | 53 | startMcpOAuthAuthorization, |
| @@ -1634,13 +1639,35 @@ export const createRuntimeSourceAuthService = (input: { · 1585 unmodified lines | |||
| 1634 | rows: SqlControlPlaneRows; | 1639 | rows: SqlControlPlaneRows; |
| 1635 | liveExecutionManager: LiveExecutionManager; | 1640 | liveExecutionManager: LiveExecutionManager; |
| 1636 | getLocalServerBaseUrl?: () => string | undefined; | 1641 | getLocalServerBaseUrl?: () => string | undefined; |
| 1642 | localConfig?: LocalExecutorConfig | null; | ||
| 1643 | workspaceRoot?: string | null; | ||
| 1644 | localWorkspaceState?: RuntimeLocalWorkspaceState; | ||
| 1637 | }) => { | 1645 | }) => { |
| 1638 | const resolveSecretMaterial = createDefaultSecretMaterialResolver({ | 1646 | const resolveSecretMaterial = createDefaultSecretMaterialResolver({ |
| 1639 | rows: input.rows, | 1647 | rows: input.rows, |
| 1648 | localConfig: input.localConfig, | ||
| 1649 | workspaceRoot: input.workspaceRoot, | ||
| 1640 | }); | 1650 | }); |
| 1641 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ | 1651 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ |
| 1642 | rows: input.rows, | 1652 | rows: input.rows, |
| 1643 | }); | 1653 | }); |
| 1654 | const mirrorLocalSourceResult = ( | ||
| 1655 | result: ExecutorSourceAddResult, | ||
| 1656 | ): Effect.Effect<ExecutorSourceAddResult, Error, never> => | ||
| 1657 | Effect.succeed(result); | ||
| 1658 | const mirrorLocalMcpSourceResult = ( | ||
| 1659 | result: McpSourceConnectResult, | ||
| 1660 | ): Effect.Effect<McpSourceConnectResult, Error, never> => | ||
| 1661 | Effect.succeed(result); | ||
| 1662 | const provideLocalWorkspace = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 1663 | input.localWorkspaceState | ||
| 1664 | ? effect.pipe( | ||
| 1665 | Effect.provideService( | ||
| 1666 | RuntimeLocalWorkspaceService, | ||
| 1667 | input.localWorkspaceState, | ||
| 1668 | ), | ||
| 1669 | ) | ||
| 1670 | : effect; | ||
| 1644 | 1671 | ||
| 1645 | return { | 1672 | return { |
| 1646 | getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, | 1673 | getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, |
| @@ -1652,68 +1679,78 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines | |||
| 1652 | }), | 1679 | }), |
| 1653 | 1680 | ||
| 1654 | getSourceById: ({ workspaceId, sourceId, actorAccountId }) => | 1681 | getSourceById: ({ workspaceId, sourceId, actorAccountId }) => |
| 1655 | loadSourceById(input.rows, { | 1682 | provideLocalWorkspace( |
| 1656 | workspaceId, | 1683 | loadSourceById(input.rows, { |
| 1657 | sourceId, | 1684 | workspaceId, |
| 1658 | actorAccountId, | 1685 | sourceId, |
| 1659 | }), | 1686 | actorAccountId, |
| 1687 | }), | ||
| 1688 | ), | ||
| 1660 | 1689 | ||
| 1661 | addExecutorSource: (sourceInput, options = undefined) => | 1690 | addExecutorSource: (sourceInput, options = undefined) => |
| 1662 | sourceInput.kind === "google_discovery" | 1691 | provideLocalWorkspace( |
| 1663 | ? addExecutorGoogleDiscoverySource({ | 1692 | (sourceInput.kind === "google_discovery" |
| 1664 | rows: input.rows, | 1693 | ? addExecutorGoogleDiscoverySource({ |
| 1665 | sourceInput, | 1694 | rows: input.rows, |
| 1666 | storeSecretMaterial, | 1695 | sourceInput, |
| 1667 | resolveSecretMaterial, | 1696 | storeSecretMaterial, |
| 1668 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 1697 | resolveSecretMaterial, |
| 1669 | baseUrl: options?.baseUrl, | 1698 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 1670 | }) | 1699 | baseUrl: options?.baseUrl, |
| 1671 | : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | 1700 | }) |
| 1672 | ? addExecutorHttpSource({ | 1701 | : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") |
| 1673 | rows: input.rows, | 1702 | ? addExecutorHttpSource({ |
| 1674 | sourceInput: sourceInput as Extract< | 1703 | rows: input.rows, |
| 1675 | ExecutorAddSourceInput, | 1704 | sourceInput: sourceInput as Extract< |
| 1676 | { kind: "openapi" | "graphql" } | 1705 | ExecutorAddSourceInput, |
| 1677 | >, | 1706 | { kind: "openapi" | "graphql" } |
| 1678 | storeSecretMaterial, | 1707 | >, |
| 1679 | resolveSecretMaterial, | 1708 | storeSecretMaterial, |
| 1680 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 1709 | resolveSecretMaterial, |
| 1681 | baseUrl: options?.baseUrl, | 1710 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 1682 | }) | 1711 | baseUrl: options?.baseUrl, |
| 1683 | : connectMcpSourceInternal({ | 1712 | }) |
| 1684 | rows: input.rows, | 1713 | : connectMcpSourceInternal({ |
| 1685 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 1714 | rows: input.rows, |
| 1686 | workspaceId: sourceInput.workspaceId, | 1715 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 1687 | actorAccountId: sourceInput.actorAccountId, | 1716 | workspaceId: sourceInput.workspaceId, |
| 1688 | executionId: sourceInput.executionId, | 1717 | actorAccountId: sourceInput.actorAccountId, |
| 1689 | interactionId: sourceInput.interactionId, | 1718 | executionId: sourceInput.executionId, |
| 1690 | endpoint: sourceInput.endpoint, | 1719 | interactionId: sourceInput.interactionId, |
| 1691 | name: sourceInput.name, | 1720 | endpoint: sourceInput.endpoint, |
| 1692 | namespace: sourceInput.namespace, | 1721 | name: sourceInput.name, |
| 1693 | mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation, | 1722 | namespace: sourceInput.namespace, |
| 1694 | baseUrl: options?.baseUrl, | 1723 | mcpDiscoveryElicitation: options?.mcpDiscoveryElicitation, |
| 1695 | resolveSecretMaterial, | 1724 | baseUrl: options?.baseUrl, |
| 1696 | }), | 1725 | resolveSecretMaterial, |
| 1726 | })).pipe( | ||
| 1727 | Effect.flatMap(mirrorLocalSourceResult), | ||
| 1728 | ), | ||
| 1729 | ), | ||
| 1697 | 1730 | ||
| 1698 | connectMcpSource: (sourceInput) => | 1731 | connectMcpSource: (sourceInput) => |
| 1699 | connectMcpSourceInternal({ | 1732 | provideLocalWorkspace( |
| 1700 | rows: input.rows, | 1733 | connectMcpSourceInternal({ |
| 1701 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 1734 | rows: input.rows, |
| 1702 | workspaceId: sourceInput.workspaceId, | 1735 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 1703 | actorAccountId: sourceInput.actorAccountId, | 1736 | workspaceId: sourceInput.workspaceId, |
| 1704 | sourceId: sourceInput.sourceId, | 1737 | actorAccountId: sourceInput.actorAccountId, |
| 1705 | executionId: null, | 1738 | sourceId: sourceInput.sourceId, |
| 1706 | interactionId: null, | 1739 | executionId: null, |
| 1707 | endpoint: sourceInput.endpoint, | 1740 | interactionId: null, |
| 1708 | name: sourceInput.name, | 1741 | endpoint: sourceInput.endpoint, |
| 1709 | namespace: sourceInput.namespace, | 1742 | name: sourceInput.name, |
| 1710 | enabled: sourceInput.enabled, | 1743 | namespace: sourceInput.namespace, |
| 1711 | transport: sourceInput.transport, | 1744 | enabled: sourceInput.enabled, |
| 1712 | queryParams: sourceInput.queryParams, | 1745 | transport: sourceInput.transport, |
| 1713 | headers: sourceInput.headers, | 1746 | queryParams: sourceInput.queryParams, |
| 1714 | baseUrl: sourceInput.baseUrl, | 1747 | headers: sourceInput.headers, |
| 1715 | resolveSecretMaterial, | 1748 | baseUrl: sourceInput.baseUrl, |
| 1716 | }), | 1749 | resolveSecretMaterial, |
| 1750 | }).pipe( | ||
| 1751 | Effect.flatMap(mirrorLocalMcpSourceResult), | ||
| 1752 | ), | ||
| 1753 | ), | ||
| 1717 | 1754 | ||
| 1718 | startSourceOAuthSession: (oauthInput) => | 1755 | startSourceOAuthSession: (oauthInput) => |
| 1719 | Effect.gen(function* () { | 1756 | Effect.gen(function* () { |
| @@ -1781,7 +1818,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines | |||
| 1781 | error, | 1818 | error, |
| 1782 | errorDescription, | 1819 | errorDescription, |
| 1783 | }) => | 1820 | }) => |
| 1784 | Effect.gen(function* () { | 1821 | provideLocalWorkspace(Effect.gen(function* () { |
| 1785 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); | 1822 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 1786 | if (Option.isNone(sessionOption)) { | 1823 | if (Option.isNone(sessionOption)) { |
| 1787 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); | 1824 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); |
| @@ -1890,7 +1927,7 @@ export const createRuntimeSourceAuthService = (input: { · 102 unmodified lines | |||
| 1890 | sessionId: session.id, | 1927 | sessionId: session.id, |
| 1891 | auth, | 1928 | auth, |
| 1892 | } satisfies CompleteSourceOAuthSessionResult; | 1929 | } satisfies CompleteSourceOAuthSessionResult; |
| 1893 | }), | 1930 | })), |
| 1894 | 1931 | ||
| 1895 | completeSourceCredentialSetup: ({ | 1932 | completeSourceCredentialSetup: ({ |
| 1896 | workspaceId, | 1933 | workspaceId, |
| @@ -1901,7 +1938,7 @@ export const createRuntimeSourceAuthService = (input: { · 4 unmodified lines | |||
| 1901 | error, | 1938 | error, |
| 1902 | errorDescription, | 1939 | errorDescription, |
| 1903 | }) => | 1940 | }) => |
| 1904 | Effect.gen(function* () { | 1941 | provideLocalWorkspace(Effect.gen(function* () { |
| 1905 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); | 1942 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 1906 | if (Option.isNone(sessionOption)) { | 1943 | if (Option.isNone(sessionOption)) { |
| 1907 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); | 1944 | return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`)); |
| @@ -2164,7 +2201,7 @@ export const createRuntimeSourceAuthService = (input: { · 256 unmodified lines | |||
| 2164 | }); | 2201 | }); |
| 2165 | 2202 | ||
| 2166 | return connectedSource; | 2203 | return connectedSource; |
| 2167 | }), | 2204 | })), |
| 2168 | } satisfies RuntimeSourceAuthServiceShape; | 2205 | } satisfies RuntimeSourceAuthServiceShape; |
| 2169 | }; | 2206 | }; |
| 2170 | 2207 | ||
| @@ -2176,6 +2213,9 @@ export class RuntimeSourceAuthServiceTag extends Context.Tag( · 5 unmodified lines | |||
| 2176 | 2213 | ||
| 2177 | export const RuntimeSourceAuthServiceLive = (input: { | 2214 | export const RuntimeSourceAuthServiceLive = (input: { |
| 2178 | getLocalServerBaseUrl?: () => string | undefined; | 2215 | getLocalServerBaseUrl?: () => string | undefined; |
| 2216 | localConfig?: LocalExecutorConfig | null; | ||
| 2217 | workspaceRoot?: string | null; | ||
| 2218 | localWorkspaceState?: RuntimeLocalWorkspaceState; | ||
| 2179 | } = {}) => | 2219 | } = {}) => |
| 2180 | Layer.effect( | 2220 | Layer.effect( |
| 2181 | RuntimeSourceAuthServiceTag, | 2221 | RuntimeSourceAuthServiceTag, |
| @@ -2187,6 +2227,9 @@ export const RuntimeSourceAuthServiceLive = (input: { · 5 unmodified lines | |||
| 2187 | rows, | 2227 | rows, |
| 2188 | liveExecutionManager, | 2228 | liveExecutionManager, |
| 2189 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 2229 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 2230 | localConfig: input.localConfig, | ||
| 2231 | workspaceRoot: input.workspaceRoot, | ||
| 2232 | localWorkspaceState: input.localWorkspaceState, | ||
| 2190 | }); | 2233 | }); |
| 2191 | }), | 2234 | }), |
| 2192 | ); | 2235 | ); |
| @@ -1,11 +1,14 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 1 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 3 | 7 | ||
| 4 | import { | 8 | import { |
| 5 | AccountIdSchema, | 9 | AccountIdSchema, |
| 6 | OrganizationIdSchema, | 10 | type LocalConfigSource, |
| 7 | SourceIdSchema, | 11 | SourceIdSchema, |
| 8 | SourceRecipeIdSchema, | ||
| 9 | SourceRecipeRevisionIdSchema, | 12 | SourceRecipeRevisionIdSchema, |
| 10 | SourceRecipeSchemaBundleIdSchema, | 13 | SourceRecipeSchemaBundleIdSchema, |
| 11 | WorkspaceIdSchema, | 14 | WorkspaceIdSchema, |
| @@ -17,11 +20,27 @@ import { · 5 unmodified lines | |||
| 17 | type SqlControlPlanePersistence, | 20 | type SqlControlPlanePersistence, |
| 18 | } from "#persistence"; | 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 | import { | 32 | import { |
| 21 | getSourceInspection, | 33 | getSourceInspection, |
| 22 | getSourceInspectionSchemaBundle, | 34 | getSourceInspectionSchemaBundle, |
| 23 | getSourceInspectionToolDetail, | 35 | getSourceInspectionToolDetail, |
| 24 | } from "./source-inspection"; | 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 | import { ControlPlaneStore } from "./store"; | 44 | import { ControlPlaneStore } from "./store"; |
| 26 | 45 | ||
| 27 | const makePersistence = () => | 46 | const makePersistence = () => |
| @@ -31,30 +50,28 @@ const makePersistence = () => · 3 unmodified lines | |||
| 31 | }), | 50 | }), |
| 32 | ); | 51 | ); |
| 33 | 52 | ||
| 34 | const seedWorkspace = async (input: { | 53 | const makeRuntimeLocalWorkspaceState = ( |
| 35 | persistence: SqlControlPlanePersistence; | 54 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, |
| 36 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | 55 | accountId: ReturnType<typeof AccountIdSchema.make>, |
| 37 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; | 56 | ) => |
| 38 | accountId: ReturnType<typeof AccountIdSchema.make>; | 57 | Effect.runPromise( |
| 39 | }) => { | 58 | Effect.gen(function* () { |
| 40 | await Effect.runPromise(input.persistence.rows.organizations.insert({ | 59 | const workspaceRoot = mkdtempSync( |
| 41 | id: input.organizationId, | 60 | join(tmpdir(), "executor-source-inspection-"), |
| 42 | slug: `org-${input.organizationId}`, | 61 | ); |
| 43 | name: `Org ${input.organizationId}`, | 62 | const context = yield* resolveLocalWorkspaceContext({ workspaceRoot }); |
| 44 | status: "active", | 63 | const loadedConfig = yield* loadLocalExecutorConfig(context); |
| 45 | createdByAccountId: input.accountId, | 64 | |
| 46 | createdAt: 1000, | 65 | return { |
| 47 | updatedAt: 1000, | 66 | context, |
| 48 | })); | 67 | installation: { |
| 49 | await Effect.runPromise(input.persistence.rows.workspaces.insert({ | 68 | workspaceId, |
| 50 | id: input.workspaceId, | 69 | accountId, |
| 51 | organizationId: input.organizationId, | 70 | }, |
| 52 | name: `Workspace ${input.workspaceId}`, | 71 | loadedConfig, |
| 53 | createdByAccountId: input.accountId, | 72 | } satisfies RuntimeLocalWorkspaceState; |
| 54 | createdAt: 1000, | 73 | }), |
| 55 | updatedAt: 1000, | 74 | ); |
| 56 | })); | ||
| 57 | }; | ||
| 58 | 75 | ||
| 59 | const makeSource = (input: { | 76 | const makeSource = (input: { |
| 60 | workspaceId: Source["workspaceId"]; | 77 | workspaceId: Source["workspaceId"]; |
| @@ -133,16 +150,39 @@ const makeOperation = ( · 72 unmodified lines | |||
| 133 | ...overrides, | 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 | describe("source-inspection", () => { | 173 | describe("source-inspection", () => { |
| 137 | it("returns a lightweight inspection bundle and loads rich detail on demand", async () => { | 174 | it("returns a lightweight inspection bundle and loads rich detail on demand", async () => { |
| 138 | const persistence = await makePersistence(); | 175 | const persistence = await makePersistence(); |
| 139 | try { | 176 | try { |
| 140 | const workspaceId = WorkspaceIdSchema.make("ws_source_inspection"); | 177 | const workspaceId = WorkspaceIdSchema.make("ws_source_inspection"); |
| 141 | const organizationId = OrganizationIdSchema.make("org_source_inspection"); | ||
| 142 | const accountId = AccountIdSchema.make("acc_source_inspection"); | 178 | const accountId = AccountIdSchema.make("acc_source_inspection"); |
| 143 | const sourceId = SourceIdSchema.make("src_source_inspection"); | 179 | const sourceId = SourceIdSchema.make("src_source_inspection"); |
| 144 | const recipeId = SourceRecipeIdSchema.make("src_recipe_inspection"); | 180 | const recipeRevisionId = |
| 145 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_inspection"); | 181 | SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"); |
| 182 | const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState( | ||
| 183 | workspaceId, | ||
| 184 | accountId, | ||
| 185 | ); | ||
| 146 | const hugeDocument = JSON.stringify({ | 186 | const hugeDocument = JSON.stringify({ |
| 147 | openapi: "3.0.3", | 187 | openapi: "3.0.3", |
| 148 | info: { | 188 | info: { |
| @@ -162,102 +202,82 @@ describe("source-inspection", () => { · 13 unmodified lines | |||
| 162 | ), | 202 | ), |
| 163 | }); | 203 | }); |
| 164 | 204 | ||
| 165 | await seedWorkspace({ | 205 | const source = makeSource({ |
| 166 | persistence, | ||
| 167 | workspaceId, | 206 | workspaceId, |
| 168 | organizationId, | 207 | sourceId, |
| 169 | accountId, | ||
| 170 | }); | 208 | }); |
| 171 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | 209 | await Effect.runPromise(writeProjectLocalExecutorConfig({ |
| 172 | id: recipeId, | 210 | context: runtimeLocalWorkspace.context, |
| 173 | kind: "http_api", | 211 | config: { |
| 174 | adapterKey: "openapi", | 212 | sources: { |
| 175 | providerKey: "generic_http", | 213 | [sourceId]: configSourceFromSource(source), |
| 176 | name: "Cloudflare API", | 214 | }, |
| 177 | summary: null, | 215 | }, |
| 178 | visibility: "workspace", | ||
| 179 | latestRevisionId: recipeRevisionId, | ||
| 180 | createdAt: 1000, | ||
| 181 | updatedAt: 1000, | ||
| 182 | })); | 216 | })); |
| 183 | await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({ | 217 | await Effect.runPromise(writeLocalSourceArtifact({ |
| 184 | id: recipeRevisionId, | 218 | context: runtimeLocalWorkspace.context, |
| 185 | recipeId, | 219 | sourceId, |
| 186 | revisionNumber: 1, | 220 | artifact: buildLocalSourceArtifact({ |
| 187 | sourceConfigJson: JSON.stringify({ | 221 | source: { |
| 188 | kind: "openapi", | 222 | ...source, |
| 189 | endpoint: "https://api.cloudflare.com/client/v4", | 223 | sourceHash: "manifest_hash", |
| 190 | specUrl: "https://example.com/openapi.json", | 224 | }, |
| 191 | }), | 225 | materialization: { |
| 192 | manifestJson: JSON.stringify({ | 226 | manifestJson: JSON.stringify({ |
| 193 | sourceHash: "manifest_hash", | 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({ | 266 | await Effect.runPromise(writeLocalWorkspaceState({ |
| 201 | recipeRevisionId, | 267 | context: runtimeLocalWorkspace.context, |
| 202 | documents: [{ | 268 | state: { |
| 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", | ||
| 251 | version: 1, | 269 | version: 1, |
| 252 | payload: { | 270 | sources: { |
| 253 | specUrl: "https://example.com/openapi.json", | 271 | [sourceId]: { |
| 254 | defaultHeaders: null, | 272 | status: "connected", |
| 273 | lastError: null, | ||
| 274 | sourceHash: "manifest_hash", | ||
| 275 | createdAt: 1000, | ||
| 276 | updatedAt: 1000, | ||
| 277 | }, | ||
| 255 | }, | 278 | }, |
| 256 | }), | 279 | policies: {}, |
| 257 | sourceHash: null, | 280 | }, |
| 258 | lastError: null, | ||
| 259 | createdAt: 1000, | ||
| 260 | updatedAt: 1000, | ||
| 261 | })); | 281 | })); |
| 262 | 282 | ||
| 263 | const inspection = await Effect.runPromise( | 283 | const inspection = await Effect.runPromise( |
| @@ -266,6 +286,10 @@ describe("source-inspection", () => { · 2 unmodified lines | |||
| 266 | sourceId, | 286 | sourceId, |
| 267 | }).pipe( | 287 | }).pipe( |
| 268 | Effect.provideService(ControlPlaneStore, persistence.rows), | 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 | toolPath: "cloudflare.api.zones.listZones", | 306 | toolPath: "cloudflare.api.zones.listZones", |
| 283 | }).pipe( | 307 | }).pipe( |
| 284 | Effect.provideService(ControlPlaneStore, persistence.rows), | 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 | schemaBundleId: "src_recipe_bundle_inspection", | 328 | schemaBundleId: "src_recipe_bundle_inspection", |
| 301 | }).pipe( | 329 | }).pipe( |
| 302 | Effect.provideService(ControlPlaneStore, persistence.rows), | 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 | import { | 16 | import { |
| 17 | ControlPlaneNotFoundError, | 17 | ControlPlaneNotFoundError, |
| 18 | ControlPlaneStorageError, | 18 | ControlPlaneStorageError, |
| 19 | } from "#api"; | 19 | } from "../api/errors"; |
| 20 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | ||
| @@ -26,6 +26,7 @@ import { · 3 unmodified lines | |||
| 26 | getSourceAdapterForOperation, | 26 | getSourceAdapterForOperation, |
| 27 | } from "./source-adapters"; | 27 | } from "./source-adapters"; |
| 28 | import { | 28 | import { |
| 29 | loadSourceWithRecipe, | ||
| 29 | recipeToolMetadata, | 30 | recipeToolMetadata, |
| 30 | recipeToolPath, | 31 | recipeToolPath, |
| 31 | } from "./source-recipes-runtime"; | 32 | } from "./source-recipes-runtime"; |
| @@ -110,41 +111,29 @@ const loadSourceRecipeRecord = (input: { · 78 unmodified lines | |||
| 110 | }) => | 111 | }) => |
| 111 | Effect.flatMap(ControlPlaneStore, (store) => | 112 | Effect.flatMap(ControlPlaneStore, (store) => |
| 112 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 113 | const sourceRecord = yield* sourceInspectOps.bundle.child("record").mapStorage( | 114 | const recipe = yield* loadSourceWithRecipe({ |
| 114 | store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId), | 115 | rows: store, |
| 115 | ); | ||
| 116 | if (Option.isNone(sourceRecord)) { | ||
| 117 | return yield* Effect.fail( | ||
| 118 | sourceInspectOps.bundle.notFound( | ||
| 119 | "Source not found", | ||
| 120 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | ||
| 121 | ), | ||
| 122 | ); | ||
| 123 | } | ||
| 124 | |||
| 125 | const source = yield* loadSourceById(store, { | ||
| 126 | workspaceId: input.workspaceId, | 116 | workspaceId: input.workspaceId, |
| 127 | sourceId: input.sourceId, | 117 | sourceId: input.sourceId, |
| 128 | }).pipe( | 118 | }).pipe( |
| 129 | Effect.mapError((cause) => | 119 | Effect.mapError((cause) => |
| 130 | sourceInspectOps.bundle.unknownStorage( | 120 | cause instanceof Error && cause.message.startsWith("Source not found:") |
| 131 | cause, | 121 | ? sourceInspectOps.bundle.notFound( |
| 132 | "Failed loading source", | 122 | "Source not found", |
| 133 | ), | 123 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, |
| 124 | ) | ||
| 125 | : sourceInspectOps.bundle.unknownStorage( | ||
| 126 | cause, | ||
| 127 | "Failed loading source recipe", | ||
| 128 | ), | ||
| 134 | ), | 129 | ), |
| 135 | ); | 130 | ); |
| 136 | const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage( | ||
| 137 | store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | ||
| 138 | ); | ||
| 139 | const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage( | ||
| 140 | store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | ||
| 141 | ); | ||
| 142 | 131 | ||
| 143 | return { | 132 | return { |
| 144 | source, | 133 | source: recipe.source, |
| 145 | operations, | 134 | operations: recipe.operations, |
| 146 | schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id | 135 | schemaBundleId: recipe.schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id |
| 147 | ?? schemaBundles[0]?.id | 136 | ?? recipe.schemaBundles[0]?.id |
| 148 | ?? null, | 137 | ?? null, |
| 149 | }; | 138 | }; |
| 150 | }), | 139 | }), |
| @@ -403,27 +392,27 @@ export const getSourceInspectionSchemaBundle = (input: { · 252 unmodified lines | |||
| 403 | }) => | 392 | }) => |
| 404 | Effect.flatMap(ControlPlaneStore, (store) => | 393 | Effect.flatMap(ControlPlaneStore, (store) => |
| 405 | Effect.gen(function* () { | 394 | Effect.gen(function* () { |
| 406 | const sourceRecord = yield* sourceInspectOps.tool.child("bundle_record").mapStorage( | 395 | const recipe = yield* loadSourceWithRecipe({ |
| 407 | store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId), | 396 | rows: store, |
| 408 | ); | 397 | workspaceId: input.workspaceId, |
| 409 | if (Option.isNone(sourceRecord)) { | 398 | sourceId: input.sourceId, |
| 410 | return yield* Effect.fail( | 399 | }).pipe( |
| 411 | sourceInspectOps.tool.notFound( | 400 | Effect.mapError((cause) => |
| 412 | "Source not found", | 401 | cause instanceof Error && cause.message.startsWith("Source not found:") |
| 413 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | 402 | ? sourceInspectOps.tool.notFound( |
| 414 | ), | 403 | "Source not found", |
| 415 | ); | 404 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, |
| 416 | } | 405 | ) |
| 417 | 406 | : sourceInspectOps.tool.unknownStorage( | |
| 418 | const schemaBundle = yield* sourceInspectOps.tool.child("schema_bundle").mapStorage( | 407 | cause, |
| 419 | store.sourceRecipeSchemaBundles.getById( | 408 | "Failed loading source recipe", |
| 420 | SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId), | 409 | ), |
| 421 | ), | 410 | ), |
| 422 | ); | 411 | ); |
| 423 | if ( | 412 | const schemaBundle = recipe.schemaBundles.find( |
| 424 | Option.isNone(schemaBundle) | 413 | (candidate) => candidate.id === SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId), |
| 425 | || schemaBundle.value.recipeRevisionId !== sourceRecord.value.recipeRevisionId | 414 | ); |
| 426 | ) { | 415 | if (!schemaBundle) { |
| 427 | return yield* Effect.fail( | 416 | return yield* Effect.fail( |
| 428 | sourceInspectOps.tool.notFound( | 417 | sourceInspectOps.tool.notFound( |
| 429 | "Schema bundle not found", | 418 | "Schema bundle not found", |
| @@ -433,10 +422,10 @@ export const getSourceInspectionSchemaBundle = (input: { · 3 unmodified lines | |||
| 433 | } | 422 | } |
| 434 | 423 | ||
| 435 | return { | 424 | return { |
| 436 | id: schemaBundle.value.id, | 425 | id: schemaBundle.id, |
| 437 | kind: schemaBundle.value.bundleKind, | 426 | kind: schemaBundle.bundleKind, |
| 438 | hash: schemaBundle.value.contentHash, | 427 | hash: schemaBundle.contentHash, |
| 439 | refsJson: schemaBundle.value.refsJson, | 428 | refsJson: schemaBundle.refsJson, |
| 440 | } satisfies SourceInspectionSchemaBundle; | 429 | } satisfies SourceInspectionSchemaBundle; |
| 441 | }), | 430 | }), |
| 442 | ).pipe( | 431 | ).pipe( |
| @@ -2,18 +2,33 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line | |||
| 2 | import type { | 2 | import type { |
| 3 | AccountId, | 3 | AccountId, |
| 4 | Source, | 4 | Source, |
| 5 | SourceStatus, | ||
| 5 | } from "#schema"; | 6 | } from "#schema"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | ||
| 9 | import { | ||
| 10 | buildLocalSourceArtifact, | ||
| 11 | writeLocalSourceArtifact, | ||
| 12 | } from "./local-source-artifacts"; | ||
| 13 | import { | ||
| 14 | requireRuntimeLocalWorkspace, | ||
| 15 | } from "./local-runtime-context"; | ||
| 16 | import { | ||
| 17 | loadLocalWorkspaceState, | ||
| 18 | writeLocalWorkspaceState, | ||
| 19 | type LocalWorkspaceState, | ||
| 20 | } from "./local-workspace-state"; | ||
| 8 | import { resolveSourceAuthMaterial } from "./source-auth-material"; | 21 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| 9 | import { | 22 | import { |
| 10 | getSourceAdapterForSource, | 23 | getSourceAdapterForSource, |
| 11 | } from "./source-adapters"; | 24 | } from "./source-adapters"; |
| 12 | import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp"; | 25 | import { |
| 26 | materializationFromMcpManifestEntries, | ||
| 27 | persistMcpRecipeRevisionFromManifestEntries, | ||
| 28 | } from "./source-adapters/mcp"; | ||
| 13 | import type { | 29 | import type { |
| 14 | ResolveSecretMaterial as ResolveSourceSecretMaterial, | 30 | ResolveSecretMaterial as ResolveSourceSecretMaterial, |
| 15 | } from "./secret-material-providers"; | 31 | } from "./secret-material-providers"; |
| 16 | import { persistRecipeMaterialization } from "./source-recipe-support"; | ||
| 17 | 32 | ||
| 18 | const shouldIndexSource = (source: Source): boolean => | 33 | const shouldIndexSource = (source: Source): boolean => |
| 19 | source.enabled | 34 | source.enabled |
| @@ -27,7 +42,28 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines | |||
| 27 | resolveSecretMaterial: ResolveSourceSecretMaterial; | 42 | resolveSecretMaterial: ResolveSourceSecretMaterial; |
| 28 | }): Effect.Effect<void, Error, never> => | 43 | }): Effect.Effect<void, Error, never> => |
| 29 | Effect.gen(function* () { | 44 | Effect.gen(function* () { |
| 45 | const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId); | ||
| 46 | |||
| 30 | if (!shouldIndexSource(input.source)) { | 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 | return; | 67 | return; |
| 32 | } | 68 | } |
| 33 | 69 | ||
| @@ -44,10 +80,33 @@ export const syncSourceMaterialization = (input: { · 10 unmodified lines | |||
| 44 | resolveSecretMaterial: input.resolveSecretMaterial, | 80 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 45 | }), | 81 | }), |
| 46 | }); | 82 | }); |
| 47 | yield* persistRecipeMaterialization({ | 83 | yield* writeLocalSourceArtifact({ |
| 48 | rows: input.rows, | 84 | context: runtimeLocalWorkspace.context, |
| 49 | source: input.source, | 85 | sourceId: input.source.id, |
| 50 | materialization, | 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 | typeof persistMcpRecipeRevisionFromManifestEntries | 117 | typeof persistMcpRecipeRevisionFromManifestEntries |
| 59 | >[0]["manifestEntries"]; | 118 | >[0]["manifestEntries"]; |
| 60 | }): Effect.Effect<void, Error, never> => | 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 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 3 | import { | 7 | import { |
| @@ -8,8 +12,8 @@ import { · 4 unmodified lines | |||
| 8 | 12 | ||
| 9 | import { | 13 | import { |
| 10 | AccountIdSchema, | 14 | AccountIdSchema, |
| 11 | OrganizationIdSchema, | ||
| 12 | SourceIdSchema, | 15 | SourceIdSchema, |
| 16 | type LocalConfigSource, | ||
| 13 | SourceRecipeIdSchema, | 17 | SourceRecipeIdSchema, |
| 14 | SourceRecipeRevisionIdSchema, | 18 | SourceRecipeRevisionIdSchema, |
| 15 | WorkspaceIdSchema, | 19 | WorkspaceIdSchema, |
| @@ -21,6 +25,22 @@ import { · 5 unmodified lines | |||
| 21 | type SqlControlPlanePersistence, | 25 | type SqlControlPlanePersistence, |
| 22 | } from "#persistence"; | 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 | import { | 44 | import { |
| 25 | expandRecipeTools, | 45 | expandRecipeTools, |
| 26 | loadSourceWithRecipe, | 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 | const openApiBindingConfigJson = (specUrl: string): string => | 91 | const openApiBindingConfigJson = (specUrl: string): string => |
| 42 | JSON.stringify({ | 92 | JSON.stringify({ |
| 43 | adapterKey: "openapi", | 93 | adapterKey: "openapi", |
| @@ -221,29 +271,128 @@ const makeMcpOperation = ( · 177 unmodified lines | |||
| 221 | ...overrides, | 271 | ...overrides, |
| 222 | }); | 272 | }); |
| 223 | 273 | ||
| 224 | const seedWorkspace = async (input: { | 274 | const configSourceFromSource = (source: Source): LocalConfigSource => { |
| 225 | persistence: SqlControlPlanePersistence; | 275 | switch (source.kind) { |
| 226 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | 276 | case "openapi": |
| 227 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; | 277 | return { |
| 228 | accountId: ReturnType<typeof AccountIdSchema.make>; | 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; | 356 | await Effect.runPromise(writeProjectLocalExecutorConfig({ |
| 231 | await Effect.runPromise(input.persistence.rows.organizations.insert({ | 357 | context: input.runtimeLocalWorkspace.context, |
| 232 | id: input.organizationId, | 358 | config: { |
| 233 | slug: `org-${input.organizationId}`, | 359 | sources: Object.fromEntries( |
| 234 | name: `Org ${input.organizationId}`, | 360 | input.fixtures.map(({ source }) => [source.id, configSourceFromSource(source)]), |
| 235 | status: "active", | 361 | ), |
| 236 | createdByAccountId: input.accountId, | 362 | }, |
| 237 | createdAt: now, | ||
| 238 | updatedAt: now, | ||
| 239 | })); | 363 | })); |
| 240 | await Effect.runPromise(input.persistence.rows.workspaces.insert({ | 364 | |
| 241 | id: input.workspaceId, | 365 | await Promise.all( |
| 242 | organizationId: input.organizationId, | 366 | input.fixtures.map(({ source, materialization }) => |
| 243 | name: `Workspace ${input.workspaceId}`, | 367 | Effect.runPromise(writeLocalSourceArtifact({ |
| 244 | createdByAccountId: input.accountId, | 368 | context: input.runtimeLocalWorkspace.context, |
| 245 | createdAt: now, | 369 | sourceId: source.id, |
| 246 | updatedAt: now, | 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 | const persistence = await makePersistence(); | 581 | const persistence = await makePersistence(); |
| 433 | try { | 582 | try { |
| 434 | const workspaceId = WorkspaceIdSchema.make("ws_shared_revision"); | 583 | const workspaceId = WorkspaceIdSchema.make("ws_shared_revision"); |
| 435 | const organizationId = OrganizationIdSchema.make("org_shared_revision"); | 584 | const runtimeLocalWorkspace = |
| 436 | const accountId = AccountIdSchema.make("acc_shared_revision"); | 585 | await makeRuntimeLocalWorkspaceState(workspaceId); |
| 437 | const recipeId = SourceRecipeIdSchema.make("src_recipe_shared_revision"); | ||
| 438 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_shared_revision"); | ||
| 439 | const openApiDocument = JSON.stringify({ | 586 | const openApiDocument = JSON.stringify({ |
| 440 | openapi: "3.0.3", | 587 | openapi: "3.0.3", |
| 441 | info: { | 588 | info: { |
| @@ -475,172 +622,115 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines | |||
| 475 | definition, | 622 | definition, |
| 476 | }); | 623 | }); |
| 477 | 624 | ||
| 478 | await seedWorkspace({ | 625 | await seedLocalWorkspaceSources({ |
| 479 | persistence, | 626 | runtimeLocalWorkspace, |
| 480 | workspaceId, | 627 | fixtures: ["GitHub One", "GitHub Two"].map((name, index) => ({ |
| 481 | organizationId, | 628 | source: makeSource({ |
| 482 | accountId, | 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()) { | 668 | const recipes = await Effect.runPromise( |
| 543 | await Effect.runPromise(persistence.rows.sources.insert({ | 669 | withRuntimeLocalWorkspace( |
| 544 | id: SourceIdSchema.make(`src_shared_revision_${index}`), | 670 | loadWorkspaceSourceRecipes({ |
| 545 | workspaceId, | 671 | rows: persistence.rows, |
| 546 | recipeId, | 672 | workspaceId, |
| 547 | recipeRevisionId, | 673 | }), |
| 548 | name, | 674 | runtimeLocalWorkspace, |
| 549 | kind: "openapi", | 675 | ), |
| 550 | endpoint: "https://api.github.com", | 676 | ); |
| 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 | })); | ||
| 567 | 677 | ||
| 568 | expect(recipes).toHaveLength(2); | 678 | expect(recipes).toHaveLength(2); |
| 569 | expect(recipes[0]?.documents).toHaveLength(1); | 679 | expect(recipes[0]?.documents).toHaveLength(1); |
| 570 | expect(recipes[0]?.operations).toHaveLength(1); | 680 | expect(recipes[0]?.operations).toHaveLength(1); |
| 571 | expect(recipes[0]?.documents).toBe(recipes[1]?.documents); | 681 | expect(recipes[0]?.documents[0]?.contentText).toBe( |
| 572 | expect(recipes[0]?.operations).toBe(recipes[1]?.operations); | 682 | recipes[1]?.documents[0]?.contentText, |
| 683 | ); | ||
| 684 | expect(recipes[0]?.operations[0]?.providerDataJson).toBe( | ||
| 685 | recipes[1]?.operations[0]?.providerDataJson, | ||
| 686 | ); | ||
| 573 | } finally { | 687 | } finally { |
| 574 | await persistence.close(); | 688 | await persistence.close(); |
| 575 | } | 689 | } |
| 576 | }); | 690 | }, 60_000); |
| 577 | 691 | ||
| 578 | it("loads sources with empty recipe documents and operations", async () => { | 692 | it("loads sources with empty recipe documents and operations", async () => { |
| 579 | const persistence = await makePersistence(); | 693 | const persistence = await makePersistence(); |
| 580 | try { | 694 | try { |
| 581 | const workspaceId = WorkspaceIdSchema.make("ws_empty_recipe_rows"); | 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 | const sourceId = SourceIdSchema.make("src_empty_recipe_rows"); | 696 | const sourceId = SourceIdSchema.make("src_empty_recipe_rows"); |
| 585 | const recipeId = SourceRecipeIdSchema.make("src_recipe_empty_recipe_rows"); | 697 | const runtimeLocalWorkspace = |
| 586 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_empty_recipe_rows"); | 698 | await makeRuntimeLocalWorkspaceState(workspaceId); |
| 587 | 699 | ||
| 588 | await seedWorkspace({ | 700 | await seedLocalWorkspaceSources({ |
| 589 | persistence, | 701 | runtimeLocalWorkspace, |
| 590 | workspaceId, | 702 | fixtures: [{ |
| 591 | organizationId, | 703 | source: makeSource({ |
| 592 | accountId, | 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({ | 725 | const recipes = await Effect.runPromise( |
| 641 | rows: persistence.rows, | 726 | withRuntimeLocalWorkspace( |
| 642 | workspaceId, | 727 | loadWorkspaceSourceRecipes({ |
| 643 | })); | 728 | rows: persistence.rows, |
| 729 | workspaceId, | ||
| 730 | }), | ||
| 731 | runtimeLocalWorkspace, | ||
| 732 | ), | ||
| 733 | ); | ||
| 644 | 734 | ||
| 645 | expect(recipes).toHaveLength(1); | 735 | expect(recipes).toHaveLength(1); |
| 646 | expect(recipes[0]?.documents).toEqual([]); | 736 | expect(recipes[0]?.documents).toEqual([]); |
| @@ -649,89 +739,117 @@ describe("source-recipes-runtime", () => { · 2 unmodified lines | |||
| 649 | } finally { | 739 | } finally { |
| 650 | await persistence.close(); | 740 | await persistence.close(); |
| 651 | } | 741 | } |
| 652 | }); | 742 | }, 60_000); |
| 653 | 743 | ||
| 654 | it("fails clearly when loading a missing source, missing revision, or invalid manifest", async () => { | 744 | it("fails clearly when loading a missing source, missing revision, or invalid manifest", async () => { |
| 655 | const persistence = await makePersistence(); | 745 | const persistence = await makePersistence(); |
| 656 | try { | 746 | try { |
| 657 | await expect(Effect.runPromise(loadSourceWithRecipe({ | 747 | const missingWorkspaceId = WorkspaceIdSchema.make("ws_missing_source"); |
| 658 | rows: persistence.rows, | 748 | const missingRuntimeLocalWorkspace = |
| 659 | workspaceId: WorkspaceIdSchema.make("ws_missing_source"), | 749 | await makeRuntimeLocalWorkspaceState(missingWorkspaceId); |
| 660 | sourceId: SourceIdSchema.make("src_missing_source"), | 750 | |
| 661 | }))).rejects.toThrow("Source not found"); | 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 | const workspaceId = WorkspaceIdSchema.make("ws_bad_recipe_runtime"); | 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 | const sourceId = SourceIdSchema.make("src_bad_recipe_runtime"); | 765 | const sourceId = SourceIdSchema.make("src_bad_recipe_runtime"); |
| 667 | const recipeId = SourceRecipeIdSchema.make("src_recipe_bad_recipe_runtime"); | 766 | const runtimeLocalWorkspace = |
| 668 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bad_recipe_runtime"); | 767 | await makeRuntimeLocalWorkspaceState(workspaceId); |
| 669 | 768 | ||
| 670 | await seedWorkspace({ | 769 | await Effect.runPromise(writeProjectLocalExecutorConfig({ |
| 671 | persistence, | 770 | context: runtimeLocalWorkspace.context, |
| 672 | workspaceId, | 771 | config: { |
| 673 | organizationId, | 772 | sources: { |
| 674 | accountId, | 773 | [sourceId]: configSourceFromSource( |
| 675 | }); | 774 | makeSource({ |
| 676 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | 775 | id: sourceId, |
| 677 | id: recipeId, | 776 | workspaceId, |
| 678 | kind: "http_api", | 777 | name: "Broken GitHub", |
| 679 | adapterKey: "openapi", | 778 | }), |
| 680 | providerKey: "generic_http", | 779 | ), |
| 681 | name: "Broken GitHub", | 780 | }, |
| 682 | summary: null, | 781 | }, |
| 683 | visibility: "workspace", | ||
| 684 | latestRevisionId: recipeRevisionId, | ||
| 685 | createdAt: 1000, | ||
| 686 | updatedAt: 1000, | ||
| 687 | })); | 782 | })); |
| 688 | await Effect.runPromise(persistence.rows.sources.insert({ | 783 | await Effect.runPromise(writeLocalWorkspaceState({ |
| 689 | id: sourceId, | 784 | context: runtimeLocalWorkspace.context, |
| 690 | workspaceId, | 785 | state: { |
| 691 | recipeId, | 786 | version: 1, |
| 692 | recipeRevisionId, | 787 | sources: { |
| 693 | name: "Broken GitHub", | 788 | [sourceId]: { |
| 694 | kind: "openapi", | 789 | status: "connected", |
| 695 | endpoint: "https://api.github.com", | 790 | lastError: null, |
| 696 | status: "connected", | 791 | sourceHash: null, |
| 697 | enabled: true, | 792 | createdAt: 1000, |
| 698 | namespace: "github", | 793 | updatedAt: 1000, |
| 699 | importAuthPolicy: "reuse_runtime", | 794 | }, |
| 700 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | 795 | }, |
| 701 | sourceHash: null, | 796 | policies: {}, |
| 702 | lastError: null, | 797 | }, |
| 703 | createdAt: 1000, | ||
| 704 | updatedAt: 1000, | ||
| 705 | })); | 798 | })); |
| 706 | 799 | ||
| 707 | await expect(Effect.runPromise(loadSourceWithRecipe({ | 800 | await expect( |
| 708 | rows: persistence.rows, | 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 | workspaceId, | 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 | sourceId, | 831 | sourceId, |
| 711 | }))).rejects.toThrow("Recipe revision missing"); | 832 | artifact: { |
| 712 | 833 | ...invalidArtifact, | |
| 713 | await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({ | 834 | revision: { |
| 714 | id: recipeRevisionId, | 835 | ...invalidArtifact.revision, |
| 715 | recipeId, | 836 | manifestJson: "{bad-json", |
| 716 | revisionNumber: 1, | 837 | }, |
| 717 | sourceConfigJson: JSON.stringify({ | 838 | }, |
| 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, | ||
| 728 | })); | 839 | })); |
| 729 | 840 | ||
| 730 | await expect(Effect.runPromise(loadSourceWithRecipe({ | 841 | await expect( |
| 731 | rows: persistence.rows, | 842 | Effect.runPromise( |
| 732 | workspaceId, | 843 | withRuntimeLocalWorkspace( |
| 733 | sourceId, | 844 | loadSourceWithRecipe({ |
| 734 | }))).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`); | 845 | rows: persistence.rows, |
| 846 | workspaceId, | ||
| 847 | sourceId, | ||
| 848 | }), | ||
| 849 | runtimeLocalWorkspace, | ||
| 850 | ), | ||
| 851 | ), | ||
| 852 | ).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`); | ||
| 735 | } finally { | 853 | } finally { |
| 736 | await persistence.close(); | 854 | await persistence.close(); |
| 737 | } | 855 | } |
| @@ -13,8 +13,14 @@ import type { · 12 unmodified lines | |||
| 13 | WorkspaceId, | 13 | WorkspaceId, |
| 14 | } from "#schema"; | 14 | } from "#schema"; |
| 15 | import * as Effect from "effect/Effect"; | 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 | import { namespaceFromSourceName } from "./source-names"; | 24 | import { namespaceFromSourceName } from "./source-names"; |
| 19 | import { | 25 | import { |
| 20 | getSourceAdapterForOperation, | 26 | getSourceAdapterForOperation, |
| @@ -156,71 +162,56 @@ export const loadWorkspaceSourceRecipes = (input: { · 135 unmodified lines | |||
| 156 | actorAccountId?: AccountId | null; | 162 | actorAccountId?: AccountId | null; |
| 157 | }): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> => | 163 | }): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> => |
| 158 | Effect.gen(function* () { | 164 | Effect.gen(function* () { |
| 159 | const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | 165 | const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId); |
| 160 | const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | 166 | const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { |
| 161 | actorAccountId: input.actorAccountId, | 167 | actorAccountId: input.actorAccountId, |
| 162 | }); | 168 | }); |
| 163 | 169 | const localRecipes = yield* Effect.forEach(sources, (source) => | |
| 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) => | ||
| 193 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 194 | const source = sourceById.get(sourceRecord.id); | 171 | const artifact = yield* readLocalSourceArtifact({ |
| 195 | if (!source) { | 172 | context: runtimeLocalWorkspace.context, |
| 196 | return yield* Effect.fail( | 173 | sourceId: source.id, |
| 197 | new Error(`Projected source missing for ${sourceRecord.id}`), | 174 | }); |
| 198 | ); | 175 | if (artifact === null) { |
| 176 | return null; | ||
| 199 | } | 177 | } |
| 200 | 178 | ||
| 201 | const revision = revisionById.get(sourceRecord.recipeRevisionId); | 179 | const sourceRecord: StoredSourceRecord = { |
| 202 | if (!revision) { | 180 | id: source.id, |
| 203 | return yield* Effect.fail( | 181 | workspaceId: source.workspaceId, |
| 204 | new Error(`Recipe revision missing for source ${sourceRecord.id}`), | 182 | recipeId: artifact.recipeId, |
| 205 | ); | 183 | recipeRevisionId: artifact.revision.id, |
| 206 | } | 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 | const manifest = yield* parseManifestForRecipe({ | 198 | const manifest = yield* parseManifestForRecipe({ |
| 209 | source, | 199 | source, |
| 210 | revision, | 200 | revision: artifact.revision, |
| 211 | }); | 201 | }); |
| 212 | 202 | ||
| 213 | return { | 203 | return { |
| 214 | source, | 204 | source, |
| 215 | sourceRecord, | 205 | sourceRecord, |
| 216 | revision, | 206 | revision: artifact.revision, |
| 217 | documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | 207 | documents: artifact.documents, |
| 218 | schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | 208 | schemaBundles: artifact.schemaBundles, |
| 219 | operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | 209 | operations: artifact.operations, |
| 220 | manifest, | 210 | manifest, |
| 221 | } satisfies LoadedSourceRecipe; | 211 | } satisfies LoadedSourceRecipe; |
| 222 | }), | 212 | }), |
| 223 | ); | 213 | ); |
| 214 | return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null); | ||
| 224 | }); | 215 | }); |
| 225 | 216 | ||
| 226 | export const loadSourceWithRecipe = (input: { | 217 | export const loadSourceWithRecipe = (input: { |
| @@ -228,46 +219,57 @@ export const loadSourceWithRecipe = (input: { · 1 unmodified line | |||
| 228 | workspaceId: WorkspaceId; | 219 | workspaceId: WorkspaceId; |
| 229 | sourceId: Source["id"]; | 220 | sourceId: Source["id"]; |
| 230 | actorAccountId?: AccountId | null; | 221 | actorAccountId?: AccountId | null; |
| 231 | }): Effect.Effect<LoadedSourceRecipe, Error, never> => | 222 | }): Effect.Effect<LoadedSourceRecipe, Error | LocalSourceArtifactMissingError, never> => |
| 232 | Effect.gen(function* () { | 223 | Effect.gen(function* () { |
| 233 | const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | 224 | const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId); |
| 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 | |||
| 243 | const source = yield* loadSourceById(input.rows, { | 225 | const source = yield* loadSourceById(input.rows, { |
| 244 | workspaceId: input.workspaceId, | 226 | workspaceId: input.workspaceId, |
| 245 | sourceId: input.sourceId, | 227 | sourceId: input.sourceId, |
| 246 | actorAccountId: input.actorAccountId, | 228 | actorAccountId: input.actorAccountId, |
| 247 | }); | 229 | }); |
| 248 | const revision = yield* input.rows.sourceRecipeRevisions.getById(sourceRecord.value.recipeRevisionId); | 230 | const artifact = yield* readLocalSourceArtifact({ |
| 249 | if (Option.isNone(revision)) { | 231 | context: runtimeLocalWorkspace.context, |
| 232 | sourceId: source.id, | ||
| 233 | }); | ||
| 234 | if (artifact === null) { | ||
| 250 | return yield* Effect.fail( | 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([ | 242 | |
| 255 | input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId), | 243 | const sourceRecord: StoredSourceRecord = { |
| 256 | input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | 244 | id: source.id, |
| 257 | input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | 245 | workspaceId: source.workspaceId, |
| 258 | parseManifestForRecipe({ | 246 | recipeId: artifact.recipeId, |
| 259 | source, | 247 | recipeRevisionId: artifact.revision.id, |
| 260 | revision: revision.value, | 248 | name: source.name, |
| 261 | }), | 249 | kind: source.kind, |
| 262 | ]); | 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 | return { | 266 | return { |
| 265 | source, | 267 | source, |
| 266 | sourceRecord: sourceRecord.value, | 268 | sourceRecord, |
| 267 | revision: revision.value, | 269 | revision: artifact.revision, |
| 268 | documents, | 270 | documents: artifact.documents, |
| 269 | schemaBundles, | 271 | schemaBundles: artifact.schemaBundles, |
| 270 | operations, | 272 | operations: artifact.operations, |
| 271 | manifest, | 273 | manifest, |
| 272 | } satisfies LoadedSourceRecipe; | 274 | } satisfies LoadedSourceRecipe; |
| 273 | }); | 275 | }); |
| @@ -339,94 +341,26 @@ export const loadWorkspaceSourceRecipeToolIndex = (input: { · 65 unmodified lines | |||
| 339 | includeSchemas: boolean; | 341 | includeSchemas: boolean; |
| 340 | }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => | 342 | }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => |
| 341 | Effect.gen(function* () { | 343 | Effect.gen(function* () { |
| 342 | const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | 344 | const recipes = yield* loadWorkspaceSourceRecipes({ |
| 343 | const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | 345 | rows: input.rows, |
| 346 | workspaceId: input.workspaceId, | ||
| 344 | actorAccountId: input.actorAccountId, | 347 | actorAccountId: input.actorAccountId, |
| 345 | }); | 348 | }); |
| 346 | const sourceById = new Map(sources.map((source) => [source.id, source])); | 349 | const tools = yield* expandRecipeTools({ |
| 347 | const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | 350 | recipes, |
| 348 | const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))]; | 351 | includeSchemas: input.includeSchemas, |
| 349 | const [operations, schemaBundles] = yield* Effect.all([ | 352 | }); |
| 350 | input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds), | 353 | return tools.map((tool) => ({ |
| 351 | input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds), | 354 | path: tool.path, |
| 352 | ]); | 355 | searchNamespace: tool.searchNamespace, |
| 353 | 356 | searchText: tool.searchText, | |
| 354 | const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); | 357 | source: tool.source, |
| 355 | for (const operation of operations) { | 358 | sourceRecord: tool.sourceRecord, |
| 356 | const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? []; | 359 | operation: tool.operation, |
| 357 | existing.push(operation); | 360 | metadata: tool.metadata, |
| 358 | operationsByRevisionId.set(operation.recipeRevisionId, existing); | 361 | schemaBundleId: tool.schemaBundleId, |
| 359 | } | 362 | descriptor: tool.descriptor, |
| 360 | 363 | })); | |
| 361 | const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | ||
| 362 | for (const schemaBundle of schemaBundles) { | ||
| 363 | const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | ||
| 364 | existing.push(schemaBundle); | ||
| 365 | schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | ||
| 366 | } | ||
| 367 | |||
| 368 | const entryGroups = yield* Effect.forEach( | ||
| 369 | relevantSourceRecords, | ||
| 370 | (sourceRecord) => { | ||
| 371 | const source = sourceById.get(sourceRecord.id); | ||
| 372 | if (!source) { | ||
| 373 | return Effect.succeed<readonly LoadedSourceRecipeToolIndexEntry[]>([]); | ||
| 374 | } | ||
| 375 | |||
| 376 | return Effect.forEach( | ||
| 377 | operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | ||
| 378 | (operation) => | ||
| 379 | Effect.gen(function* () { | ||
| 380 | const path = recipeToolPath({ | ||
| 381 | source, | ||
| 382 | operation, | ||
| 383 | }); | ||
| 384 | const searchNamespace = recipeToolSearchNamespace({ | ||
| 385 | source, | ||
| 386 | path, | ||
| 387 | operation, | ||
| 388 | }); | ||
| 389 | const schemaBundleId = primarySchemaBundleForRevision({ | ||
| 390 | source, | ||
| 391 | schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | ||
| 392 | })?.id ?? null; | ||
| 393 | const metadata = yield* recipeToolMetadata({ | ||
| 394 | source, | ||
| 395 | operation, | ||
| 396 | path, | ||
| 397 | }); | ||
| 398 | |||
| 399 | return { | ||
| 400 | path, | ||
| 401 | searchNamespace, | ||
| 402 | searchText: [ | ||
| 403 | path, | ||
| 404 | searchNamespace, | ||
| 405 | source.name, | ||
| 406 | metadata.searchText, | ||
| 407 | ] | ||
| 408 | .filter((part) => part.length > 0) | ||
| 409 | .join(" ") | ||
| 410 | .toLowerCase(), | ||
| 411 | source, | ||
| 412 | sourceRecord, | ||
| 413 | operation, | ||
| 414 | metadata, | ||
| 415 | schemaBundleId, | ||
| 416 | descriptor: recipeToolDescriptor({ | ||
| 417 | source, | ||
| 418 | operation, | ||
| 419 | path, | ||
| 420 | schemaBundleId, | ||
| 421 | includeSchemas: input.includeSchemas, | ||
| 422 | }), | ||
| 423 | } satisfies LoadedSourceRecipeToolIndexEntry; | ||
| 424 | }), | ||
| 425 | ); | ||
| 426 | }, | ||
| 427 | ); | ||
| 428 | |||
| 429 | return entryGroups.flat(); | ||
| 430 | }); | 364 | }); |
| 431 | 365 | ||
| 432 | export const loadWorkspaceSourceRecipeToolByPath = (input: { | 366 | export const loadWorkspaceSourceRecipeToolByPath = (input: { |
| @@ -437,110 +371,27 @@ export const loadWorkspaceSourceRecipeToolByPath = (input: { · 4 unmodified lines | |||
| 437 | includeSchemas: boolean; | 371 | includeSchemas: boolean; |
| 438 | }): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> => | 372 | }): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> => |
| 439 | Effect.gen(function* () { | 373 | Effect.gen(function* () { |
| 440 | const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | 374 | const recipes = yield* loadWorkspaceSourceRecipes({ |
| 441 | const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | 375 | rows: input.rows, |
| 376 | workspaceId: input.workspaceId, | ||
| 442 | actorAccountId: input.actorAccountId, | 377 | actorAccountId: input.actorAccountId, |
| 443 | }); | 378 | }); |
| 444 | const sourceById = new Map(sources.map((source) => [source.id, source])); | 379 | const tools = yield* expandRecipeTools({ |
| 445 | const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | 380 | recipes, |
| 446 | 381 | includeSchemas: input.includeSchemas, | |
| 447 | const candidates = relevantSourceRecords.flatMap((sourceRecord) => { | ||
| 448 | const source = sourceById.get(sourceRecord.id); | ||
| 449 | if (!source) { | ||
| 450 | return []; | ||
| 451 | } | ||
| 452 | |||
| 453 | const namespace = source.namespace ?? namespaceFromSourceName(source.name); | ||
| 454 | if (namespace.length > 0) { | ||
| 455 | if (!input.path.startsWith(`${namespace}.`)) { | ||
| 456 | return []; | ||
| 457 | } | ||
| 458 | |||
| 459 | return [{ | ||
| 460 | source, | ||
| 461 | sourceRecord, | ||
| 462 | toolId: input.path.slice(namespace.length + 1), | ||
| 463 | }]; | ||
| 464 | } | ||
| 465 | |||
| 466 | return [{ | ||
| 467 | source, | ||
| 468 | sourceRecord, | ||
| 469 | toolId: input.path, | ||
| 470 | }]; | ||
| 471 | }); | 382 | }); |
| 472 | 383 | const tool = tools.find((entry) => entry.path === input.path) ?? null; | |
| 473 | if (candidates.length === 0) { | 384 | return tool |
| 474 | return null; | 385 | ? { |
| 475 | } | 386 | path: tool.path, |
| 476 | 387 | searchNamespace: tool.searchNamespace, | |
| 477 | const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))]; | 388 | searchText: tool.searchText, |
| 478 | const [operations, schemaBundles] = yield* Effect.all([ | 389 | source: tool.source, |
| 479 | input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds), | 390 | sourceRecord: tool.sourceRecord, |
| 480 | input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds), | 391 | operation: tool.operation, |
| 481 | ]); | 392 | metadata: tool.metadata, |
| 482 | const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | 393 | schemaBundleId: tool.schemaBundleId, |
| 483 | for (const schemaBundle of schemaBundles) { | 394 | descriptor: tool.descriptor, |
| 484 | const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | 395 | } |
| 485 | existing.push(schemaBundle); | 396 | : null; |
| 486 | schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | ||
| 487 | } | ||
| 488 | |||
| 489 | for (const candidate of candidates) { | ||
| 490 | const operation = operations.find((entry) => | ||
| 491 | entry.recipeRevisionId === candidate.sourceRecord.recipeRevisionId | ||
| 492 | && entry.toolId === candidate.toolId | ||
| 493 | ); | ||
| 494 | if (!operation) { | ||
| 495 | continue; | ||
| 496 | } | ||
| 497 | |||
| 498 | const path = recipeToolPath({ | ||
| 499 | source: candidate.source, | ||
| 500 | operation, | ||
| 501 | }); | ||
| 502 | const searchNamespace = recipeToolSearchNamespace({ | ||
| 503 | source: candidate.source, | ||
| 504 | path, | ||
| 505 | operation, | ||
| 506 | }); | ||
| 507 | const schemaBundleId = primarySchemaBundleForRevision({ | ||
| 508 | source: candidate.source, | ||
| 509 | schemaBundles: | ||
| 510 | schemaBundlesByRevisionId.get(candidate.sourceRecord.recipeRevisionId) ?? [], | ||
| 511 | })?.id ?? null; | ||
| 512 | const metadata = yield* recipeToolMetadata({ | ||
| 513 | source: candidate.source, | ||
| 514 | operation, | ||
| 515 | path, | ||
| 516 | }); | ||
| 517 | |||
| 518 | return { | ||
| 519 | path, | ||
| 520 | searchNamespace, | ||
| 521 | searchText: [ | ||
| 522 | path, | ||
| 523 | searchNamespace, | ||
| 524 | candidate.source.name, | ||
| 525 | metadata.searchText, | ||
| 526 | ] | ||
| 527 | .filter((part) => part.length > 0) | ||
| 528 | .join(" ") | ||
| 529 | .toLowerCase(), | ||
| 530 | source: candidate.source, | ||
| 531 | sourceRecord: candidate.sourceRecord, | ||
| 532 | operation, | ||
| 533 | metadata, | ||
| 534 | schemaBundleId, | ||
| 535 | descriptor: recipeToolDescriptor({ | ||
| 536 | source: candidate.source, | ||
| 537 | operation, | ||
| 538 | path, | ||
| 539 | schemaBundleId, | ||
| 540 | includeSchemas: input.includeSchemas, | ||
| 541 | }), | ||
| 542 | } satisfies LoadedSourceRecipeToolIndexEntry; | ||
| 543 | } | ||
| 544 | |||
| 545 | return null; | ||
| 546 | }); | 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 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 6 | ||
| 3 | import { | 7 | import { |
| 4 | AccountIdSchema, | 8 | AccountIdSchema, |
| 5 | decodeBuiltInAuthArtifactConfig, | 9 | decodeBuiltInAuthArtifactConfig, |
| 6 | McpSourceAuthSessionDataJsonSchema, | 10 | McpSourceAuthSessionDataJsonSchema, |
| 7 | OrganizationIdSchema, | ||
| 8 | SecretMaterialIdSchema, | 11 | SecretMaterialIdSchema, |
| 9 | SourceAuthSessionIdSchema, | 12 | SourceAuthSessionIdSchema, |
| 10 | SourceIdSchema, | 13 | SourceIdSchema, |
| @@ -21,9 +24,16 @@ import { · 10 unmodified lines | |||
| 21 | type SqlControlPlanePersistence, | 24 | type SqlControlPlanePersistence, |
| 22 | } from "../persistence"; | 25 | } from "../persistence"; |
| 23 | import { | 26 | import { |
| 24 | stableSourceRecipeId, | 27 | loadLocalExecutorConfig, |
| 25 | stableSourceRecipeRevisionId, | 28 | resolveLocalWorkspaceContext, |
| 26 | } from "./source-definitions"; | 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 | import { persistSource, removeSourceById } from "./source-store"; | 37 | import { persistSource, removeSourceById } from "./source-store"; |
| 28 | 38 | ||
| 29 | const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | 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 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | 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 | const makeOpenApiSource = (input: { | 80 | const makeOpenApiSource = (input: { |
| 44 | workspaceId: Source["workspaceId"]; | 81 | workspaceId: Source["workspaceId"]; |
| 45 | sourceId: Source["id"]; | 82 | sourceId: Source["id"]; |
| @@ -78,28 +115,13 @@ describe("source-store", () => { · 32 unmodified lines | |||
| 78 | const persistence = yield* makePersistence; | 115 | const persistence = yield* makePersistence; |
| 79 | const now = Date.now(); | 116 | const now = Date.now(); |
| 80 | const accountId = AccountIdSchema.make("acc_source_store"); | 117 | const accountId = AccountIdSchema.make("acc_source_store"); |
| 81 | const organizationId = OrganizationIdSchema.make("org_source_store"); | ||
| 82 | const workspaceId = WorkspaceIdSchema.make("ws_source_store"); | 118 | const workspaceId = WorkspaceIdSchema.make("ws_source_store"); |
| 83 | const sourceId = SourceIdSchema.make("src_source_store"); | 119 | const sourceId = SourceIdSchema.make("github"); |
| 84 | const firstTokenId = SecretMaterialIdSchema.make("sec_source_store_first"); | 120 | const firstTokenId = SecretMaterialIdSchema.make("sec_source_store_first"); |
| 85 | const secondTokenId = SecretMaterialIdSchema.make("sec_source_store_second"); | 121 | const secondTokenId = SecretMaterialIdSchema.make("sec_source_store_second"); |
| 86 | 122 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({ | |
| 87 | yield* persistence.rows.organizations.insert({ | 123 | workspaceId, |
| 88 | id: organizationId, | 124 | accountId, |
| 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, | ||
| 103 | }); | 125 | }); |
| 104 | 126 | ||
| 105 | yield* persistence.rows.secretMaterials.upsert({ | 127 | yield* persistence.rows.secretMaterials.upsert({ |
| @@ -119,22 +141,25 @@ describe("source-store", () => { · 13 unmodified lines | |||
| 119 | updatedAt: now, | 141 | updatedAt: now, |
| 120 | }); | 142 | }); |
| 121 | 143 | ||
| 122 | yield* persistSource( | 144 | yield* withRuntimeLocalWorkspace( |
| 123 | persistence.rows, | 145 | persistSource( |
| 124 | makeOpenApiSource({ | 146 | persistence.rows, |
| 125 | workspaceId, | 147 | makeOpenApiSource({ |
| 126 | sourceId, | 148 | workspaceId, |
| 127 | now, | 149 | sourceId, |
| 128 | auth: { | 150 | now, |
| 129 | kind: "bearer", | 151 | auth: { |
| 130 | headerName: "Authorization", | 152 | kind: "bearer", |
| 131 | prefix: "Bearer ", | 153 | headerName: "Authorization", |
| 132 | token: { | 154 | prefix: "Bearer ", |
| 133 | providerId: "postgres", | 155 | token: { |
| 134 | handle: firstTokenId, | 156 | providerId: "postgres", |
| 157 | handle: firstTokenId, | ||
| 158 | }, | ||
| 135 | }, | 159 | }, |
| 136 | }, | 160 | }), |
| 137 | }), | 161 | ), |
| 162 | runtimeLocalWorkspace, | ||
| 138 | ); | 163 | ); |
| 139 | 164 | ||
| 140 | yield* persistence.rows.sourceAuthSessions.upsert({ | 165 | yield* persistence.rows.sourceAuthSessions.upsert({ |
| @@ -167,23 +192,26 @@ describe("source-store", () => { · 26 unmodified lines | |||
| 167 | updatedAt: now, | 192 | updatedAt: now, |
| 168 | }); | 193 | }); |
| 169 | 194 | ||
| 170 | yield* persistSource( | 195 | yield* withRuntimeLocalWorkspace( |
| 171 | persistence.rows, | 196 | persistSource( |
| 172 | makeOpenApiSource({ | 197 | persistence.rows, |
| 173 | workspaceId, | 198 | makeOpenApiSource({ |
| 174 | sourceId, | 199 | workspaceId, |
| 175 | now, | 200 | sourceId, |
| 176 | updatedAt: now + 1, | 201 | now, |
| 177 | auth: { | 202 | updatedAt: now + 1, |
| 178 | kind: "bearer", | 203 | auth: { |
| 179 | headerName: "Authorization", | 204 | kind: "bearer", |
| 180 | prefix: "Bearer ", | 205 | headerName: "Authorization", |
| 181 | token: { | 206 | prefix: "Bearer ", |
| 182 | providerId: "postgres", | 207 | token: { |
| 183 | handle: secondTokenId, | 208 | providerId: "postgres", |
| 209 | handle: secondTokenId, | ||
| 210 | }, | ||
| 184 | }, | 211 | }, |
| 185 | }, | 212 | }), |
| 186 | }), | 213 | ), |
| 214 | runtimeLocalWorkspace, | ||
| 187 | ); | 215 | ); |
| 188 | 216 | ||
| 189 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true); | 217 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true); |
| @@ -196,16 +224,22 @@ describe("source-store", () => { · 6 unmodified lines | |||
| 196 | : null, | 224 | : null, |
| 197 | ).toBe(secondTokenId); | 225 | ).toBe(secondTokenId); |
| 198 | 226 | ||
| 199 | const removed = yield* removeSourceById(persistence.rows, { | 227 | const removed = yield* withRuntimeLocalWorkspace( |
| 200 | workspaceId, | 228 | removeSourceById(persistence.rows, { |
| 201 | sourceId, | 229 | workspaceId, |
| 202 | }); | 230 | sourceId, |
| 231 | }), | ||
| 232 | runtimeLocalWorkspace, | ||
| 233 | ); | ||
| 203 | expect(removed).toBe(true); | 234 | expect(removed).toBe(true); |
| 204 | 235 | ||
| 205 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); | 236 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); |
| 206 | expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); | ||
| 207 | expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); | 237 | expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 208 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 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 | const persistence = yield* makePersistence; | 248 | const persistence = yield* makePersistence; |
| 215 | const now = Date.now(); | 249 | const now = Date.now(); |
| 216 | const accountId = AccountIdSchema.make("acc_actor_scoped"); | 250 | const accountId = AccountIdSchema.make("acc_actor_scoped"); |
| 217 | const organizationId = OrganizationIdSchema.make("org_actor_scoped"); | ||
| 218 | const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped"); | 251 | const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped"); |
| 219 | const sourceId = SourceIdSchema.make("src_actor_scoped"); | 252 | const sourceId = SourceIdSchema.make("github"); |
| 220 | const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared"); | 253 | const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared"); |
| 221 | 254 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({ | |
| 222 | yield* persistence.rows.organizations.insert({ | 255 | workspaceId, |
| 223 | id: organizationId, | 256 | accountId, |
| 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, | ||
| 238 | }); | 257 | }); |
| 239 | yield* persistence.rows.secretMaterials.upsert({ | 258 | yield* persistence.rows.secretMaterials.upsert({ |
| 240 | id: sharedTokenId, | 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); | 282 | yield* withRuntimeLocalWorkspace( |
| 264 | yield* persistSource( | 283 | persistSource(persistence.rows, source), |
| 265 | persistence.rows, | 284 | runtimeLocalWorkspace, |
| 266 | { | 285 | ); |
| 267 | ...source, | 286 | yield* withRuntimeLocalWorkspace( |
| 268 | updatedAt: now + 1, | 287 | persistSource( |
| 269 | }, | 288 | persistence.rows, |
| 270 | { | 289 | { |
| 271 | actorAccountId: accountId, | 290 | ...source, |
| 272 | }, | 291 | updatedAt: now + 1, |
| 292 | }, | ||
| 293 | { | ||
| 294 | actorAccountId: accountId, | ||
| 295 | }, | ||
| 296 | ), | ||
| 297 | runtimeLocalWorkspace, | ||
| 273 | ); | 298 | ); |
| 274 | 299 | ||
| 275 | const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({ | 300 | const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({ |
| @@ -282,264 +307,4 @@ describe("source-store", () => { · 6 unmodified lines | |||
| 282 | expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2); | 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 | AccountId, | 2 | AccountId, |
| 3 | AuthArtifact, | 3 | AuthArtifact, |
| 4 | CredentialSlot, | 4 | CredentialSlot, |
| 5 | LocalConfigSecretInput, | ||
| 6 | LocalConfigSource, | ||
| 5 | Source, | 7 | Source, |
| 6 | SourceRecipeId, | 8 | SourceId, |
| 7 | SourceRecipeRevisionId, | ||
| 8 | WorkspaceId, | 9 | WorkspaceId, |
| 9 | } from "#schema"; | 10 | } from "#schema"; |
| 11 | import { SourceIdSchema } from "#schema"; | ||
| 10 | import { type SqlControlPlaneRows } from "#persistence"; | 12 | import { type SqlControlPlaneRows } from "#persistence"; |
| 11 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 12 | import * as Option from "effect/Option"; | ||
| 13 | 14 | ||
| 14 | import { | 15 | import { |
| 15 | createSourceRecipeRecord, | ||
| 16 | createSourceRecipeRevisionRecord, | ||
| 17 | projectSourceFromStorage, | ||
| 18 | projectSourcesFromStorage, | ||
| 19 | stableSourceRecipeId, | 16 | stableSourceRecipeId, |
| 20 | stableSourceRecipeRevisionId, | 17 | stableSourceRecipeRevisionId, |
| 21 | splitSourceForStorage, | 18 | splitSourceForStorage, |
| 22 | } from "./source-definitions"; | 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 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; | 54 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; |
| 24 | import { authArtifactSecretMaterialRefs } from "./auth-artifacts"; | 55 | import { authArtifactSecretMaterialRefs } from "./auth-artifacts"; |
| 25 | import { removeAuthLeaseAndSecrets } from "./auth-leases"; | 56 | import { removeAuthLeaseAndSecrets } from "./auth-leases"; |
| 57 | import { getSourceAdapter } from "./source-adapters"; | ||
| 58 | import { slugify } from "./slug"; | ||
| 26 | 59 | ||
| 27 | const secretRefKey = (ref: { providerId: string; handle: string }): string => | 60 | const secretRefKey = (ref: { providerId: string; handle: string }): string => |
| 28 | `${ref.providerId}:${ref.handle}`; | 61 | `${ref.providerId}:${ref.handle}`; |
| @@ -79,78 +112,408 @@ const selectExactAuthArtifact = (input: { · 50 unmodified lines | |||
| 79 | && artifact.actorAccountId === (input.actorAccountId ?? null), | 112 | && artifact.actorAccountId === (input.actorAccountId ?? null), |
| 80 | ) ?? null; | 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 | export const loadSourcesInWorkspace = ( | 381 | export const loadSourcesInWorkspace = ( |
| 83 | rows: SqlControlPlaneRows, | 382 | rows: SqlControlPlaneRows, |
| 84 | workspaceId: WorkspaceId, | 383 | workspaceId: WorkspaceId, |
| 85 | options: { | 384 | options: { |
| 86 | actorAccountId?: AccountId | null; | 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 | Effect.gen(function* () { | 398 | Effect.gen(function* () { |
| 90 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); | 399 | const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId); |
| 91 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); | 400 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); |
| 92 | const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => { | 401 | return yield* Effect.forEach( |
| 93 | const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id); | 402 | Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}), |
| 94 | const preferred = selectPreferredAuthArtifact({ | 403 | (sourceId) => |
| 95 | authArtifacts: matches, | 404 | Effect.map( |
| 96 | actorAccountId: options.actorAccountId, | 405 | buildLocalSourceRecord({ |
| 97 | slot: "runtime", | 406 | context: localWorkspace.context, |
| 98 | }); | 407 | workspaceId, |
| 99 | const preferredImport = selectPreferredAuthArtifact({ | 408 | loadedConfig: localWorkspace.loadedConfig, |
| 100 | authArtifacts: matches, | 409 | workspaceState: localWorkspace.workspaceState, |
| 101 | actorAccountId: options.actorAccountId, | 410 | sourceId: SourceIdSchema.make(sourceId), |
| 102 | slot: "import", | 411 | actorAccountId: options.actorAccountId, |
| 103 | }); | 412 | authArtifacts, |
| 104 | return [preferred, preferredImport].filter( | 413 | }), |
| 105 | (artifact): artifact is AuthArtifact => artifact !== null, | 414 | ({ source }) => source, |
| 106 | ); | 415 | ), |
| 107 | }); | 416 | ); |
| 108 | |||
| 109 | return yield* projectSourcesFromStorage({ | ||
| 110 | sourceRecords, | ||
| 111 | authArtifacts: filteredAuthArtifacts, | ||
| 112 | }); | ||
| 113 | }); | 417 | }); |
| 114 | 418 | ||
| 115 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { | 419 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { |
| 116 | workspaceId: WorkspaceId; | 420 | workspaceId: WorkspaceId; |
| 117 | sourceId: Source["id"]; | 421 | sourceId: Source["id"]; |
| 118 | actorAccountId?: AccountId | null; | 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 | Effect.gen(function* () { | 434 | Effect.gen(function* () { |
| 121 | const sourceRecord = yield* rows.sources.getByWorkspaceAndId( | 435 | const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId); |
| 122 | input.workspaceId, | 436 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId); |
| 123 | input.sourceId, | 437 | if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) { |
| 124 | ); | ||
| 125 | |||
| 126 | if (Option.isNone(sourceRecord)) { | ||
| 127 | return yield* Effect.fail( | 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 | workspaceId: input.workspaceId, | 448 | workspaceId: input.workspaceId, |
| 449 | loadedConfig: localWorkspace.loadedConfig, | ||
| 450 | workspaceState: localWorkspace.workspaceState, | ||
| 134 | sourceId: input.sourceId, | 451 | sourceId: input.sourceId, |
| 135 | }); | ||
| 136 | const authArtifact = selectPreferredAuthArtifact({ | ||
| 137 | authArtifacts, | ||
| 138 | actorAccountId: input.actorAccountId, | 452 | actorAccountId: input.actorAccountId, |
| 139 | slot: "runtime", | ||
| 140 | }); | ||
| 141 | const importAuthArtifact = selectPreferredAuthArtifact({ | ||
| 142 | authArtifacts, | 453 | authArtifacts, |
| 143 | actorAccountId: input.actorAccountId, | ||
| 144 | slot: "import", | ||
| 145 | }); | 454 | }); |
| 146 | 455 | ||
| 147 | return yield* projectSourceFromStorage({ | 456 | return localSource.source; |
| 148 | sourceRecord: sourceRecord.value, | 457 | }); |
| 149 | runtimeAuthArtifact: authArtifact, | 458 | |
| 150 | importAuthArtifact, | 459 | const configSourceFromLocalSource = (input: { |
| 151 | }); | 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 | const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { | 517 | const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { |
| 155 | workspaceId: WorkspaceId; | 518 | workspaceId: WorkspaceId; |
| 156 | sourceId: Source["id"]; | 519 | sourceId: Source["id"]; |
| @@ -188,50 +551,46 @@ const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { · 31 unmodified lines | |||
| 188 | return existingAuthArtifacts.length; | 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 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { | 554 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { |
| 226 | workspaceId: WorkspaceId; | 555 | workspaceId: WorkspaceId; |
| 227 | sourceId: Source["id"]; | 556 | sourceId: Source["id"]; |
| 228 | }) => | 557 | }) => |
| 229 | Effect.gen(function* () { | 558 | Effect.gen(function* () { |
| 230 | const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId); | 559 | const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId); |
| 231 | if (Option.isNone(sourceRecord)) { | 560 | if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) { |
| 232 | return false; | 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 | yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( | 594 | yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( |
| 236 | input.workspaceId, | 595 | input.workspaceId, |
| 237 | input.sourceId, | 596 | input.sourceId, |
| @@ -241,15 +600,6 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 3 unmodified lines | |||
| 241 | sourceId: input.sourceId, | 600 | sourceId: input.sourceId, |
| 242 | }); | 601 | }); |
| 243 | yield* removeAuthArtifactsForSource(rows, input); | 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 | return true; | 604 | return true; |
| 255 | }); | 605 | }); |
| @@ -262,10 +612,21 @@ export const persistSource = ( · 6 unmodified lines | |||
| 262 | } = {}, | 612 | } = {}, |
| 263 | ) => | 613 | ) => |
| 264 | Effect.gen(function* () { | 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 | const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ | 627 | const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 267 | workspaceId: source.workspaceId, | 628 | workspaceId: nextSource.workspaceId, |
| 268 | sourceId: source.id, | 629 | sourceId: nextSource.id, |
| 269 | }); | 630 | }); |
| 270 | const existingRuntimeAuthArtifact = selectExactAuthArtifact({ | 631 | const existingRuntimeAuthArtifact = selectExactAuthArtifact({ |
| 271 | authArtifacts: existingAuthArtifacts, | 632 | authArtifacts: existingAuthArtifacts, |
| @@ -277,73 +638,33 @@ export const persistSource = ( · 5 unmodified lines | |||
| 277 | actorAccountId: options.actorAccountId, | 638 | actorAccountId: options.actorAccountId, |
| 278 | slot: "import", | 639 | slot: "import", |
| 279 | }); | 640 | }); |
| 280 | 641 | const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {}); | |
| 281 | const nextRecipeId = stableSourceRecipeId(source); | 642 | const sources = { |
| 282 | const nextRecipeRevisionId = Option.isSome(existing) && existing.value.recipeId === nextRecipeId | 643 | ...(projectConfig.sources ?? {}), |
| 283 | ? existing.value.recipeRevisionId | 644 | }; |
| 284 | : stableSourceRecipeRevisionId(source); | 645 | const existingConfigSource = sources[nextSource.id]; |
| 285 | const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); | 646 | sources[nextSource.id] = configSourceFromLocalSource({ |
| 286 | const nextRevision = createSourceRecipeRevisionRecord({ | 647 | source: nextSource, |
| 287 | source, | 648 | existingConfigAuth: existingConfigSource?.connection.auth, |
| 288 | recipeId: nextRecipeId, | 649 | config: localWorkspace.loadedConfig.config, |
| 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, | ||
| 302 | }); | 650 | }); |
| 303 | 651 | yield* writeProjectLocalExecutorConfig({ | |
| 304 | const nextRecipe = createSourceRecipeRecord({ | 652 | context: localWorkspace.context, |
| 305 | source, | 653 | config: { |
| 306 | recipeId: nextRecipeId, | 654 | ...projectConfig, |
| 307 | latestRevisionId: nextRevision.id, | 655 | sources, |
| 656 | }, | ||
| 308 | }); | 657 | }); |
| 309 | 658 | ||
| 310 | const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ | 659 | const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ |
| 311 | source, | 660 | source: nextSource, |
| 312 | recipeId: nextRecipe.id, | 661 | recipeId: stableSourceRecipeId(nextSource), |
| 313 | recipeRevisionId: nextRevision.id, | 662 | recipeRevisionId: stableSourceRecipeRevisionId(nextSource), |
| 314 | actorAccountId: options.actorAccountId, | 663 | actorAccountId: options.actorAccountId, |
| 315 | existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null, | 664 | existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null, |
| 316 | existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null, | 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 | if (runtimeAuthArtifact === null) { | 668 | if (runtimeAuthArtifact === null) { |
| 348 | if (existingRuntimeAuthArtifact !== null) { | 669 | if (existingRuntimeAuthArtifact !== null) { |
| 349 | yield* removeAuthLeaseAndSecrets(rows, { | 670 | yield* removeAuthLeaseAndSecrets(rows, { |
| @@ -351,8 +672,8 @@ export const persistSource = ( · 1 unmodified line | |||
| 351 | }); | 672 | }); |
| 352 | } | 673 | } |
| 353 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | 674 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ |
| 354 | workspaceId: source.workspaceId, | 675 | workspaceId: nextSource.workspaceId, |
| 355 | sourceId: source.id, | 676 | sourceId: nextSource.id, |
| 356 | actorAccountId: options.actorAccountId ?? null, | 677 | actorAccountId: options.actorAccountId ?? null, |
| 357 | slot: "runtime", | 678 | slot: "runtime", |
| 358 | }); | 679 | }); |
| @@ -380,8 +701,8 @@ export const persistSource = ( · 21 unmodified lines | |||
| 380 | }); | 701 | }); |
| 381 | } | 702 | } |
| 382 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | 703 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ |
| 383 | workspaceId: source.workspaceId, | 704 | workspaceId: nextSource.workspaceId, |
| 384 | sourceId: source.id, | 705 | sourceId: nextSource.id, |
| 385 | actorAccountId: options.actorAccountId ?? null, | 706 | actorAccountId: options.actorAccountId ?? null, |
| 386 | slot: "import", | 707 | slot: "import", |
| 387 | }); | 708 | }); |
| @@ -402,5 +723,28 @@ export const persistSource = ( · 14 unmodified lines | |||
| 402 | next: importAuthArtifact, | 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 | CreateSourcePayload, | 2 | CreateSourcePayload, |
| 3 | UpdateSourcePayload, | 3 | UpdateSourcePayload, |
| 4 | } from "../api/sources/api"; | 4 | } from "../api/sources/api"; |
| 5 | import { Actor } from "#domain"; | ||
| 6 | import { | 5 | import { |
| 7 | type AccountId, | 6 | type AccountId, |
| 8 | SourceIdSchema, | 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 | Effect.flatMap(ControlPlaneStore, (store) => | 143 | Effect.flatMap(ControlPlaneStore, (store) => |
| 142 | Effect.flatMap(Actor, (actor) => | 144 | Effect.gen(function* () { |
| 143 | loadSourcesInWorkspace(store, workspaceId, { | 145 | return yield* loadSourcesInWorkspace(store, input.workspaceId, { |
| 144 | actorAccountId: actor.principal.accountId, | 146 | actorAccountId: input.accountId, |
| 145 | }).pipe( | 147 | }).pipe( |
| 146 | Effect.mapError((error) => | 148 | Effect.mapError((error) => |
| 147 | sourceOps.list.unknownStorage( | 149 | sourceOps.list.unknownStorage( |
| @@ -149,58 +151,60 @@ export const listSources = (workspaceId: WorkspaceId) => · 1 unmodified line | |||
| 149 | "Failed projecting stored sources", | 151 | "Failed projecting stored sources", |
| 150 | ), | 152 | ), |
| 151 | ), | 153 | ), |
| 152 | ), | 154 | ); |
| 153 | )); | 155 | })); |
| 154 | 156 | ||
| 155 | export const createSource = (input: { | 157 | export const createSource = (input: { |
| 156 | workspaceId: WorkspaceId; | 158 | workspaceId: WorkspaceId; |
| 159 | accountId: AccountId; | ||
| 157 | payload: CreateSourcePayload; | 160 | payload: CreateSourcePayload; |
| 158 | }) => | 161 | }) => |
| 159 | Effect.flatMap(ControlPlaneStore, (store) => | 162 | Effect.flatMap(ControlPlaneStore, (store) => |
| 160 | Effect.flatMap(Actor, (actor) => | 163 | Effect.gen(function* () { |
| 161 | Effect.gen(function* () { | 164 | const now = Date.now(); |
| 162 | const now = Date.now(); | ||
| 163 | 165 | ||
| 164 | const source = yield* createSourceFromPayload({ | 166 | const source = yield* createSourceFromPayload({ |
| 165 | workspaceId: input.workspaceId, | 167 | workspaceId: input.workspaceId, |
| 166 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | 168 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), |
| 167 | payload: input.payload, | 169 | payload: input.payload, |
| 168 | now, | 170 | now, |
| 169 | }).pipe( | 171 | }).pipe( |
| 170 | Effect.mapError((cause) => | 172 | Effect.mapError((cause) => |
| 171 | sourceOps.create.badRequest( | 173 | sourceOps.create.badRequest( |
| 172 | "Invalid source definition", | 174 | "Invalid source definition", |
| 173 | cause instanceof Error ? cause.message : String(cause), | 175 | cause instanceof Error ? cause.message : String(cause), |
| 174 | ), | ||
| 175 | ), | 176 | ), |
| 176 | ); | 177 | ), |
| 178 | ); | ||
| 177 | 179 | ||
| 178 | yield* mapPersistenceError( | 180 | const persistedSource = yield* mapPersistenceError( |
| 179 | sourceOps.create.child("persist"), | 181 | sourceOps.create.child("persist"), |
| 180 | persistSource(store, source, { | 182 | persistSource(store, source, { |
| 181 | actorAccountId: actor.principal.accountId, | 183 | actorAccountId: input.accountId, |
| 182 | }), | 184 | }), |
| 183 | ); | 185 | ); |
| 184 | 186 | ||
| 185 | return yield* syncArtifactsForSource({ | 187 | const synchronizedSource = yield* syncArtifactsForSource({ |
| 186 | store, | 188 | store, |
| 187 | source, | 189 | source: persistedSource, |
| 188 | actorAccountId: actor.principal.accountId, | 190 | actorAccountId: input.accountId, |
| 189 | operation: sourceOps.create, | 191 | operation: sourceOps.create, |
| 190 | }); | 192 | }); |
| 191 | }), | 193 | |
| 192 | )); | 194 | return synchronizedSource; |
| 195 | })); | ||
| 193 | 196 | ||
| 194 | export const getSource = (input: { | 197 | export const getSource = (input: { |
| 195 | workspaceId: WorkspaceId; | 198 | workspaceId: WorkspaceId; |
| 196 | sourceId: SourceId; | 199 | sourceId: SourceId; |
| 200 | accountId: AccountId; | ||
| 197 | }) => | 201 | }) => |
| 198 | Effect.flatMap(ControlPlaneStore, (store) => | 202 | Effect.flatMap(ControlPlaneStore, (store) => |
| 199 | Effect.flatMap(Actor, (actor) => | 203 | Effect.gen(function* () { |
| 200 | loadSourceById(store, { | 204 | return yield* loadSourceById(store, { |
| 201 | workspaceId: input.workspaceId, | 205 | workspaceId: input.workspaceId, |
| 202 | sourceId: input.sourceId, | 206 | sourceId: input.sourceId, |
| 203 | actorAccountId: actor.principal.accountId, | 207 | actorAccountId: input.accountId, |
| 204 | }).pipe( | 208 | }).pipe( |
| 205 | Effect.mapError((cause) => | 209 | Effect.mapError((cause) => |
| 206 | cause instanceof Error && cause.message.startsWith("Source not found:") | 210 | cause instanceof Error && cause.message.startsWith("Source not found:") |
| @@ -213,63 +217,64 @@ export const getSource = (input: { · 6 unmodified lines | |||
| 213 | "Failed projecting stored source", | 217 | "Failed projecting stored source", |
| 214 | ), | 218 | ), |
| 215 | ), | 219 | ), |
| 216 | ), | 220 | ); |
| 217 | )); | 221 | })); |
| 218 | 222 | ||
| 219 | export const updateSource = (input: { | 223 | export const updateSource = (input: { |
| 220 | workspaceId: WorkspaceId; | 224 | workspaceId: WorkspaceId; |
| 221 | sourceId: SourceId; | 225 | sourceId: SourceId; |
| 226 | accountId: AccountId; | ||
| 222 | payload: UpdateSourcePayload; | 227 | payload: UpdateSourcePayload; |
| 223 | }) => | 228 | }) => |
| 224 | Effect.flatMap(ControlPlaneStore, (store) => | 229 | Effect.flatMap(ControlPlaneStore, (store) => |
| 225 | Effect.flatMap(Actor, (actor) => | 230 | Effect.gen(function* () { |
| 226 | Effect.gen(function* () { | 231 | const existingSource = yield* loadSourceById(store, { |
| 227 | const existingSource = yield* loadSourceById(store, { | 232 | workspaceId: input.workspaceId, |
| 228 | workspaceId: input.workspaceId, | 233 | sourceId: input.sourceId, |
| 229 | sourceId: input.sourceId, | 234 | actorAccountId: input.accountId, |
| 230 | actorAccountId: actor.principal.accountId, | 235 | }).pipe( |
| 231 | }).pipe( | 236 | Effect.mapError((cause) => |
| 232 | Effect.mapError((cause) => | 237 | cause instanceof Error && cause.message.startsWith("Source not found:") |
| 233 | cause instanceof Error && cause.message.startsWith("Source not found:") | 238 | ? sourceOps.update.notFound( |
| 234 | ? sourceOps.update.notFound( | 239 | "Source not found", |
| 235 | "Source not found", | 240 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, |
| 236 | `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | 241 | ) |
| 237 | ) | 242 | : sourceOps.update.unknownStorage( |
| 238 | : sourceOps.update.unknownStorage( | 243 | cause, |
| 239 | cause, | 244 | "Failed projecting stored source", |
| 240 | "Failed projecting stored source", | 245 | ), |
| 241 | ), | 246 | ), |
| 242 | ), | 247 | ); |
| 243 | ); | ||
| 244 | 248 | ||
| 245 | const updatedSource = yield* updateSourceFromPayload({ | 249 | const updatedSource = yield* updateSourceFromPayload({ |
| 246 | source: existingSource, | 250 | source: existingSource, |
| 247 | payload: input.payload, | 251 | payload: input.payload, |
| 248 | now: Date.now(), | 252 | now: Date.now(), |
| 249 | }).pipe( | 253 | }).pipe( |
| 250 | Effect.mapError((cause) => | 254 | Effect.mapError((cause) => |
| 251 | sourceOps.update.badRequest( | 255 | sourceOps.update.badRequest( |
| 252 | "Invalid source definition", | 256 | "Invalid source definition", |
| 253 | cause instanceof Error ? cause.message : String(cause), | 257 | cause instanceof Error ? cause.message : String(cause), |
| 254 | ), | ||
| 255 | ), | 258 | ), |
| 256 | ); | 259 | ), |
| 260 | ); | ||
| 257 | 261 | ||
| 258 | yield* mapPersistenceError( | 262 | const persistedSource = yield* mapPersistenceError( |
| 259 | sourceOps.update.child("persist"), | 263 | sourceOps.update.child("persist"), |
| 260 | persistSource(store, updatedSource, { | 264 | persistSource(store, updatedSource, { |
| 261 | actorAccountId: actor.principal.accountId, | 265 | actorAccountId: input.accountId, |
| 262 | }), | 266 | }), |
| 263 | ); | 267 | ); |
| 264 | 268 | ||
| 265 | return yield* syncArtifactsForSource({ | 269 | const synchronizedSource = yield* syncArtifactsForSource({ |
| 266 | store, | 270 | store, |
| 267 | source: updatedSource, | 271 | source: persistedSource, |
| 268 | actorAccountId: actor.principal.accountId, | 272 | actorAccountId: input.accountId, |
| 269 | operation: sourceOps.update, | 273 | operation: sourceOps.update, |
| 270 | }); | 274 | }); |
| 271 | }), | 275 | |
| 272 | )); | 276 | return synchronizedSource; |
| 277 | })); | ||
| 273 | 278 | ||
| 274 | export const removeSource = (input: { | 279 | export const removeSource = (input: { |
| 275 | workspaceId: WorkspaceId; | 280 | workspaceId: WorkspaceId; |
| @@ -1,8 +1,6 @@ | |||
| 1 | import { | 1 | import { |
| 2 | HttpApiBuilder, | 2 | HttpApiBuilder, |
| 3 | HttpApiClient, | 3 | HttpApiClient, |
| 4 | HttpClient, | ||
| 5 | HttpClientRequest, | ||
| 6 | } from "@effect/platform"; | 4 | } from "@effect/platform"; |
| 7 | import { NodeHttpServer } from "@effect/platform-node"; | 5 | import { NodeHttpServer } from "@effect/platform-node"; |
| 8 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| @@ -14,11 +12,10 @@ import { · 5 unmodified lines | |||
| 14 | } from "#api"; | 12 | } from "#api"; |
| 15 | 13 | ||
| 16 | import { | 14 | import { |
| 17 | ControlPlaneAuthHeaders, | 15 | type ControlPlaneRuntime, |
| 18 | type SqlControlPlaneRuntime, | ||
| 19 | } from "./index"; | 16 | } from "./index"; |
| 20 | 17 | ||
| 21 | const createClientLayer = (runtime: SqlControlPlaneRuntime) => { | 18 | const createClientLayer = (runtime: ControlPlaneRuntime) => { |
| 22 | const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer); | 19 | const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer); |
| 23 | 20 | ||
| 24 | return HttpApiBuilder.serve().pipe( | 21 | return HttpApiBuilder.serve().pipe( |
| @@ -28,19 +25,9 @@ const createClientLayer = (runtime: SqlControlPlaneRuntime) => { · 3 unmodified lines | |||
| 28 | }; | 25 | }; |
| 29 | 26 | ||
| 30 | const createControlPlaneClient = (accountId?: string) => | 27 | const createControlPlaneClient = (accountId?: string) => |
| 28 | (void accountId, | ||
| 31 | HttpApiClient.make(ControlPlaneApi, { | 29 | HttpApiClient.make(ControlPlaneApi, { |
| 32 | transformClient: accountId | 30 | })); |
| 33 | ? (client) => | ||
| 34 | client.pipe( | ||
| 35 | HttpClient.mapRequest( | ||
| 36 | HttpClientRequest.setHeader( | ||
| 37 | ControlPlaneAuthHeaders.accountId, | ||
| 38 | accountId, | ||
| 39 | ), | ||
| 40 | ), | ||
| 41 | ) | ||
| 42 | : undefined, | ||
| 43 | }); | ||
| 44 | 31 | ||
| 45 | type ControlPlaneClient = Effect.Effect.Success< | 32 | type ControlPlaneClient = Effect.Effect.Success< |
| 46 | ReturnType<typeof createControlPlaneClient> | 33 | ReturnType<typeof createControlPlaneClient> |
| @@ -48,7 +35,7 @@ type ControlPlaneClient = Effect.Effect.Success< · 1 unmodified line | |||
| 48 | 35 | ||
| 49 | export const withControlPlaneClient = <A, E>( | 36 | export const withControlPlaneClient = <A, E>( |
| 50 | input: { | 37 | input: { |
| 51 | runtime: SqlControlPlaneRuntime; | 38 | runtime: ControlPlaneRuntime; |
| 52 | accountId?: string; | 39 | accountId?: string; |
| 53 | }, | 40 | }, |
| 54 | f: (client: ControlPlaneClient) => Effect.Effect<A, E, never>, | 41 | f: (client: ControlPlaneClient) => Effect.Effect<A, E, never>, |
| @@ -1,5 +1,8 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { createServer } from "node:http"; | 3 | import { createServer } from "node:http"; |
| 4 | import { tmpdir } from "node:os"; | ||
| 5 | import { join } from "node:path"; | ||
| 3 | 6 | ||
| 4 | import { | 7 | import { |
| 5 | HttpApi, | 8 | HttpApi, |
| @@ -45,7 +48,6 @@ import * as Effect from "effect/Effect"; · 39 unmodified lines | |||
| 45 | import * as Exit from "effect/Exit"; | 48 | import * as Exit from "effect/Exit"; |
| 46 | import * as Layer from "effect/Layer"; | 49 | import * as Layer from "effect/Layer"; |
| 47 | import * as Scope from "effect/Scope"; | 50 | import * as Scope from "effect/Scope"; |
| 48 | import * as Option from "effect/Option"; | ||
| 49 | import { Schema } from "effect"; | 51 | import { Schema } from "effect"; |
| 50 | import * as z from "zod/v4"; | 52 | import * as z from "zod/v4"; |
| 51 | 53 | ||
| @@ -59,9 +61,28 @@ import { · 7 unmodified lines | |||
| 59 | createRuntimeSourceAuthService, | 61 | createRuntimeSourceAuthService, |
| 60 | type RuntimeSourceAuthService, | 62 | type RuntimeSourceAuthService, |
| 61 | } from "./source-auth-service"; | 63 | } from "./source-auth-service"; |
| 64 | import { | ||
| 65 | loadLocalExecutorConfig, | ||
| 66 | resolveLocalWorkspaceContext, | ||
| 67 | writeProjectLocalExecutorConfig, | ||
| 68 | } from "./local-config"; | ||
| 62 | import { createLiveExecutionManager } from "./live-execution"; | 69 | import { createLiveExecutionManager } from "./live-execution"; |
| 63 | import { persistSource } from "./source-store"; | 70 | import { |
| 64 | import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization"; | 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 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; | 86 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 66 | 87 | ||
| 67 | type CountedMcpServer = { | 88 | type CountedMcpServer = { |
| @@ -579,7 +600,7 @@ const makeGraphqlServer = Effect.acquireRelease( · 511 unmodified lines | |||
| 579 | ); | 600 | ); |
| 580 | 601 | ||
| 581 | const persistConnectedEchoTool = (input: { | 602 | const persistConnectedEchoTool = (input: { |
| 582 | persistence: SqlControlPlanePersistence; | 603 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState; |
| 583 | endpoint: string; | 604 | endpoint: string; |
| 584 | workspaceId: WorkspaceId; | 605 | workspaceId: WorkspaceId; |
| 585 | sourceId: SourceId; | 606 | sourceId: SourceId; |
| @@ -610,32 +631,70 @@ const persistConnectedEchoTool = (input: { · 24 unmodified lines | |||
| 610 | updatedAt: now, | 631 | updatedAt: now, |
| 611 | }; | 632 | }; |
| 612 | 633 | ||
| 613 | yield* persistSource(input.persistence.rows, source); | 634 | yield* writeProjectLocalExecutorConfig({ |
| 614 | yield* persistMcpRecipeMaterializationFromManifest({ | 635 | context: input.runtimeLocalWorkspace.context, |
| 615 | rows: input.persistence.rows, | 636 | config: { |
| 616 | source, | 637 | sources: { |
| 617 | manifestEntries: [ | 638 | [input.sourceId]: { |
| 618 | { | 639 | kind: "mcp", |
| 619 | toolId: "echo", | 640 | name: source.name, |
| 620 | toolName: "echo", | 641 | ...(source.namespace ? { namespace: source.namespace } : {}), |
| 621 | description: "Echo the provided string", | 642 | connection: { |
| 622 | inputSchemaJson: JSON.stringify({ | 643 | endpoint: source.endpoint, |
| 623 | type: "object", | ||
| 624 | properties: { | ||
| 625 | value: { | ||
| 626 | type: "string", | ||
| 627 | }, | ||
| 628 | }, | 644 | }, |
| 629 | required: ["value"], | 645 | binding: { |
| 630 | additionalProperties: false, | 646 | transport: "streamable-http", |
| 631 | }), | 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 | const persistConnectedGithubOpenApiSource = (input: { | 696 | const persistConnectedGithubOpenApiSource = (input: { |
| 638 | persistence: SqlControlPlanePersistence; | 697 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState; |
| 639 | workspaceId: WorkspaceId; | 698 | workspaceId: WorkspaceId; |
| 640 | sourceId: SourceId; | 699 | sourceId: SourceId; |
| 641 | endpoint?: string; | 700 | endpoint?: string; |
| @@ -831,7 +890,22 @@ const persistConnectedGithubOpenApiSource = (input: { · 189 unmodified lines | |||
| 831 | updatedAt: now, | 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 | const manifest = yield* extractOpenApiManifest( | 910 | const manifest = yield* extractOpenApiManifest( |
| 837 | source.name, | 911 | source.name, |
| @@ -842,131 +916,165 @@ const persistConnectedGithubOpenApiSource = (input: { · 4 unmodified lines | |||
| 842 | ), | 916 | ), |
| 843 | ); | 917 | ); |
| 844 | const definitions = compileOpenApiToolDefinitions(manifest); | 918 | const definitions = compileOpenApiToolDefinitions(manifest); |
| 845 | const sourceRecord = | 919 | yield* writeProjectLocalExecutorConfig({ |
| 846 | yield* input.persistence.rows.sources.getByWorkspaceAndId( | 920 | context: input.runtimeLocalWorkspace.context, |
| 847 | input.workspaceId, | 921 | config: { |
| 848 | input.sourceId, | 922 | sources: { |
| 849 | ); | 923 | [input.sourceId]: { |
| 850 | if (Option.isNone(sourceRecord)) { | 924 | kind: "openapi", |
| 851 | return yield* Effect.fail( | 925 | name: source.name, |
| 852 | new Error(`Missing stored source ${input.sourceId}`), | 926 | ...(source.namespace ? { namespace: source.namespace } : {}), |
| 853 | ); | 927 | connection: { |
| 854 | } | 928 | endpoint: source.endpoint, |
| 855 | 929 | ...(configAuth !== undefined ? { auth: configAuth } : {}), | |
| 856 | yield* input.persistence.rows.sourceRecipeRevisions.update( | 930 | }, |
| 857 | sourceRecord.value.recipeRevisionId, | 931 | binding: { |
| 858 | { | 932 | specUrl: String(source.binding.specUrl), |
| 859 | manifestJson: JSON.stringify(manifest), | 933 | defaultHeaders: null, |
| 860 | manifestHash: manifest.sourceHash, | 934 | }, |
| 861 | updatedAt: now, | 935 | }, |
| 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, | ||
| 880 | }, | 936 | }, |
| 881 | ], | 937 | }, |
| 882 | }); | 938 | }); |
| 883 | const refEntries = Object.entries(manifest.refHintTable ?? {}); | 939 | const refEntries = Object.entries(manifest.refHintTable ?? {}); |
| 884 | yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | 940 | yield* writeLocalSourceArtifact({ |
| 885 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | 941 | context: input.runtimeLocalWorkspace.context, |
| 886 | bundles: | 942 | sourceId: source.id, |
| 887 | refEntries.length > 0 | 943 | artifact: buildLocalSourceArtifact({ |
| 888 | ? [ | 944 | source: { |
| 889 | { | 945 | ...source, |
| 890 | id: SourceRecipeSchemaBundleIdSchema.make( | 946 | sourceHash: manifest.sourceHash, |
| 891 | `src_recipe_bundle_${randomUUID()}`, | 947 | }, |
| 892 | ), | 948 | materialization: { |
| 893 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | 949 | manifestJson: JSON.stringify(manifest), |
| 894 | bundleKind: "json_schema_ref_map", | 950 | manifestHash: manifest.sourceHash, |
| 895 | refsJson: JSON.stringify( | 951 | sourceHash: manifest.sourceHash, |
| 896 | Object.fromEntries( | 952 | documents: [ |
| 897 | refEntries.map(([ref, rawValue]) => { | 953 | { |
| 898 | try { | 954 | id: `src_recipe_doc_${randomUUID()}`, |
| 899 | return [ref, JSON.parse(rawValue) as unknown]; | 955 | recipeRevisionId: "src_recipe_rev_materialization" as never, |
| 900 | } catch { | 956 | documentKind: "openapi", |
| 901 | return [ref, rawValue]; | 957 | documentKey: |
| 902 | } | 958 | typeof source.binding.specUrl === "string" |
| 903 | }), | 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 | ), | 1026 | inputSchemaJson: presentation.inputSchemaJson ?? null, |
| 906 | contentHash: manifest.sourceHash, | 1027 | outputSchemaJson: presentation.outputSchemaJson ?? null, |
| 907 | createdAt: now, | 1028 | providerKind: "openapi", |
| 908 | updatedAt: now, | 1029 | providerDataJson: presentation.providerDataJson, |
| 909 | }, | 1030 | createdAt: now, |
| 910 | ] | 1031 | updatedAt: now, |
| 911 | : [], | 1032 | }; |
| 912 | }); | 1033 | }), |
| 913 | yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({ | 1034 | }, |
| 914 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | 1035 | }), |
| 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 | }), | ||
| 955 | }); | 1036 | }); |
| 956 | yield* input.persistence.rows.sources.update( | 1037 | yield* writeLocalWorkspaceState({ |
| 957 | input.workspaceId, | 1038 | context: input.runtimeLocalWorkspace.context, |
| 958 | input.sourceId, | 1039 | state: { |
| 959 | { | 1040 | version: 1, |
| 960 | sourceHash: manifest.sourceHash, | 1041 | sources: { |
| 961 | updatedAt: now, | 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 | return; | 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) => | 1072 | const makeResolver = ( |
| 969 | createWorkspaceExecutionEnvironmentResolver({ | 1073 | persistence: SqlControlPlanePersistence, |
| 1074 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | ||
| 1075 | ) => | ||
| 1076 | ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) => | ||
| 1077 | createWorkspaceExecutionEnvironmentResolver({ | ||
| 970 | rows: persistence.rows, | 1078 | rows: persistence.rows, |
| 971 | sourceAuthService: { | 1079 | sourceAuthService: { |
| 972 | getLocalServerBaseUrl: () => null, | 1080 | getLocalServerBaseUrl: () => null, |
| @@ -983,19 +1091,25 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 10 unmodified lines | |||
| 983 | completeSourceCredentialSetup: () => | 1091 | completeSourceCredentialSetup: () => |
| 984 | Effect.fail(new Error("not implemented in test")), | 1092 | Effect.fail(new Error("not implemented in test")), |
| 985 | } as RuntimeSourceAuthService, | 1093 | } as RuntimeSourceAuthService, |
| 986 | }); | 1094 | })(input).pipe( |
| 1095 | Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | ||
| 1096 | )); | ||
| 987 | 1097 | ||
| 988 | const makeResolverWithLiveSourceAuth = ( | 1098 | const makeResolverWithLiveSourceAuth = ( |
| 989 | persistence: SqlControlPlanePersistence, | 1099 | persistence: SqlControlPlanePersistence, |
| 1100 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | ||
| 990 | ) => | 1101 | ) => |
| 991 | createWorkspaceExecutionEnvironmentResolver({ | 1102 | ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) => |
| 1103 | createWorkspaceExecutionEnvironmentResolver({ | ||
| 992 | rows: persistence.rows, | 1104 | rows: persistence.rows, |
| 993 | sourceAuthService: createRuntimeSourceAuthService({ | 1105 | sourceAuthService: createRuntimeSourceAuthService({ |
| 994 | rows: persistence.rows, | 1106 | rows: persistence.rows, |
| 995 | liveExecutionManager: createLiveExecutionManager(), | 1107 | liveExecutionManager: createLiveExecutionManager(), |
| 996 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", | 1108 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", |
| 997 | }), | 1109 | }), |
| 998 | }); | 1110 | })(input).pipe( |
| 1111 | Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | ||
| 1112 | )); | ||
| 999 | 1113 | ||
| 1000 | describe("workspace-execution-environment", () => { | 1114 | describe("workspace-execution-environment", () => { |
| 1001 | it.scoped("discovers persisted tools without live MCP listing", () => | 1115 | it.scoped("discovers persisted tools without live MCP listing", () => |
| @@ -1004,8 +1118,9 @@ describe("workspace-execution-environment", () => { · 2 unmodified lines | |||
| 1004 | const persistence = yield* makePersistence; | 1118 | const persistence = yield* makePersistence; |
| 1005 | const workspaceId = WorkspaceIdSchema.make("ws_catalog"); | 1119 | const workspaceId = WorkspaceIdSchema.make("ws_catalog"); |
| 1006 | const sourceId = SourceIdSchema.make("src_catalog"); | 1120 | const sourceId = SourceIdSchema.make("src_catalog"); |
| 1121 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | ||
| 1007 | yield* persistConnectedEchoTool({ | 1122 | yield* persistConnectedEchoTool({ |
| 1008 | persistence, | 1123 | runtimeLocalWorkspace, |
| 1009 | endpoint: server.endpoint, | 1124 | endpoint: server.endpoint, |
| 1010 | workspaceId, | 1125 | workspaceId, |
| 1011 | sourceId, | 1126 | sourceId, |
| @@ -1017,7 +1132,7 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines | |||
| 1017 | cause instanceof Error ? cause : new Error(String(cause)), | 1132 | cause instanceof Error ? cause : new Error(String(cause)), |
| 1018 | }).pipe(Effect.orDie); | 1133 | }).pipe(Effect.orDie); |
| 1019 | 1134 | ||
| 1020 | const resolveEnvironment = makeResolver(persistence); | 1135 | const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); |
| 1021 | const environment = yield* resolveEnvironment({ | 1136 | const environment = yield* resolveEnvironment({ |
| 1022 | workspaceId, | 1137 | workspaceId, |
| 1023 | accountId: AccountIdSchema.make("acc_catalog"), | 1138 | accountId: AccountIdSchema.make("acc_catalog"), |
| @@ -1050,13 +1165,14 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines | |||
| 1050 | const persistence = yield* makePersistence; | 1165 | const persistence = yield* makePersistence; |
| 1051 | const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); | 1166 | const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); |
| 1052 | const sourceId = SourceIdSchema.make("src_github_discovery"); | 1167 | const sourceId = SourceIdSchema.make("src_github_discovery"); |
| 1168 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | ||
| 1053 | yield* persistConnectedGithubOpenApiSource({ | 1169 | yield* persistConnectedGithubOpenApiSource({ |
| 1054 | persistence, | 1170 | runtimeLocalWorkspace, |
| 1055 | workspaceId, | 1171 | workspaceId, |
| 1056 | sourceId, | 1172 | sourceId, |
| 1057 | }); | 1173 | }); |
| 1058 | 1174 | ||
| 1059 | const resolveEnvironment = makeResolver(persistence); | 1175 | const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); |
| 1060 | const environment = yield* resolveEnvironment({ | 1176 | const environment = yield* resolveEnvironment({ |
| 1061 | workspaceId, | 1177 | workspaceId, |
| 1062 | accountId: AccountIdSchema.make("acc_github_discovery"), | 1178 | accountId: AccountIdSchema.make("acc_github_discovery"), |
| @@ -1103,14 +1219,15 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines | |||
| 1103 | const persistence = yield* makePersistence; | 1219 | const persistence = yield* makePersistence; |
| 1104 | const workspaceId = WorkspaceIdSchema.make("ws_invoke"); | 1220 | const workspaceId = WorkspaceIdSchema.make("ws_invoke"); |
| 1105 | const sourceId = SourceIdSchema.make("src_invoke"); | 1221 | const sourceId = SourceIdSchema.make("src_invoke"); |
| 1222 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | ||
| 1106 | yield* persistConnectedEchoTool({ | 1223 | yield* persistConnectedEchoTool({ |
| 1107 | persistence, | 1224 | runtimeLocalWorkspace, |
| 1108 | endpoint: server.endpoint, | 1225 | endpoint: server.endpoint, |
| 1109 | workspaceId, | 1226 | workspaceId, |
| 1110 | sourceId, | 1227 | sourceId, |
| 1111 | }); | 1228 | }); |
| 1112 | 1229 | ||
| 1113 | const resolveEnvironment = makeResolver(persistence); | 1230 | const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); |
| 1114 | const environment = yield* resolveEnvironment({ | 1231 | const environment = yield* resolveEnvironment({ |
| 1115 | workspaceId, | 1232 | workspaceId, |
| 1116 | accountId: AccountIdSchema.make("acc_invoke"), | 1233 | accountId: AccountIdSchema.make("acc_invoke"), |
| @@ -1139,7 +1256,8 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines | |||
| 1139 | const specServer = yield* makeOpenApiSpecServer; | 1256 | const specServer = yield* makeOpenApiSpecServer; |
| 1140 | const persistence = yield* makePersistence; | 1257 | const persistence = yield* makePersistence; |
| 1141 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); | 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 | const now = Date.now(); | 1261 | const now = Date.now(); |
| 1144 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | 1262 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 1145 | "sec_test_openapi_bearer", | 1263 | "sec_test_openapi_bearer", |
| @@ -1220,21 +1338,22 @@ describe("workspace-execution-environment", () => { · 74 unmodified lines | |||
| 1220 | encodeURIComponent("exec_add_openapi:executor.sources.add:"), | 1338 | encodeURIComponent("exec_add_openapi:executor.sources.add:"), |
| 1221 | ); | 1339 | ); |
| 1222 | 1340 | ||
| 1223 | const storedSource = | 1341 | const loadedConfig = yield* loadLocalExecutorConfig(runtimeLocalWorkspace.context); |
| 1224 | yield* persistence.rows.sources.getByWorkspaceAndId( | 1342 | expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({ |
| 1225 | workspaceId, | 1343 | kind: "openapi", |
| 1226 | added.id, | 1344 | connection: { |
| 1227 | ); | 1345 | endpoint: `${specServer.baseUrl}/`, |
| 1228 | expect(Option.isSome(storedSource)).toBe(true); | 1346 | }, |
| 1229 | if (Option.isSome(storedSource)) { | 1347 | }); |
| 1230 | const documents = | 1348 | const artifact = yield* readLocalSourceArtifact({ |
| 1231 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | 1349 | context: runtimeLocalWorkspace.context, |
| 1232 | storedSource.value.recipeRevisionId, | 1350 | sourceId: added.id, |
| 1233 | ); | 1351 | }); |
| 1234 | expect(documents[0]?.contentText).toContain( | 1352 | expect(artifact?.documents[0]?.contentText).toContain( |
| 1235 | '"operationId":"repos.getRepo"', | 1353 | '"operationId":"repos.getRepo"', |
| 1236 | ); | 1354 | ); |
| 1237 | } | 1355 | const workspaceState = yield* loadLocalWorkspaceState(runtimeLocalWorkspace.context); |
| 1356 | expect(workspaceState.sources[added.id]?.status).toBe("connected"); | ||
| 1238 | 1357 | ||
| 1239 | const freshEnvironment = yield* resolveEnvironment({ | 1358 | const freshEnvironment = yield* resolveEnvironment({ |
| 1240 | workspaceId, | 1359 | workspaceId, |
| @@ -1280,7 +1399,8 @@ describe("workspace-execution-environment", () => { · 39 unmodified lines | |||
| 1280 | const graphqlServer = yield* makeGraphqlServer; | 1399 | const graphqlServer = yield* makeGraphqlServer; |
| 1281 | const persistence = yield* makePersistence; | 1400 | const persistence = yield* makePersistence; |
| 1282 | const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); | 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 | const now = Date.now(); | 1404 | const now = Date.now(); |
| 1285 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | 1405 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 1286 | "sec_test_graphql_bearer", | 1406 | "sec_test_graphql_bearer", |
| @@ -1339,33 +1459,28 @@ describe("workspace-execution-environment", () => { · 52 unmodified lines | |||
| 1339 | expect(added.auth.kind).toBe("bearer"); | 1459 | expect(added.auth.kind).toBe("bearer"); |
| 1340 | expect(added.auth.token?.providerId).toBe("postgres"); | 1460 | expect(added.auth.token?.providerId).toBe("postgres"); |
| 1341 | 1461 | ||
| 1342 | const storedSource = | 1462 | const artifact = yield* readLocalSourceArtifact({ |
| 1343 | yield* persistence.rows.sources.getByWorkspaceAndId( | 1463 | context: runtimeLocalWorkspace.context, |
| 1344 | workspaceId, | 1464 | sourceId: added.id, |
| 1345 | added.id, | 1465 | }); |
| 1346 | ); | 1466 | expect(artifact?.documents[0]?.contentText).toContain('"__schema"'); |
| 1347 | expect(Option.isSome(storedSource)).toBe(true); | 1467 | expect(artifact?.schemaBundles.length).toBe(1); |
| 1348 | if (Option.isSome(storedSource)) { | 1468 | if (artifact === null) { |
| 1349 | const documents = | 1469 | throw new Error(`Missing local source artifact for ${added.id}`); |
| 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); | ||
| 1359 | } | 1470 | } |
| 1360 | 1471 | ||
| 1361 | const connectedSourceRecord = Option.getOrThrow(storedSource); | 1472 | yield* writeLocalSourceArtifact({ |
| 1362 | yield* persistence.rows.sourceRecipeRevisions.update( | 1473 | context: runtimeLocalWorkspace.context, |
| 1363 | connectedSourceRecord.recipeRevisionId, | 1474 | sourceId: added.id, |
| 1364 | { | 1475 | artifact: { |
| 1365 | manifestJson: null, | 1476 | ...artifact, |
| 1366 | updatedAt: Date.now(), | 1477 | revision: { |
| 1478 | ...artifact.revision, | ||
| 1479 | manifestJson: null, | ||
| 1480 | updatedAt: Date.now(), | ||
| 1481 | }, | ||
| 1367 | }, | 1482 | }, |
| 1368 | ); | 1483 | }); |
| 1369 | 1484 | ||
| 1370 | const freshEnvironment = yield* resolveEnvironment({ | 1485 | const freshEnvironment = yield* resolveEnvironment({ |
| 1371 | workspaceId, | 1486 | workspaceId, |
| @@ -1515,9 +1630,10 @@ describe("workspace-execution-environment", () => { · 143 unmodified lines | |||
| 1515 | const persistence = yield* makePersistence; | 1630 | const persistence = yield* makePersistence; |
| 1516 | const workspaceId = WorkspaceIdSchema.make("ws_params_auth"); | 1631 | const workspaceId = WorkspaceIdSchema.make("ws_params_auth"); |
| 1517 | const sourceId = SourceIdSchema.make("src_params_auth"); | 1632 | const sourceId = SourceIdSchema.make("src_params_auth"); |
| 1633 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | ||
| 1518 | 1634 | ||
| 1519 | yield* persistConnectedGithubOpenApiSource({ | 1635 | yield* persistConnectedGithubOpenApiSource({ |
| 1520 | persistence, | 1636 | runtimeLocalWorkspace, |
| 1521 | workspaceId, | 1637 | workspaceId, |
| 1522 | sourceId, | 1638 | sourceId, |
| 1523 | endpoint: specServer.baseUrl, | 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 | const environment = yield* resolveEnvironment({ | 1647 | const environment = yield* resolveEnvironment({ |
| 1532 | workspaceId, | 1648 | workspaceId, |
| 1533 | accountId: AccountIdSchema.make("acc_params_auth"), | 1649 | accountId: AccountIdSchema.make("acc_params_auth"), |
| @@ -1567,14 +1683,15 @@ describe("workspace-execution-environment", () => { · 33 unmodified lines | |||
| 1567 | "ws_openapi_describe_bundle", | 1683 | "ws_openapi_describe_bundle", |
| 1568 | ); | 1684 | ); |
| 1569 | const sourceId = SourceIdSchema.make("src_openapi_describe_bundle"); | 1685 | const sourceId = SourceIdSchema.make("src_openapi_describe_bundle"); |
| 1686 | const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId); | ||
| 1570 | 1687 | ||
| 1571 | yield* persistConnectedGithubOpenApiSource({ | 1688 | yield* persistConnectedGithubOpenApiSource({ |
| 1572 | persistence, | 1689 | runtimeLocalWorkspace, |
| 1573 | workspaceId, | 1690 | workspaceId, |
| 1574 | sourceId, | 1691 | sourceId, |
| 1575 | }); | 1692 | }); |
| 1576 | 1693 | ||
| 1577 | const resolveEnvironment = makeResolver(persistence); | 1694 | const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace); |
| 1578 | const environment = yield* resolveEnvironment({ | 1695 | const environment = yield* resolveEnvironment({ |
| 1579 | workspaceId, | 1696 | workspaceId, |
| 1580 | accountId: AccountIdSchema.make("acc_openapi_describe_bundle"), | 1697 | accountId: AccountIdSchema.make("acc_openapi_describe_bundle"), |
| @@ -1621,10 +1738,12 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines | |||
| 1621 | () => | 1738 | () => |
| 1622 | Effect.gen(function* () { | 1739 | Effect.gen(function* () { |
| 1623 | const persistence = yield* makePersistence; | 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 | const environment = yield* resolveEnvironment({ | 1745 | const environment = yield* resolveEnvironment({ |
| 1627 | workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"), | 1746 | workspaceId, |
| 1628 | accountId: AccountIdSchema.make("acc_describe_add_source"), | 1747 | accountId: AccountIdSchema.make("acc_describe_add_source"), |
| 1629 | executionId: ExecutionIdSchema.make("exec_describe_add_source"), | 1748 | executionId: ExecutionIdSchema.make("exec_describe_add_source"), |
| 1630 | }); | 1749 | }); |
| @@ -37,8 +37,10 @@ import { · 36 unmodified lines | |||
| 37 | type RuntimeSourceAuthService, | 37 | type RuntimeSourceAuthService, |
| 38 | } from "./source-auth-service"; | 38 | } from "./source-auth-service"; |
| 39 | import { | 39 | import { |
| 40 | loadSourceWithRecipe, | ||
| 40 | loadWorkspaceSourceRecipeToolByPath, | 41 | loadWorkspaceSourceRecipeToolByPath, |
| 41 | loadWorkspaceSourceRecipeToolIndex, | 42 | loadWorkspaceSourceRecipeToolIndex, |
| 43 | loadWorkspaceSourceRecipes, | ||
| 42 | type LoadedSourceRecipeToolIndexEntry, | 44 | type LoadedSourceRecipeToolIndexEntry, |
| 43 | } from "./source-recipes-runtime"; | 45 | } from "./source-recipes-runtime"; |
| 44 | import { resolveSourceAuthMaterial } from "./source-auth-material"; | 46 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| @@ -53,6 +55,14 @@ import { · 8 unmodified lines | |||
| 53 | evaluateInvocationPolicy, | 55 | evaluateInvocationPolicy, |
| 54 | type InvocationDescriptor, | 56 | type InvocationDescriptor, |
| 55 | } from "./invocation-policy-engine"; | 57 | } from "./invocation-policy-engine"; |
| 58 | import { | ||
| 59 | loadRuntimeLocalWorkspacePolicies, | ||
| 60 | } from "./policies-operations"; | ||
| 61 | import { | ||
| 62 | getRuntimeLocalWorkspaceOption, | ||
| 63 | RuntimeLocalWorkspaceService, | ||
| 64 | type RuntimeLocalWorkspaceState, | ||
| 65 | } from "./local-runtime-context"; | ||
| 56 | 66 | ||
| 57 | const asToolPath = (value: string): ToolPath => value as ToolPath; | 67 | const asToolPath = (value: string): ToolPath => value as ToolPath; |
| 58 | 68 | ||
| @@ -180,31 +190,38 @@ const loadWorkspaceSchemaBundle = (input: { · 121 unmodified lines | |||
| 180 | id: SourceRecipeSchemaBundleId; | 190 | id: SourceRecipeSchemaBundleId; |
| 181 | }): Effect.Effect<ToolSchemaBundle | null, Error, never> => | 191 | }): Effect.Effect<ToolSchemaBundle | null, Error, never> => |
| 182 | Effect.gen(function* () { | 192 | Effect.gen(function* () { |
| 183 | const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById( | 193 | const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); |
| 184 | input.id, | 194 | if (runtimeLocalWorkspace !== null) { |
| 185 | ); | 195 | const recipes = yield* loadWorkspaceSourceRecipes({ |
| 186 | if (Option.isNone(bundle)) { | 196 | rows: input.rows, |
| 187 | return null; | 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( | 212 | const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(input.id); |
| 191 | input.workspaceId, | 213 | if (Option.isSome(bundle)) { |
| 192 | ); | 214 | const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); |
| 193 | if ( | 215 | if (sourceRecords.some((sourceRecord) => sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId)) { |
| 194 | !sourceRecords.some( | 216 | return { |
| 195 | (sourceRecord) => | 217 | id: bundle.value.id, |
| 196 | sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId, | 218 | kind: bundle.value.bundleKind, |
| 197 | ) | 219 | hash: bundle.value.contentHash, |
| 198 | ) { | 220 | refsJson: bundle.value.refsJson, |
| 199 | return null; | 221 | }; |
| 222 | } | ||
| 200 | } | 223 | } |
| 201 | 224 | return null; | |
| 202 | return { | ||
| 203 | id: bundle.value.id, | ||
| 204 | kind: bundle.value.bundleKind, | ||
| 205 | hash: bundle.value.contentHash, | ||
| 206 | refsJson: bundle.value.refsJson, | ||
| 207 | }; | ||
| 208 | }); | 225 | }); |
| 209 | 226 | ||
| 210 | const scoreRecipeTool = ( | 227 | const scoreRecipeTool = ( |
| @@ -373,41 +390,18 @@ const authorizePersistedToolInvocation = (input: { · 162 unmodified lines | |||
| 373 | >[0]["onElicitation"]; | 390 | >[0]["onElicitation"]; |
| 374 | }): Effect.Effect<void, Error, never> => | 391 | }): Effect.Effect<void, Error, never> => |
| 375 | Effect.gen(function* () { | 392 | Effect.gen(function* () { |
| 376 | const workspace = yield* input.rows.workspaces | 393 | const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe( |
| 377 | .getById(input.workspaceId) | 394 | Effect.mapError((cause) => |
| 378 | .pipe( | 395 | cause instanceof Error ? cause : new Error(String(cause)), |
| 379 | Effect.mapError((cause) => | 396 | ), |
| 380 | cause instanceof Error ? cause : new Error(String(cause)), | 397 | ); |
| 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 | : []; | ||
| 395 | 398 | ||
| 396 | const decision = evaluateInvocationPolicy({ | 399 | const decision = evaluateInvocationPolicy({ |
| 397 | descriptor: input.descriptor, | 400 | descriptor: input.descriptor, |
| 398 | args: input.args, | 401 | args: input.args, |
| 399 | policies, | 402 | policies: localWorkspacePolicies.policies, |
| 400 | context: { | 403 | context: { |
| 401 | workspaceId: input.workspaceId, | 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 | const createWorkspaceToolCatalog = (input: { | 481 | const createWorkspaceToolCatalog = (input: { |
| 475 | workspaceId: Source["workspaceId"]; | 482 | workspaceId: Source["workspaceId"]; |
| 476 | accountId: AccountId; | 483 | accountId: AccountId; |
| 477 | rows: SqlControlPlaneRows; | 484 | rows: SqlControlPlaneRows; |
| 478 | executorCatalog: ToolCatalog; | 485 | executorCatalog: ToolCatalog; |
| 486 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | ||
| 479 | }): ToolCatalog => ({ | 487 | }): ToolCatalog => ({ |
| 480 | listNamespaces: ({ limit }) => | 488 | listNamespaces: ({ limit }) => |
| 481 | Effect.gen(function* () { | 489 | provideRuntimeLocalWorkspace(Effect.gen(function* () { |
| 482 | const [recipeTools, executor] = yield* Effect.all([ | 490 | const [recipeTools, executor] = yield* Effect.all([ |
| 483 | loadWorkspaceRecipeTools({ | 491 | loadWorkspaceRecipeTools({ |
| 484 | rows: input.rows, | 492 | rows: input.rows, |
| @@ -513,10 +521,10 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines | |||
| 513 | return [...merged.values()] | 521 | return [...merged.values()] |
| 514 | .sort((left, right) => left.namespace.localeCompare(right.namespace)) | 522 | .sort((left, right) => left.namespace.localeCompare(right.namespace)) |
| 515 | .slice(0, limit); | 523 | .slice(0, limit); |
| 516 | }), | 524 | }), input.runtimeLocalWorkspace), |
| 517 | 525 | ||
| 518 | listTools: ({ namespace, query, limit, includeSchemas = false }) => | 526 | listTools: ({ namespace, query, limit, includeSchemas = false }) => |
| 519 | Effect.gen(function* () { | 527 | provideRuntimeLocalWorkspace(Effect.gen(function* () { |
| 520 | const [recipeTools, executor] = yield* Effect.all([ | 528 | const [recipeTools, executor] = yield* Effect.all([ |
| 521 | loadWorkspaceRecipeTools({ | 529 | loadWorkspaceRecipeTools({ |
| 522 | rows: input.rows, | 530 | rows: input.rows, |
| @@ -549,10 +557,10 @@ const createWorkspaceToolCatalog = (input: { · 26 unmodified lines | |||
| 549 | return [...recipeTools.map((tool) => tool.descriptor), ...executor] | 557 | return [...recipeTools.map((tool) => tool.descriptor), ...executor] |
| 550 | .sort((left, right) => left.path.localeCompare(right.path)) | 558 | .sort((left, right) => left.path.localeCompare(right.path)) |
| 551 | .slice(0, limit); | 559 | .slice(0, limit); |
| 552 | }), | 560 | }), input.runtimeLocalWorkspace), |
| 553 | 561 | ||
| 554 | getToolByPath: ({ path, includeSchemas }) => | 562 | getToolByPath: ({ path, includeSchemas }) => |
| 555 | Effect.gen(function* () { | 563 | provideRuntimeLocalWorkspace(Effect.gen(function* () { |
| 556 | const executor = yield* input.executorCatalog.getToolByPath({ | 564 | const executor = yield* input.executorCatalog.getToolByPath({ |
| 557 | path, | 565 | path, |
| 558 | includeSchemas, | 566 | includeSchemas, |
| @@ -569,17 +577,17 @@ const createWorkspaceToolCatalog = (input: { · 10 unmodified lines | |||
| 569 | includeSchemas, | 577 | includeSchemas, |
| 570 | }); | 578 | }); |
| 571 | return recipeTool?.descriptor ?? null; | 579 | return recipeTool?.descriptor ?? null; |
| 572 | }), | 580 | }), input.runtimeLocalWorkspace), |
| 573 | 581 | ||
| 574 | getSchemaBundle: ({ id }) => | 582 | getSchemaBundle: ({ id }) => |
| 575 | loadWorkspaceSchemaBundle({ | 583 | provideRuntimeLocalWorkspace(loadWorkspaceSchemaBundle({ |
| 576 | rows: input.rows, | 584 | rows: input.rows, |
| 577 | workspaceId: input.workspaceId, | 585 | workspaceId: input.workspaceId, |
| 578 | id: SourceRecipeSchemaBundleIdSchema.make(id), | 586 | id: SourceRecipeSchemaBundleIdSchema.make(id), |
| 579 | }), | 587 | }), input.runtimeLocalWorkspace), |
| 580 | 588 | ||
| 581 | searchTools: ({ query, namespace, limit }) => | 589 | searchTools: ({ query, namespace, limit }) => |
| 582 | Effect.gen(function* () { | 590 | provideRuntimeLocalWorkspace(Effect.gen(function* () { |
| 583 | const queryTokens = tokenize(query); | 591 | const queryTokens = tokenize(query); |
| 584 | const [recipeTools, executor] = yield* Effect.all([ | 592 | const [recipeTools, executor] = yield* Effect.all([ |
| 585 | loadWorkspaceRecipeTools({ | 593 | loadWorkspaceRecipeTools({ |
| @@ -614,7 +622,7 @@ const createWorkspaceToolCatalog = (input: { · 28 unmodified lines | |||
| 614 | right.score - left.score || left.path.localeCompare(right.path), | 622 | right.score - left.score || left.path.localeCompare(right.path), |
| 615 | ) | 623 | ) |
| 616 | .slice(0, limit); | 624 | .slice(0, limit); |
| 617 | }), | 625 | }), input.runtimeLocalWorkspace), |
| 618 | }); | 626 | }); |
| 619 | 627 | ||
| 620 | const createWorkspaceToolInvoker = (input: { | 628 | const createWorkspaceToolInvoker = (input: { |
| @@ -623,6 +631,7 @@ const createWorkspaceToolInvoker = (input: { · 2 unmodified lines | |||
| 623 | rows: SqlControlPlaneRows; | 631 | rows: SqlControlPlaneRows; |
| 624 | resolveSecretMaterial: ResolveSecretMaterial; | 632 | resolveSecretMaterial: ResolveSecretMaterial; |
| 625 | sourceAuthService: RuntimeSourceAuthService; | 633 | sourceAuthService: RuntimeSourceAuthService; |
| 634 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState | null; | ||
| 626 | onElicitation?: Parameters< | 635 | onElicitation?: Parameters< |
| 627 | typeof makeToolInvokerFromTools | 636 | typeof makeToolInvokerFromTools |
| 628 | >[0]["onElicitation"]; | 637 | >[0]["onElicitation"]; |
| @@ -634,6 +643,7 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines | |||
| 634 | workspaceId: input.workspaceId, | 643 | workspaceId: input.workspaceId, |
| 635 | accountId: input.accountId, | 644 | accountId: input.accountId, |
| 636 | sourceAuthService: input.sourceAuthService, | 645 | sourceAuthService: input.sourceAuthService, |
| 646 | runtimeLocalWorkspace: input.runtimeLocalWorkspace, | ||
| 637 | }); | 647 | }); |
| 638 | const executorCatalog = createToolCatalogFromTools({ | 648 | const executorCatalog = createToolCatalogFromTools({ |
| 639 | tools: executorTools, | 649 | tools: executorTools, |
| @@ -643,6 +653,7 @@ const createWorkspaceToolInvoker = (input: { · 3 unmodified lines | |||
| 643 | accountId: input.accountId, | 653 | accountId: input.accountId, |
| 644 | rows: input.rows, | 654 | rows: input.rows, |
| 645 | executorCatalog, | 655 | executorCatalog, |
| 656 | runtimeLocalWorkspace: input.runtimeLocalWorkspace, | ||
| 646 | }); | 657 | }); |
| 647 | const systemTools = createSystemToolMap({ catalog }); | 658 | const systemTools = createSystemToolMap({ catalog }); |
| 648 | const systemToolPaths = new Set(Object.keys(systemTools)); | 659 | const systemToolPaths = new Set(Object.keys(systemTools)); |
| @@ -661,7 +672,7 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines | |||
| 661 | args: unknown; | 672 | args: unknown; |
| 662 | context?: Record<string, unknown>; | 673 | context?: Record<string, unknown>; |
| 663 | }) => | 674 | }) => |
| 664 | Effect.gen(function* () { | 675 | provideRuntimeLocalWorkspace(Effect.gen(function* () { |
| 665 | const recipeTool = yield* loadWorkspaceRecipeToolByPath({ | 676 | const recipeTool = yield* loadWorkspaceRecipeToolByPath({ |
| 666 | rows: input.rows, | 677 | rows: input.rows, |
| 667 | workspaceId: input.workspaceId, | 678 | workspaceId: input.workspaceId, |
| @@ -700,17 +711,12 @@ const createWorkspaceToolInvoker = (input: { · 32 unmodified lines | |||
| 700 | id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId), | 711 | id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId), |
| 701 | }) | 712 | }) |
| 702 | : null; | 713 | : null; |
| 703 | const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | 714 | const recipe = yield* loadSourceWithRecipe({ |
| 704 | input.workspaceId, | 715 | rows: input.rows, |
| 705 | recipeTool.source.id, | 716 | workspaceId: input.workspaceId, |
| 706 | ); | 717 | sourceId: recipeTool.source.id, |
| 707 | let manifestJson: string | null = null; | 718 | actorAccountId: input.accountId, |
| 708 | if (Option.isSome(sourceRecord)) { | 719 | }); |
| 709 | const revision = yield* input.rows.sourceRecipeRevisions.getById( | ||
| 710 | sourceRecord.value.recipeRevisionId, | ||
| 711 | ); | ||
| 712 | manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null; | ||
| 713 | } | ||
| 714 | 720 | ||
| 715 | return yield* getSourceAdapterForOperation(recipeTool.operation) | 721 | return yield* getSourceAdapterForOperation(recipeTool.operation) |
| 716 | .invokePersistedTool({ | 722 | .invokePersistedTool({ |
| @@ -721,23 +727,26 @@ const createWorkspaceToolInvoker = (input: { · 4 unmodified lines | |||
| 721 | path: invocation.path, | 727 | path: invocation.path, |
| 722 | operation: recipeTool.operation, | 728 | operation: recipeTool.operation, |
| 723 | schemaBundle, | 729 | schemaBundle, |
| 724 | manifestJson, | 730 | manifestJson: recipe.revision.manifestJson, |
| 725 | auth, | 731 | auth, |
| 726 | args: invocation.args, | 732 | args: invocation.args, |
| 727 | context: invocation.context, | 733 | context: invocation.context, |
| 728 | onElicitation: input.onElicitation, | 734 | onElicitation: input.onElicitation, |
| 729 | }); | 735 | }); |
| 730 | }); | 736 | }), input.runtimeLocalWorkspace); |
| 731 | 737 | ||
| 732 | return { | 738 | return { |
| 733 | catalog, | 739 | catalog, |
| 734 | toolInvoker: { | 740 | toolInvoker: { |
| 735 | invoke: ({ path, args, context }) => | 741 | invoke: ({ path, args, context }) => |
| 736 | systemToolPaths.has(path) | 742 | provideRuntimeLocalWorkspace( |
| 737 | ? systemInvoker.invoke({ path, args, context }) | 743 | systemToolPaths.has(path) |
| 738 | : executorToolPaths.has(path) | 744 | ? systemInvoker.invoke({ path, args, context }) |
| 739 | ? executorInvoker.invoke({ path, args, context }) | 745 | : executorToolPaths.has(path) |
| 740 | : invokePersistedTool({ path, args, context }), | 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 | return ({ workspaceId, accountId, onElicitation }) => | 765 | return ({ workspaceId, accountId, onElicitation }) => |
| 757 | Effect.sync(() => { | 766 | Effect.gen(function* () { |
| 767 | const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); | ||
| 758 | const { catalog, toolInvoker } = createWorkspaceToolInvoker({ | 768 | const { catalog, toolInvoker } = createWorkspaceToolInvoker({ |
| 759 | workspaceId, | 769 | workspaceId, |
| 760 | accountId, | 770 | accountId, |
| 761 | rows: input.rows, | 771 | rows: input.rows, |
| 762 | resolveSecretMaterial, | 772 | resolveSecretMaterial, |
| 763 | sourceAuthService: input.sourceAuthService, | 773 | sourceAuthService: input.sourceAuthService, |
| 774 | runtimeLocalWorkspace, | ||
| 764 | onElicitation, | 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 | export { | 1 | export { |
| 12 | SourceAuthSchema, | 2 | SourceAuthSchema, |
| 13 | SourceBindingSchema, | 3 | SourceBindingSchema, |
| @@ -72,12 +62,8 @@ export { · 58 unmodified lines | |||
| 72 | type SourceAuthSessionStatus, | 62 | type SourceAuthSessionStatus, |
| 73 | } from "./models/source-auth-session"; | 63 | } from "./models/source-auth-session"; |
| 74 | export { | 64 | export { |
| 75 | PolicyApprovalModeSchema, | 65 | LocalWorkspacePolicyApprovalModeSchema, |
| 76 | PolicyEffectSchema, | 66 | LocalWorkspacePolicyEffectSchema, |
| 77 | PolicyMatchTypeSchema, | 67 | type LocalWorkspacePolicyApprovalMode, |
| 78 | PolicyResourceTypeSchema, | 68 | type LocalWorkspacePolicyEffect, |
| 79 | type PolicyApprovalMode, | ||
| 80 | type PolicyEffect, | ||
| 81 | type PolicyMatchType, | ||
| 82 | type PolicyResourceType, | ||
| 83 | } from "./models/policy"; | 69 | } from "./models/policy"; |
| @@ -1,10 +1,6 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | export const AccountIdSchema = Schema.String.pipe(Schema.brand("AccountId")); | 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 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); | 4 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); |
| 9 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); | 5 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); |
| 10 | export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId")); | 6 | export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId")); |
| @@ -31,15 +27,12 @@ export const SecretMaterialIdSchema = Schema.String.pipe( · 20 unmodified lines | |||
| 31 | Schema.brand("SecretMaterialId"), | 27 | Schema.brand("SecretMaterialId"), |
| 32 | ); | 28 | ); |
| 33 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); | 29 | export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); |
| 34 | export const InstallationIdSchema = Schema.String.pipe(Schema.brand("InstallationId")); | ||
| 35 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); | 30 | export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId")); |
| 36 | export const ExecutionInteractionIdSchema = Schema.String.pipe( | 31 | export const ExecutionInteractionIdSchema = Schema.String.pipe( |
| 37 | Schema.brand("ExecutionInteractionId"), | 32 | Schema.brand("ExecutionInteractionId"), |
| 38 | ); | 33 | ); |
| 39 | 34 | ||
| 40 | export type AccountId = typeof AccountIdSchema.Type; | 35 | export type AccountId = typeof AccountIdSchema.Type; |
| 41 | export type OrganizationId = typeof OrganizationIdSchema.Type; | ||
| 42 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; | ||
| 43 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; | 36 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 44 | export type SourceId = typeof SourceIdSchema.Type; | 37 | export type SourceId = typeof SourceIdSchema.Type; |
| 45 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; | 38 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; |
| @@ -52,6 +45,5 @@ export type CredentialId = typeof CredentialIdSchema.Type; · 6 unmodified lines | |||
| 52 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; | 45 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; |
| 53 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; | 46 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 54 | export type PolicyId = typeof PolicyIdSchema.Type; | 47 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 55 | export type InstallationId = typeof InstallationIdSchema.Type; | ||
| 56 | export type ExecutionId = typeof ExecutionIdSchema.Type; | 48 | export type ExecutionId = typeof ExecutionIdSchema.Type; |
| 57 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; | 49 | export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type; |
| @@ -3,7 +3,6 @@ export * from "./enums"; · 2 unmodified lines | |||
| 3 | export * from "./ids"; | 3 | export * from "./ids"; |
| 4 | 4 | ||
| 5 | export * from "./models/auth"; | 5 | export * from "./models/auth"; |
| 6 | export * from "./models/workspace"; | ||
| 7 | export * from "./models/source"; | 6 | export * from "./models/source"; |
| 8 | export * from "./models/source-recipe"; | 7 | export * from "./models/source-recipe"; |
| 9 | export * from "./models/auth-artifact"; | 8 | export * from "./models/auth-artifact"; |
| @@ -13,6 +12,7 @@ export * from "./models/secret-material"; · 3 unmodified lines | |||
| 13 | export * from "./models/source-auth-session"; | 12 | export * from "./models/source-auth-session"; |
| 14 | export * from "./models/policy"; | 13 | export * from "./models/policy"; |
| 15 | export * from "./models/local-installation"; | 14 | export * from "./models/local-installation"; |
| 15 | export * from "./models/local-config"; | ||
| 16 | export * from "./models/execution"; | 16 | export * from "./models/execution"; |
| 17 | export * from "./models/source-inspection"; | 17 | export * from "./models/source-inspection"; |
| 18 | export * from "./models/source-discovery"; | 18 | export * from "./models/source-discovery"; |
| @@ -1,7 +1,2 @@ | |||
| 1 | export * from "./account"; | 1 | export * from "./account"; |
| 2 | export * from "./membership"; | ||
| 3 | export * from "./organization"; | ||
| 4 | export * from "./organization-membership"; | ||
| 5 | export * from "./permission"; | ||
| 6 | export * from "./principal"; | 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 { | 1 | import * as Schema from "effect/Schema"; |
| 2 | createInsertSchema, | ||
| 3 | createSelectSchema, | ||
| 4 | createUpdateSchema, | ||
| 5 | } from "drizzle-orm/effect-schema"; | ||
| 6 | 2 | ||
| 7 | import { localInstallationsTable } from "../../persistence/schema"; | ||
| 8 | import { TimestampMsSchema } from "../common"; | ||
| 9 | import { | 3 | import { |
| 10 | AccountIdSchema, | 4 | AccountIdSchema, |
| 11 | InstallationIdSchema, | ||
| 12 | OrganizationIdSchema, | ||
| 13 | WorkspaceIdSchema, | 5 | WorkspaceIdSchema, |
| 14 | } from "../ids"; | 6 | } from "../ids"; |
| 15 | 7 | ||
| 16 | const localInstallationSchemaOverrides = { | 8 | export const LocalInstallationSchema = Schema.Struct({ |
| 17 | id: InstallationIdSchema, | ||
| 18 | accountId: AccountIdSchema, | 9 | accountId: AccountIdSchema, |
| 19 | organizationId: OrganizationIdSchema, | ||
| 20 | workspaceId: WorkspaceIdSchema, | 10 | workspaceId: WorkspaceIdSchema, |
| 21 | createdAt: TimestampMsSchema, | 11 | }); |
| 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 | ); | ||
| 34 | 12 | ||
| 35 | export const LocalInstallationUpdateSchema = createUpdateSchema( | 13 | export const LocalInstallationInsertSchema = LocalInstallationSchema; |
| 36 | localInstallationsTable, | 14 | export const LocalInstallationUpdateSchema = Schema.partial(LocalInstallationSchema); |
| 37 | localInstallationSchemaOverrides, | ||
| 38 | ); | ||
| 39 | 15 | ||
| 40 | export type LocalInstallation = typeof LocalInstallationSchema.Type; | 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 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 7 | 2 | ||
| 8 | import { policiesTable } from "../../persistence/schema"; | ||
| 9 | import { TimestampMsSchema } from "../common"; | 3 | import { TimestampMsSchema } from "../common"; |
| 10 | import { | 4 | import { PolicyIdSchema, WorkspaceIdSchema } from "../ids"; |
| 11 | AccountIdSchema, | ||
| 12 | OrganizationIdSchema, | ||
| 13 | PolicyIdSchema, | ||
| 14 | WorkspaceIdSchema, | ||
| 15 | } 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( | 9 | export const LocalWorkspacePolicySchema = Schema.Struct({ |
| 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 = { | ||
| 44 | id: PolicyIdSchema, | 10 | id: PolicyIdSchema, |
| 45 | scopeType: PolicyScopeTypeSchema, | 11 | key: Schema.String, |
| 46 | organizationId: OrganizationIdSchema, | 12 | workspaceId: WorkspaceIdSchema, |
| 47 | workspaceId: Schema.NullOr(WorkspaceIdSchema), | 13 | resourcePattern: Schema.String, |
| 48 | targetAccountId: Schema.NullOr(AccountIdSchema), | 14 | effect: LocalWorkspacePolicyEffectSchema, |
| 49 | clientId: Schema.NullOr(Schema.String), | 15 | approvalMode: LocalWorkspacePolicyApprovalModeSchema, |
| 50 | resourceType: PolicyResourceTypeSchema, | 16 | priority: Schema.Number, |
| 51 | matchType: PolicyMatchTypeSchema, | 17 | enabled: Schema.Boolean, |
| 52 | effect: PolicyEffectSchema, | ||
| 53 | approvalMode: PolicyApprovalModeSchema, | ||
| 54 | argumentConditionsJson: Schema.NullOr(Schema.String), | ||
| 55 | createdAt: TimestampMsSchema, | 18 | createdAt: TimestampMsSchema, |
| 56 | updatedAt: TimestampMsSchema, | 19 | updatedAt: TimestampMsSchema, |
| 57 | } as const; | 20 | }); |
| 58 | |||
| 59 | export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides); | ||
| 60 | |||
| 61 | export const PolicyInsertSchema = createInsertSchema( | ||
| 62 | policiesTable, | ||
| 63 | policySchemaOverrides, | ||
| 64 | ); | ||
| 65 | 21 | ||
| 66 | export const PolicyUpdateSchema = createUpdateSchema( | 22 | export const LocalWorkspacePolicyInsertSchema = LocalWorkspacePolicySchema; |
| 67 | policiesTable, | 23 | export const LocalWorkspacePolicyUpdateSchema = Schema.partial(LocalWorkspacePolicySchema); |
| 68 | policySchemaOverrides, | ||
| 69 | ); | ||
| 70 | 24 | ||
| 71 | export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type; | 25 | export type LocalWorkspacePolicyEffect = typeof LocalWorkspacePolicyEffectSchema.Type; |
| 72 | export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type; | 26 | export type LocalWorkspacePolicyApprovalMode = |
| 73 | export type PolicyEffect = typeof PolicyEffectSchema.Type; | 27 | typeof LocalWorkspacePolicyApprovalModeSchema.Type; |
| 74 | export type PolicyApprovalMode = typeof PolicyApprovalModeSchema.Type; | 28 | export type LocalWorkspacePolicy = typeof LocalWorkspacePolicySchema.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; | ||
| @@ -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 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | PermissionValues, | ||
| 7 | RolePermissions, | ||
| 8 | StoredSourceRecipeOperationRecordSchema, | 6 | StoredSourceRecipeOperationRecordSchema, |
| 9 | } from "./index"; | 7 | } from "./index"; |
| 10 | 8 | ||
| 11 | describe("control-plane-schema", () => { | 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 | it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => { | 10 | it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => { |
| 20 | const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema); | 11 | const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema); |
| 21 | 12 | ||
| @@ -1,7 +1,6 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | ExecutionEnvelope, | 2 | ExecutionEnvelope, |
| 3 | ExecutionInteraction, | 3 | ControlPlaneRuntime, |
| 4 | SqlControlPlaneRuntime, | ||
| 5 | } from "@executor/control-plane"; | 4 | } from "@executor/control-plane"; |
| 6 | import { | 5 | import { |
| 7 | EXECUTOR_SOURCES_ADD_HELP_LINES, | 6 | EXECUTOR_SOURCES_ADD_HELP_LINES, |
| @@ -18,6 +17,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 10 unmodified lines | |||
| 18 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 17 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 19 | import { z } from "zod/v4"; | 18 | import { z } from "zod/v4"; |
| 20 | 19 | ||
| 20 | import { | ||
| 21 | buildPausedResultText, | ||
| 22 | parseInteractionPayload, | ||
| 23 | } from "./paused-result"; | ||
| 24 | |||
| 21 | const pollingIntervalMs = 200; | 25 | const pollingIntervalMs = 200; |
| 22 | 26 | ||
| 23 | const executeInputSchema = { | 27 | const executeInputSchema = { |
| @@ -34,14 +38,6 @@ const resumeInputSchema = { · 10 unmodified lines | |||
| 34 | }).optional(), | 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 | type ResumePayload = { | 41 | type ResumePayload = { |
| 46 | executionId: string; | 42 | executionId: string; |
| 47 | }; | 43 | }; |
| @@ -62,9 +58,6 @@ export type ExecutorMcpRequestHandler = { · 14 unmodified lines | |||
| 62 | close: () => Promise<void>; | 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 | const parseJsonValue = (value: string | null): unknown => { | 61 | const parseJsonValue = (value: string | null): unknown => { |
| 69 | if (value === null) { | 62 | if (value === null) { |
| 70 | return null; | 63 | return null; |
| @@ -93,48 +86,8 @@ const formatResultPreview = (resultJson: string): string => { · 22 unmodified lines | |||
| 93 | return truncateText(resultJson, maxResultPreviewChars); | 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 | const runControlPlane = async <A, E, R>( | 89 | const runControlPlane = async <A, E, R>( |
| 137 | runtime: SqlControlPlaneRuntime, | 90 | runtime: ControlPlaneRuntime, |
| 138 | effect: Effect.Effect<A, E, R>, | 91 | effect: Effect.Effect<A, E, R>, |
| 139 | ): Promise<A> => { | 92 | ): Promise<A> => { |
| 140 | const exit = await Effect.runPromiseExit( | 93 | const exit = await Effect.runPromiseExit( |
| @@ -192,7 +145,7 @@ const buildExecuteWorkflowText = (namespaces: readonly string[] = []): string => · 51 unmodified lines | |||
| 192 | 145 | ||
| 193 | const defaultExecuteDescription = buildExecuteWorkflowText(); | 146 | const defaultExecuteDescription = buildExecuteWorkflowText(); |
| 194 | 147 | ||
| 195 | const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string> => | 148 | const loadExecuteDescription = (runtime: ControlPlaneRuntime): Promise<string> => |
| 196 | runControlPlane( | 149 | runControlPlane( |
| 197 | runtime, | 150 | runtime, |
| 198 | Effect.gen(function* () { | 151 | Effect.gen(function* () { |
| @@ -265,9 +218,7 @@ const buildPausedResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult = · 66 unmodified lines | |||
| 265 | return { | 218 | return { |
| 266 | content: [{ | 219 | content: [{ |
| 267 | type: "text", | 220 | type: "text", |
| 268 | text: interaction | 221 | text: buildPausedResultText(envelope), |
| 269 | ? `Execution ${envelope.execution.id} paused: ${parsed?.message ?? "Interaction required."}` | ||
| 270 | : `Execution ${envelope.execution.id} is waiting for interaction.`, | ||
| 271 | }], | 222 | }], |
| 272 | structuredContent: { | 223 | structuredContent: { |
| 273 | executionId: envelope.execution.id, | 224 | executionId: envelope.execution.id, |
| @@ -305,7 +256,7 @@ const buildToolResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult => · 31 unmodified lines | |||
| 305 | }; | 256 | }; |
| 306 | 257 | ||
| 307 | const waitForInteractionProgress = async (input: { | 258 | const waitForInteractionProgress = async (input: { |
| 308 | runtime: SqlControlPlaneRuntime; | 259 | runtime: ControlPlaneRuntime; |
| 309 | workspaceId: string; | 260 | workspaceId: string; |
| 310 | executionId: string; | 261 | executionId: string; |
| 311 | pendingInteractionId: string; | 262 | pendingInteractionId: string; |
| @@ -331,7 +282,7 @@ const waitForInteractionProgress = async (input: { · 19 unmodified lines | |||
| 331 | }; | 282 | }; |
| 332 | 283 | ||
| 333 | const driveExecutionWithElicitation = async (input: { | 284 | const driveExecutionWithElicitation = async (input: { |
| 334 | runtime: SqlControlPlaneRuntime; | 285 | runtime: ControlPlaneRuntime; |
| 335 | workspaceId: string; | 286 | workspaceId: string; |
| 336 | accountId: string; | 287 | accountId: string; |
| 337 | server: McpServer; | 288 | server: McpServer; |
| @@ -408,7 +359,7 @@ const driveExecutionWithElicitation = async (input: { · 70 unmodified lines | |||
| 408 | }; | 359 | }; |
| 409 | 360 | ||
| 410 | const driveExecutionWithoutElicitation = async (input: { | 361 | const driveExecutionWithoutElicitation = async (input: { |
| 411 | runtime: SqlControlPlaneRuntime; | 362 | runtime: ControlPlaneRuntime; |
| 412 | workspaceId: string; | 363 | workspaceId: string; |
| 413 | accountId: string; | 364 | accountId: string; |
| 414 | executionId: string; | 365 | executionId: string; |
| @@ -447,7 +398,7 @@ const driveExecutionWithoutElicitation = async (input: { · 32 unmodified lines | |||
| 447 | }; | 398 | }; |
| 448 | 399 | ||
| 449 | const createExecutorMcpServer = async (config: { | 400 | const createExecutorMcpServer = async (config: { |
| 450 | runtime: SqlControlPlaneRuntime; | 401 | runtime: ControlPlaneRuntime; |
| 451 | }): Promise<McpServer> => { | 402 | }): Promise<McpServer> => { |
| 452 | const executeDescription = await loadExecuteDescription(config.runtime); | 403 | const executeDescription = await loadExecuteDescription(config.runtime); |
| 453 | const server = new McpServer( | 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 | export const createExecutorMcpRequestHandler = ( | 505 | export const createExecutorMcpRequestHandler = ( |
| 555 | runtime: SqlControlPlaneRuntime, | 506 | runtime: ControlPlaneRuntime, |
| 556 | ): ExecutorMcpRequestHandler => { | 507 | ): ExecutorMcpRequestHandler => { |
| 557 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); | 508 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 558 | const servers = new Map<string, McpServer>(); | 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 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { | 6 | import { |
| 7 | createControlPlaneApiLayer, | 7 | createControlPlaneApiLayer, |
| 8 | createSqlControlPlaneRuntime, | 8 | createControlPlaneRuntime, |
| 9 | type LocalInstallation, | 9 | type LocalInstallation, |
| 10 | type Source, | 10 | type Source, |
| 11 | type SqlControlPlaneRuntime, | 11 | type ControlPlaneRuntime, |
| 12 | } from "@executor/control-plane"; | 12 | } from "@executor/control-plane"; |
| 13 | import * as Context from "effect/Context"; | 13 | import * as Context from "effect/Context"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| @@ -69,7 +69,7 @@ type RunningServer = { · 54 unmodified lines | |||
| 69 | }; | 69 | }; |
| 70 | 70 | ||
| 71 | type ApiServer = RunningServer & { | 71 | type ApiServer = RunningServer & { |
| 72 | runtime: SqlControlPlaneRuntime; | 72 | runtime: ControlPlaneRuntime; |
| 73 | }; | 73 | }; |
| 74 | 74 | ||
| 75 | type HookHarness<T> = { | 75 | type HookHarness<T> = { |
| @@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 19 unmodified lines | |||
| 95 | 95 | ||
| 96 | const startControlPlaneServer = async (): Promise<ApiServer> => { | 96 | const startControlPlaneServer = async (): Promise<ApiServer> => { |
| 97 | const runtime = await Effect.runPromise( | 97 | const runtime = await Effect.runPromise( |
| 98 | createSqlControlPlaneRuntime({ localDataDir: ":memory:" }), | 98 | createControlPlaneRuntime({ |
| 99 | localDataDir: ":memory:", | ||
| 100 | workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")), | ||
| 101 | }), | ||
| 99 | ); | 102 | ); |
| 100 | const scope = await Effect.runPromise(Scope.make()); | 103 | const scope = await Effect.runPromise(Scope.make()); |
| 101 | 104 | ||
| @@ -531,6 +534,7 @@ describe("executor-react source hooks", () => { · 429 unmodified lines | |||
| 531 | await apiServer.close(); | 534 | await apiServer.close(); |
| 532 | } | 535 | } |
| 533 | }), | 536 | }), |
| 537 | 60_000, | ||
| 534 | ); | 538 | ); |
| 535 | 539 | ||
| 536 | it.effect("applies optimistic source updates and refreshes live inspection data after success", () => | 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 | await apiServer.close(); | 628 | await apiServer.close(); |
| 625 | } | 629 | } |
| 626 | }), | 630 | }), |
| 631 | 60_000, | ||
| 627 | ); | 632 | ); |
| 628 | 633 | ||
| 629 | it.effect("optimistically removes deleted sources and invalidates mounted source queries", () => | 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 | await apiServer.close(); | 711 | await apiServer.close(); |
| 707 | } | 712 | } |
| 708 | }), | 713 | }), |
| 714 | 60_000, | ||
| 709 | ); | 715 | ); |
| 710 | 716 | ||
| 711 | it.effect("surfaces missing sources as errors instead of staying loading", () => | 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 | await apiServer.close(); | 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 | const runControlPlane = async <A>(input: { | 232 | const runControlPlane = async <A>(input: { |
| 233 | baseUrl?: string; | ||
| 233 | accountId?: string; | 234 | accountId?: string; |
| 234 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; | 235 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| 235 | }): Promise<A> => { | 236 | }): Promise<A> => { |
| 237 | const baseUrl = input.baseUrl ?? apiBaseUrl; | ||
| 236 | const accountId = input.accountId; | 238 | const accountId = input.accountId; |
| 237 | 239 | ||
| 238 | const exit = await Effect.runPromiseExit( | 240 | const exit = await Effect.runPromiseExit( |
| 239 | createControlPlaneClient({ | 241 | createControlPlaneClient({ |
| 240 | baseUrl: apiBaseUrl, | 242 | baseUrl, |
| 241 | ...(accountId !== undefined ? { accountId } : {}), | 243 | ...(accountId !== undefined ? { accountId } : {}), |
| 242 | }).pipe(Effect.flatMap(input.execute)), | 244 | }).pipe(Effect.flatMap(input.execute)), |
| 243 | ); | 245 | ); |
| @@ -248,7 +250,7 @@ const runControlPlane = async <A>(input: { · 4 unmodified lines | |||
| 248 | 250 | ||
| 249 | const error = Cause.squash(exit.cause); | 251 | const error = Cause.squash(exit.cause); |
| 250 | logExecutorDevError("control-plane request failed", { | 252 | logExecutorDevError("control-plane request failed", { |
| 251 | baseUrl: apiBaseUrl, | 253 | baseUrl, |
| 252 | accountId, | 254 | accountId, |
| 253 | error: describeExecutorDevError(error), | 255 | error: describeExecutorDevError(error), |
| 254 | cause: Cause.pretty(exit.cause), | 256 | cause: Cause.pretty(exit.cause), |
| @@ -257,6 +259,7 @@ const runControlPlane = async <A>(input: { · 2 unmodified lines | |||
| 257 | }; | 259 | }; |
| 258 | 260 | ||
| 259 | const controlPlaneRequest = <A>(input: { | 261 | const controlPlaneRequest = <A>(input: { |
| 262 | baseUrl?: string; | ||
| 260 | accountId?: string; | 263 | accountId?: string; |
| 261 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; | 264 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| 262 | }): Effect.Effect<A, Error> => | 265 | }): Effect.Effect<A, Error> => |
| @@ -265,23 +268,32 @@ const controlPlaneRequest = <A>(input: { · 2 unmodified lines | |||
| 265 | catch: toError, | 268 | catch: toError, |
| 266 | }); | 269 | }); |
| 267 | 270 | ||
| 268 | const localInstallationAtom = Atom.make( | 271 | const localInstallationAtom = Atom.family((baseUrl: string) => |
| 269 | controlPlaneRequest({ | 272 | Atom.make( |
| 270 | execute: (client) => client.local.installation({}), | 273 | controlPlaneRequest({ |
| 271 | }), | 274 | baseUrl, |
| 272 | ).pipe(Atom.keepAlive); | 275 | execute: (client) => client.local.installation({}), |
| 273 | 276 | }), | |
| 274 | const instanceConfigAtom = Atom.make( | 277 | ).pipe(Atom.keepAlive), |
| 275 | controlPlaneRequest({ | 278 | ); |
| 276 | execute: (client) => client.local.config({}), | 279 | |
| 277 | }), | 280 | const instanceConfigAtom = Atom.family((baseUrl: string) => |
| 278 | ).pipe(Atom.keepAlive); | 281 | Atom.make( |
| 279 | 282 | controlPlaneRequest({ | |
| 280 | const secretsAtom = Atom.make( | 283 | baseUrl, |
| 281 | controlPlaneRequest({ | 284 | execute: (client) => client.local.config({}), |
| 282 | execute: (client) => client.local.listSecrets({}), | 285 | }), |
| 283 | }), | 286 | ).pipe(Atom.keepAlive), |
| 284 | ).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 | const sourcesAtom = Atom.family((key: string) => { | 298 | const sourcesAtom = Atom.family((key: string) => { |
| 287 | const [enabled, workspaceId, accountId] = decodeAtomKey<SourcesKeyParts>(key); | 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 | const useWorkspaceContext = (): Loadable<WorkspaceContext> => { | 467 | const useWorkspaceContext = (): Loadable<WorkspaceContext> => { |
| 456 | const installation = useLoadableAtom(localInstallationAtom); | 468 | const installation = useLoadableAtom(localInstallationAtom(apiBaseUrl)); |
| 457 | 469 | ||
| 458 | return React.useMemo(() => { | 470 | return React.useMemo(() => { |
| 459 | if (installation.status !== "ready") { | 471 | if (installation.status !== "ready") { |
| @@ -810,18 +822,18 @@ export const useInvalidateExecutorQueries = (): (() => void) => { · 350 unmodified lines | |||
| 810 | }; | 822 | }; |
| 811 | 823 | ||
| 812 | export const useLocalInstallation = (): Loadable<LocalInstallation> => | 824 | export const useLocalInstallation = (): Loadable<LocalInstallation> => |
| 813 | useLoadableAtom(localInstallationAtom); | 825 | useLoadableAtom(localInstallationAtom(apiBaseUrl)); |
| 814 | 826 | ||
| 815 | export const useInstanceConfig = (): Loadable<InstanceConfig> => | 827 | export const useInstanceConfig = (): Loadable<InstanceConfig> => |
| 816 | useLoadableAtom(instanceConfigAtom); | 828 | useLoadableAtom(instanceConfigAtom(apiBaseUrl)); |
| 817 | 829 | ||
| 818 | export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => | 830 | export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> => |
| 819 | useLoadableAtom(secretsAtom); | 831 | useLoadableAtom(secretsAtom(apiBaseUrl)); |
| 820 | 832 | ||
| 821 | export const useRefreshSecrets = (): (() => void) => { | 833 | export const useRefreshSecrets = (): (() => void) => { |
| 822 | const registry = React.useContext(RegistryContext); | 834 | const registry = React.useContext(RegistryContext); |
| 823 | return React.useCallback(() => { | 835 | return React.useCallback(() => { |
| 824 | registry.refresh(secretsAtom); | 836 | registry.refresh(secretsAtom(apiBaseUrl)); |
| 825 | }, [registry]); | 837 | }, [registry]); |
| 826 | }; | 838 | }; |
| 827 | 839 | ||
| @@ -850,7 +862,7 @@ const useSecretMutation = <TInput, TOutput>( · 22 unmodified lines | |||
| 850 | 862 | ||
| 851 | try { | 863 | try { |
| 852 | const data = await execute(payload); | 864 | const data = await execute(payload); |
| 853 | registry.refresh(secretsAtom); | 865 | registry.refresh(secretsAtom(apiBaseUrl)); |
| 854 | setState({ status: "success", data, error: null }); | 866 | setState({ status: "success", data, error: null }); |
| 855 | return data; | 867 | return data; |
| 856 | } catch (cause) { | 868 | } catch (cause) { |
| @@ -237,4 +237,3 @@ export const makeSesExecutor = ( · 236 unmodified lines | |||
| 237 | ): CodeExecutor => ({ | 237 | ): CodeExecutor => ({ |
| 238 | execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker), | 238 | execute: (code: string, toolInvoker: ToolInvoker) => runInSes(options, code, toolInvoker), |
| 239 | }); | 239 | }); |
| 240 | |||
| @@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines | |||
| 24 | 24 | ||
| 25 | const formatLogLine = (args) => args.map(formatLogArg).join(" "); | 25 | const formatLogLine = (args) => args.map(formatLogArg).join(" "); |
| 26 | 26 | ||
| 27 | const toSerializableValue = (value) => { | ||
| 28 | if (typeof value === "undefined") { | ||
| 29 | return undefined; | ||
| 30 | } | ||
| 31 | |||
| 32 | return JSON.parse(JSON.stringify(value)); | ||
| 33 | }; | ||
| 34 | |||
| 27 | const buildExecutionSource = (code) => { | 35 | const buildExecutionSource = (code) => { |
| 28 | const trimmed = code.trim(); | 36 | const trimmed = code.trim(); |
| 29 | const looksLikeArrowFunction = | 37 | const looksLikeArrowFunction = |
| @@ -157,7 +165,7 @@ process.on("message", async (message) => { · 127 unmodified lines | |||
| 157 | process.send?.({ | 165 | process.send?.({ |
| 158 | type: "result", | 166 | type: "result", |
| 159 | id: message.id, | 167 | id: message.id, |
| 160 | value: result, | 168 | value: toSerializableValue(result), |
| 161 | logs, | 169 | logs, |
| 162 | }); | 170 | }); |
| 163 | } catch (error) { | 171 | } catch (error) { |
| @@ -11,10 +11,10 @@ import { Readable } from "node:stream"; · 10 unmodified lines | |||
| 11 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; | 11 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 12 | import { | 12 | import { |
| 13 | createControlPlaneApiLayer, | 13 | createControlPlaneApiLayer, |
| 14 | createSqlControlPlaneRuntime, | 14 | createControlPlaneRuntime, |
| 15 | type ResolveExecutionEnvironment, | 15 | type ResolveExecutionEnvironment, |
| 16 | type ResolveSecretMaterial, | 16 | type ResolveSecretMaterial, |
| 17 | type SqlControlPlaneRuntime, | 17 | type ControlPlaneRuntime, |
| 18 | } from "@executor/control-plane"; | 18 | } from "@executor/control-plane"; |
| 19 | import { createExecutorMcpRequestHandler } from "@executor/executor-mcp"; | 19 | import { createExecutorMcpRequestHandler } from "@executor/executor-mcp"; |
| 20 | import * as Cause from "effect/Cause"; | 20 | import * as Cause from "effect/Cause"; |
| @@ -58,7 +58,7 @@ type StaticUiOptions = { · 37 unmodified lines | |||
| 58 | }; | 58 | }; |
| 59 | 59 | ||
| 60 | export type LocalExecutorServer = { | 60 | export type LocalExecutorServer = { |
| 61 | readonly runtime: SqlControlPlaneRuntime; | 61 | readonly runtime: ControlPlaneRuntime; |
| 62 | readonly port: number; | 62 | readonly port: number; |
| 63 | readonly host: string; | 63 | readonly host: string; |
| 64 | readonly baseUrl: string; | 64 | readonly baseUrl: string; |
| @@ -68,6 +68,7 @@ export type StartLocalExecutorServerOptions = { · 3 unmodified lines | |||
| 68 | readonly port?: number; | 68 | readonly port?: number; |
| 69 | readonly host?: string; | 69 | readonly host?: string; |
| 70 | readonly localDataDir?: string; | 70 | readonly localDataDir?: string; |
| 71 | readonly workspaceRoot?: string; | ||
| 71 | readonly migrationsFolder?: string; | 72 | readonly migrationsFolder?: string; |
| 72 | readonly pidFile?: string; | 73 | readonly pidFile?: string; |
| 73 | readonly executionResolver?: ResolveExecutionEnvironment; | 74 | readonly executionResolver?: ResolveExecutionEnvironment; |
| @@ -76,7 +77,7 @@ export type StartLocalExecutorServerOptions = { · 2 unmodified lines | |||
| 76 | }; | 77 | }; |
| 77 | 78 | ||
| 78 | export type LocalExecutorRequestHandler = { | 79 | export type LocalExecutorRequestHandler = { |
| 79 | readonly runtime: SqlControlPlaneRuntime; | 80 | readonly runtime: ControlPlaneRuntime; |
| 80 | readonly handleApiRequest: (request: Request) => Promise<Response>; | 81 | readonly handleApiRequest: (request: Request) => Promise<Response>; |
| 81 | readonly getBaseUrl: () => string | undefined; | 82 | readonly getBaseUrl: () => string | undefined; |
| 82 | readonly setBaseUrl: (baseUrl: string) => void; | 83 | readonly setBaseUrl: (baseUrl: string) => void; |
| @@ -85,7 +86,7 @@ export type LocalExecutorRequestHandler = { · 2 unmodified lines | |||
| 85 | type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>; | 86 | type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>; |
| 86 | type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; | 87 | type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; |
| 87 | 88 | ||
| 88 | const disposeRuntime = (runtime: SqlControlPlaneRuntime) => | 89 | const disposeRuntime = (runtime: ControlPlaneRuntime) => |
| 89 | Effect.tryPromise({ | 90 | Effect.tryPromise({ |
| 90 | try: () => runtime.close(), | 91 | try: () => runtime.close(), |
| 91 | catch: (cause) => | 92 | catch: (cause) => |
| @@ -97,9 +98,10 @@ const createRuntime = ( · 5 unmodified lines | |||
| 97 | getLocalServerBaseUrl: () => string | undefined, | 98 | getLocalServerBaseUrl: () => string | undefined, |
| 98 | options: StartLocalExecutorServerOptions, | 99 | options: StartLocalExecutorServerOptions, |
| 99 | ) => | 100 | ) => |
| 100 | createSqlControlPlaneRuntime({ | 101 | createControlPlaneRuntime({ |
| 101 | localDataDir, | 102 | localDataDir, |
| 102 | migrationsFolder: options.migrationsFolder, | 103 | migrationsFolder: options.migrationsFolder, |
| 104 | workspaceRoot: options.workspaceRoot, | ||
| 103 | executionResolver: options.executionResolver, | 105 | executionResolver: options.executionResolver, |
| 104 | resolveSecretMaterial: options.resolveSecretMaterial, | 106 | resolveSecretMaterial: options.resolveSecretMaterial, |
| 105 | getLocalServerBaseUrl, | 107 | getLocalServerBaseUrl, |
| @@ -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 | Effect.acquireRelease( | 213 | Effect.acquireRelease( |
| 212 | Effect.sync(() => | 214 | Effect.sync(() => |
| 213 | HttpApiBuilder.toWebHandler( | 215 | HttpApiBuilder.toWebHandler( |