History

Merge pull request #21 from RhysSullivan/codex/local-first-config

Rhys Sullivan committed 8 months ago

Commit e5076902 parents fba2511 f858ab8106 files · +4916 −7687Browse files

Unified · Split

106 files
  1. apps/executor/src/cli/interaction-handling.test.ts +1 −1
  2. apps/executor/src/cli/main.ts +5 −5
  3. apps/executor/src/distribution/portable.test.ts +8 −3
  4. apps/executor/src/server/server.test.ts +158 −175
  5. bun.lock +5 −4
  6. packages/control-plane/package.json +1 −0
  7. packages/control-plane/src/api/api.ts +1 −7
  8. packages/control-plane/src/api/auth/actor-resolver.ts +0 −31
  9. packages/control-plane/src/api/auth/workspace-membership.test.ts +0 −59
  10. packages/control-plane/src/api/auth/workspace-membership.ts +0 −118
  11. packages/control-plane/src/api/executions/http.ts +12 −20
  12. packages/control-plane/src/api/http-auth.ts +0 −98
  13. packages/control-plane/src/api/http.ts +0 −14
  14. packages/control-plane/src/api/index.ts +0 −32
  15. packages/control-plane/src/api/local-context.ts +25 −0
  16. packages/control-plane/src/api/memberships/api.ts +0 −84
  17. packages/control-plane/src/api/memberships/http.ts +0 −67
  18. packages/control-plane/src/api/oauth/http.ts +5 −12
  19. packages/control-plane/src/api/organizations/api.ts +0 −82
  20. packages/control-plane/src/api/organizations/http.ts +0 −75
  21. packages/control-plane/src/api/payload-schemas.test.ts +6 −35
  22. packages/control-plane/src/api/policies/api.ts +13 −79
  23. packages/control-plane/src/api/policies/http.ts +12 −91
  24. packages/control-plane/src/api/sources/http.ts +44 −54
  25. packages/control-plane/src/api/workspaces/api.ts +0 −83
  26. packages/control-plane/src/api/workspaces/http.ts +0 −89
  27. packages/control-plane/src/auth-headers.ts +0 −7
  28. packages/control-plane/src/client.ts +2 −19
  29. packages/control-plane/src/domain/auth/actor.test.ts +0 −98
  30. packages/control-plane/src/domain/auth/actor.ts +0 −239
  31. packages/control-plane/src/domain/auth/index.ts +0 −21
  32. packages/control-plane/src/domain/auth/policy.test.ts +0 −34
  33. packages/control-plane/src/domain/auth/policy.ts +0 −55
  34. packages/control-plane/src/domain/index.ts +0 −1
  35. packages/control-plane/src/index.ts +0 −2
  36. packages/control-plane/src/persistence/control-plane-rows.test.ts +0 −947
  37. packages/control-plane/src/persistence/index.ts +0 −10
  38. packages/control-plane/src/persistence/post-migrations.test.ts +86 −108
  39. packages/control-plane/src/persistence/repos/index.ts +0 −5
  40. packages/control-plane/src/persistence/repos/local-installations-repo.ts +0 −48
  41. packages/control-plane/src/persistence/repos/organization-memberships-repo.ts +0 −110
  42. packages/control-plane/src/persistence/repos/organizations-repo.ts +0 −233
  43. packages/control-plane/src/persistence/repos/policies-repo.ts +0 −118
  44. packages/control-plane/src/persistence/repos/workspaces-repo.ts +0 −158
  45. packages/control-plane/src/persistence/schema.ts +0 −167
  46. packages/control-plane/src/runtime/actor-resolver.ts +0 −160
  47. packages/control-plane/src/runtime/control-plane-runtime.test.ts +97 −428
  48. packages/control-plane/src/runtime/execution-http.test.ts +7 −2
  49. packages/control-plane/src/runtime/execution-mcp-resume.test.ts +7 −2
  50. packages/control-plane/src/runtime/execution-service.ts +1 −1
  51. packages/control-plane/src/runtime/executor-tools.ts +29 −5
  52. packages/control-plane/src/runtime/index.ts +117 −34
  53. packages/control-plane/src/runtime/invocation-policy-engine.test.ts +25 −58
  54. packages/control-plane/src/runtime/invocation-policy-engine.ts +18 −195
  55. packages/control-plane/src/runtime/local-config-secrets.ts +9 −0
  56. packages/control-plane/src/runtime/local-config.test.ts +128 −0
  57. packages/control-plane/src/runtime/local-config.ts +450 −0
  58. packages/control-plane/src/runtime/local-errors.ts +54 −0
  59. packages/control-plane/src/runtime/local-installation.test.ts +22 −18
  60. packages/control-plane/src/runtime/local-installation.ts +25 −104
  61. packages/control-plane/src/runtime/local-operations.ts +13 −22
  62. packages/control-plane/src/runtime/local-runtime-context.ts +71 −0
  63. packages/control-plane/src/runtime/local-source-artifacts.ts +230 −0
  64. packages/control-plane/src/runtime/local-workspace-state.ts +124 −0
  65. packages/control-plane/src/runtime/local-workspace-sync.ts +93 −0
  66. packages/control-plane/src/runtime/operation-errors.ts +1 −1
  67. packages/control-plane/src/runtime/operations-shared.ts +1 −56
  68. packages/control-plane/src/runtime/organizations-operations.ts +0 −434
  69. packages/control-plane/src/runtime/policies-operations.ts +297 −306
  70. packages/control-plane/src/runtime/secret-material-providers.ts +178 −1
  71. packages/control-plane/src/runtime/source-adapters/mcp.ts +1 −1
  72. packages/control-plane/src/runtime/source-auth-service.test.ts +67 −60
  73. packages/control-plane/src/runtime/source-auth-service.ts +105 −62
  74. packages/control-plane/src/runtime/source-inspection.test.ts +150 −118
  75. packages/control-plane/src/runtime/source-inspection.ts +40 −51
  76. packages/control-plane/src/runtime/source-materialization.ts +82 −7
  77. packages/control-plane/src/runtime/source-recipes-runtime.test.ts +362 −244
  78. packages/control-plane/src/runtime/source-recipes-runtime.ts +116 −265
  79. packages/control-plane/src/runtime/source-store.test.ts +113 −348
  80. packages/control-plane/src/runtime/source-store.ts +508 −164
  81. packages/control-plane/src/runtime/sources-operations.ts +90 −85
  82. packages/control-plane/src/runtime/test-http-client.ts +5 −18
  83. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +316 −197
  84. packages/control-plane/src/runtime/workspace-execution-environment.ts +92 −81
  85. packages/control-plane/src/schema/enums.ts +4 −18
  86. packages/control-plane/src/schema/ids.ts +0 −8
  87. packages/control-plane/src/schema/index.ts +1 −1
  88. packages/control-plane/src/schema/models/auth/index.ts +0 −5
  89. packages/control-plane/src/schema/models/auth/membership.ts +0 −24
  90. packages/control-plane/src/schema/models/auth/organization-membership.ts +0 −55
  91. packages/control-plane/src/schema/models/auth/organization.ts +0 −42
  92. packages/control-plane/src/schema/models/auth/permission.ts +0 −36
  93. packages/control-plane/src/schema/models/auth/role.ts +0 −32
  94. packages/control-plane/src/schema/models/local-config.ts +185 −0
  95. packages/control-plane/src/schema/models/local-installation.ts +5 −29
  96. packages/control-plane/src/schema/models/policy.ts +18 −69
  97. packages/control-plane/src/schema/models/workspace.ts +0 −32
  98. packages/control-plane/src/schema/schema.test.ts +0 −9
  99. packages/executor-mcp/src/index.ts +14 −63
  100. packages/executor-mcp/src/paused-result.test.ts +97 −0
  101. packages/executor-mcp/src/paused-result.ts +95 −0
  102. packages/react/src/index.test.tsx +14 −4
  103. packages/react/src/index.ts +37 −25
  104. packages/runtime-ses/src/index.ts +0 −1
  105. packages/runtime-ses/src/sandbox-worker.mjs +9 −1
  106. packages/server/src/index.ts +9 −7
apps/executor/src/cli/interaction-handling.test.ts+1−1
@@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; · 2 unmodified lines
3import { decideInteractionHandling } from "./interaction-handling";3import { decideInteractionHandling } from "./interaction-handling";
4 4
5describe("interaction-handling", () => {5describe("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",
apps/executor/src/cli/main.ts+5−5
@@ -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";
24import type { ToolCatalog } from "@executor/codemode-core";24import 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
250const closeSqlRuntime = (runtime: SqlControlPlaneRuntime) =>250const 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
275const loadRunWorkflowText = (): Effect.Effect<string, Error, never> =>275const 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(
apps/executor/src/distribution/portable.test.ts+8−3
@@ -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);
apps/executor/src/server/server.test.ts+158−175
@@ -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";
12import { NodeFileSystem } from "@effect/platform-node";
11import { describe, expect, it } from "@effect/vitest";13import { describe, expect, it } from "@effect/vitest";
12import { assertTrue } from "@effect/vitest/utils";14import { assertTrue } from "@effect/vitest/utils";
13import * as Effect from "effect/Effect";15import * 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";
27import { makeSesExecutor } from "@executor/runtime-ses";34import { makeSesExecutor } from "@executor/runtime-ses";
28import { Client } from "@modelcontextprotocol/sdk/client/index.js";35import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -58,7 +65,115 @@ const executionResolver: ResolveExecutionEnvironment = () => · 29 unmodified lines
58 }),65 }),
59 });66 });
60 67
61const makeServer = createLocalExecutorServer({68const makeTempWorkspaceRoot = () =>
69 FileSystem.FileSystem.pipe(
70 Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "executor-server-test-" })),
71 Effect.provide(NodeFileSystem.layer),
72 );
73
74const 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
89const 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
176const 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
582const 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
604describe("local-executor-server", () => {697describe("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({
bun.lock+5−4
@@ -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
packages/control-plane/package.json+1−0
@@ -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 },
packages/control-plane/src/api/api.ts+1−7
@@ -2,26 +2,20 @@ import { HttpApi, OpenApi } from "@effect/platform"; · 1 unmodified line
2 2
3import { ExecutionsApi } from "./executions/api";3import { ExecutionsApi } from "./executions/api";
4import { LocalApi } from "./local/api";4import { LocalApi } from "./local/api";
5import { MembershipsApi } from "./memberships/api";
6import { OAuthApi } from "./oauth/api";5import { OAuthApi } from "./oauth/api";
7import { OrganizationsApi } from "./organizations/api";
8import { PoliciesApi } from "./policies/api";6import { PoliciesApi } from "./policies/api";
9import { SourcesApi } from "./sources/api";7import { SourcesApi } from "./sources/api";
10import { WorkspacesApi } from "./workspaces/api";
11 8
12export class ControlPlaneApi extends HttpApi.make("controlPlane")9export 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
packages/control-plane/src/api/auth/actor-resolver.ts+0−31
@@ -1,31 +0,0 @@
1import { type WorkspaceId } from "#schema";
2import * as PlatformHeaders from "@effect/platform/Headers";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5
6import {
7 type ActorShape,
8 type ActorUnauthenticatedError,
9} from "#domain";
10
11export type ResolveActorInput = {
12 headers: PlatformHeaders.Headers;
13};
14
15export type ResolveWorkspaceActorInput = {
16 workspaceId: WorkspaceId;
17 headers: PlatformHeaders.Headers;
18};
19
20export type ControlPlaneActorResolverShape = {
21 resolveActor: (
22 input: ResolveActorInput,
23 ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
24 resolveWorkspaceActor: (
25 input: ResolveWorkspaceActorInput,
26 ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
27};
28
29export class ControlPlaneActorResolver extends Context.Tag(
30 "#api/ControlPlaneActorResolver",
31)<ControlPlaneActorResolver, ControlPlaneActorResolverShape>() {}
packages/control-plane/src/api/auth/workspace-membership.test.ts+0−59
@@ -1,59 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 AccountIdSchema,
4 OrganizationIdSchema,
5 OrganizationMemberIdSchema,
6 WorkspaceIdSchema,
7} from "#schema";
8
9import { deriveWorkspaceMembershipsForPrincipal } from "./workspace-membership";
10
11describe("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});
packages/control-plane/src/api/auth/workspace-membership.ts+0−118
@@ -1,118 +0,0 @@
1import {
2 type AccountId,
3 type OrganizationMembership,
4 type OrganizationMemberStatus,
5 type Role,
6 type Workspace,
7 type WorkspaceMembership,
8} from "#schema";
9
10type WorkspaceMembershipCandidate = Pick<
11 WorkspaceMembership,
12 "role" | "status" | "grantedAt" | "updatedAt"
13>;
14
15const roleRank: Readonly<Record<Role, number>> = {
16 viewer: 0,
17 editor: 1,
18 admin: 2,
19 owner: 3,
20};
21
22const 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
35const 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
47export 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};
packages/control-plane/src/api/executions/http.ts+12−20
@@ -1,7 +1,5 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import type { WorkspaceId } from "#schema";2import * as Effect from "effect/Effect";
3
4import { requirePermission, withPolicy } from "#domain";
5import {3import {
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
11import { ControlPlaneApi } from "../api";9import { ControlPlaneApi } from "../api";
12import { withWorkspaceRequestActor } from "../http-auth";10import { resolveRequestedLocalWorkspace } from "../local-context";
13
14const requireExecuteWorkspace = (workspaceId: WorkspaceId) =>
15 requirePermission({
16 permission: "workspace:read",
17 workspaceId,
18 });
19 11
20export const ControlPlaneExecutionsLive = HttpApiBuilder.group(12export 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 handlers16 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 ),
packages/control-plane/src/api/http-auth.ts+0−98
@@ -1,98 +0,0 @@
1import { HttpServerRequest } from "@effect/platform";
2import type { WorkspaceId } from "#schema";
3import {
4 Actor,
5 ActorForbiddenError,
6 type ActorShape,
7 ActorUnauthenticatedError,
8} from "#domain";
9import * as Effect from "effect/Effect";
10
11import { ControlPlaneActorResolver } from "./auth/actor-resolver";
12import {
13 ControlPlaneForbiddenError,
14 ControlPlaneUnauthorizedError,
15} from "./errors";
16
17const requestHeaders = Effect.map(
18 HttpServerRequest.HttpServerRequest,
19 (request) => request.headers,
20);
21
22const readString = (value: unknown, fallback: string): string =>
23 typeof value === "string" && value.length > 0 ? value : fallback;
24
25const 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
35const 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
45export const resolveRequestActor = Effect.flatMap(
46 ControlPlaneActorResolver,
47 (actorResolver) =>
48 Effect.flatMap(requestHeaders, (headers) => actorResolver.resolveActor({ headers })),
49);
50
51export const resolveWorkspaceRequestActor = (workspaceId: WorkspaceId) =>
52 Effect.flatMap(ControlPlaneActorResolver, (actorResolver) =>
53 Effect.flatMap(requestHeaders, (headers) =>
54 actorResolver.resolveWorkspaceActor({ workspaceId, headers })
55 ));
56
57const provideActor = <A, E, R>(
58 actor: ActorShape,
59 effect: Effect.Effect<A, E, R>,
60) => effect.pipe(Effect.provideService(Actor, actor));
61
62export 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
75export 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
87export 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 );
packages/control-plane/src/api/http.ts+0−14
@@ -2,25 +2,15 @@ import { HttpApiBuilder } from "@effect/platform"; · 1 unmodified line
2import * as Layer from "effect/Layer";2import * as Layer from "effect/Layer";
3 3
4import { ControlPlaneApi } from "./api";4import { ControlPlaneApi } from "./api";
5import {
6 ControlPlaneActorResolver,
7 type ControlPlaneActorResolverShape,
8} from "./auth/actor-resolver";
9import { ControlPlaneExecutionsLive } from "./executions/http";5import { ControlPlaneExecutionsLive } from "./executions/http";
10import { ControlPlaneLocalLive } from "./local/http";6import { ControlPlaneLocalLive } from "./local/http";
11import { ControlPlaneMembershipsLive } from "./memberships/http";
12import { ControlPlaneOAuthLive } from "./oauth/http";7import { ControlPlaneOAuthLive } from "./oauth/http";
13import { ControlPlaneOrganizationsLive } from "./organizations/http";
14import { ControlPlanePoliciesLive } from "./policies/http";8import { ControlPlanePoliciesLive } from "./policies/http";
15import { ControlPlaneSourcesLive } from "./sources/http";9import { ControlPlaneSourcesLive } from "./sources/http";
16import { ControlPlaneWorkspacesLive } from "./workspaces/http";
17 10
18export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(11export 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
44export const ControlPlaneActorResolverLive = (
45 resolver: ControlPlaneActorResolverShape,
46) => Layer.succeed(ControlPlaneActorResolver, resolver);
packages/control-plane/src/api/index.ts+0−32
@@ -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
24export {
25 ControlPlaneActorResolver,
26 type ControlPlaneActorResolverShape,
27 type ResolveActorInput,
28 type ResolveWorkspaceActorInput,
29} from "./auth/actor-resolver";
30
31export { deriveWorkspaceMembershipsForPrincipal } from "./auth/workspace-membership";
32
33
34export {23export {
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
63export {
64 CreateOrganizationPayloadSchema,
65 UpdateOrganizationPayloadSchema,
66 type CreateOrganizationPayload,
67 type UpdateOrganizationPayload,
68} from "./organizations/api";
69
70export {
71 CreateMembershipPayloadSchema,
72 UpdateMembershipPayloadSchema,
73 type CreateMembershipPayload,
74 type UpdateMembershipPayload,
75} from "./memberships/api";
76
77export {
78 CreateWorkspacePayloadSchema,
79 UpdateWorkspacePayloadSchema,
80 type CreateWorkspacePayload,
81 type UpdateWorkspacePayload,
82} from "./workspaces/api";
83
84export {52export {
85 ConnectSourcePayloadSchema,53 ConnectSourcePayloadSchema,
86 ConnectSourceResultSchema,54 ConnectSourceResultSchema,
packages/control-plane/src/api/local-context.ts+25−0
@@ -0,0 +1,25 @@
1import type { WorkspaceId } from "#schema";
2import * as Effect from "effect/Effect";
3
4import {
5 RuntimeLocalWorkspaceMismatchError,
6} from "../runtime/local-errors";
7import { requireRuntimeLocalWorkspace } from "../runtime/local-runtime-context";
8import { ControlPlaneForbiddenError } from "./errors";
9
10export 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 );
packages/control-plane/src/api/memberships/api.ts+0−84
@@ -1,84 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 AccountIdSchema,
4 OrganizationIdSchema,
5 OrganizationMembershipInsertSchema,
6 OrganizationMembershipSchema,
7 OrganizationMembershipUpdateSchema,
8} from "#schema";
9import * as Schema from "effect/Schema";
10
11import {
12 ControlPlaneBadRequestError,
13 ControlPlaneForbiddenError,
14 ControlPlaneNotFoundError,
15 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,
17} from "../errors";
18
19const createMembershipPayloadRequiredSchema = OrganizationMembershipInsertSchema.pipe(
20 Schema.pick("accountId", "role"),
21);
22
23const createMembershipPayloadOptionalSchema = OrganizationMembershipInsertSchema.pipe(
24 Schema.pick("status", "billable", "invitedByAccountId"),
25 Schema.partialWith({ exact: true }),
26);
27
28export const CreateMembershipPayloadSchema = Schema.extend(
29 createMembershipPayloadRequiredSchema,
30 createMembershipPayloadOptionalSchema,
31);
32
33export type CreateMembershipPayload = typeof CreateMembershipPayloadSchema.Type;
34
35export const UpdateMembershipPayloadSchema = OrganizationMembershipUpdateSchema.pipe(
36 Schema.pick("role", "status", "billable"),
37 Schema.partialWith({ exact: true }),
38);
39
40export type UpdateMembershipPayload = typeof UpdateMembershipPayloadSchema.Type;
41
42const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
43const accountIdParam = HttpApiSchema.param("accountId", AccountIdSchema);
44
45export 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") {}
packages/control-plane/src/api/memberships/http.ts+0−67
@@ -1,67 +0,0 @@
1import { HttpApiBuilder } from "@effect/platform";
2import type { OrganizationId } from "#schema";
3
4import { requirePermission, withPolicy } from "#domain";
5import {
6 createMembership,
7 listMemberships,
8 removeMembership,
9 updateMembership,
10} from "../../runtime/organizations-operations";
11
12import { ControlPlaneApi } from "../api";
13import { withRequestActor } from "../http-auth";
14
15const requireReadMemberships = (organizationId: OrganizationId) =>
16 requirePermission({
17 permission: "memberships:read",
18 organizationId,
19 });
20
21const requireWriteMemberships = (organizationId: OrganizationId) =>
22 requirePermission({
23 permission: "memberships:write",
24 organizationId,
25 });
26
27export 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);
packages/control-plane/src/api/oauth/http.ts+5−12
@@ -8,27 +8,20 @@ import * as Cause from "effect/Cause"; · 7 unmodified lines
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10 10
11import { requirePermission, withPolicy } from "#domain";
12import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service";11import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service";
13import { ControlPlaneApi } from "../api";12import { ControlPlaneApi } from "../api";
14import {13import {
15 ControlPlaneBadRequestError,14 ControlPlaneBadRequestError,
16 ControlPlaneStorageError,15 ControlPlaneStorageError,
17} from "../errors";16} from "../errors";
17import { resolveRequestedLocalWorkspace } from "../local-context";
18import {18import {
19 SourceOAuthPopupResultSchema,19 SourceOAuthPopupResultSchema,
20 type SourceOAuthPopupResult,20 type SourceOAuthPopupResult,
21} from "./api";21} from "./api";
22import { withWorkspaceRequestActor } from "../http-auth";
23 22
24const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";23const OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
25 24
26const requireWriteSources = (workspaceId: WorkspaceId) =>
27 requirePermission({
28 permission: "sources:write",
29 workspaceId,
30 });
31
32const escapeHtml = (value: string): string =>25const escapeHtml = (value: string): string =>
33 value26 value
34 .replaceAll("&", "&amp;")27 .replaceAll("&", "&amp;")
@@ -241,14 +234,14 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 206 unmodified lines
241 (handlers) =>234 (handlers) =>
242 handlers235 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 )
packages/control-plane/src/api/organizations/api.ts+0−82
@@ -1,82 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 OrganizationIdSchema,
4 OrganizationStatusSchema,
5 OrganizationSchema,
6} from "#schema";
7import * as Schema from "effect/Schema";
8
9import {
10 ControlPlaneBadRequestError,
11 ControlPlaneForbiddenError,
12 ControlPlaneNotFoundError,
13 ControlPlaneStorageError,
14 ControlPlaneUnauthorizedError,
15} from "../errors";
16import {
17 OptionalTrimmedNonEmptyStringSchema,
18 TrimmedNonEmptyStringSchema,
19} from "../string-schemas";
20
21export const CreateOrganizationPayloadSchema = Schema.Struct({
22 name: TrimmedNonEmptyStringSchema,
23 slug: OptionalTrimmedNonEmptyStringSchema,
24});
25
26export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type;
27
28export const UpdateOrganizationPayloadSchema = Schema.Struct({
29 name: OptionalTrimmedNonEmptyStringSchema,
30 status: Schema.optional(OrganizationStatusSchema),
31});
32
33export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type;
34
35const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
36
37export 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") {}
packages/control-plane/src/api/organizations/http.ts+0−75
@@ -1,75 +0,0 @@
1import { HttpApiBuilder } from "@effect/platform";
2import type { OrganizationId } from "#schema";
3
4import { requirePermission, withPolicy } from "#domain";
5import {
6 createOrganization,
7 getOrganization,
8 listOrganizations,
9 removeOrganization,
10 updateOrganization,
11} from "../../runtime/organizations-operations";
12
13import { ControlPlaneApi } from "../api";
14import { withRequestActor } from "../http-auth";
15
16const requireReadOrganizations = requirePermission({
17 permission: "organizations:read",
18});
19
20const requireManageOrganizations = (organizationId: OrganizationId) =>
21 requirePermission({
22 permission: "organizations:manage",
23 organizationId,
24 });
25
26export 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);
packages/control-plane/src/api/payload-schemas.test.ts+6−35
@@ -5,38 +5,15 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines
5import {5import {
6 CreateExecutionPayloadSchema,6 CreateExecutionPayloadSchema,
7} from "./executions/api";7} from "./executions/api";
8import {
9 CreateOrganizationPayloadSchema,
10 UpdateOrganizationPayloadSchema,
11} from "./organizations/api";
12import { CreatePolicyPayloadSchema } from "./policies/api";8import { CreatePolicyPayloadSchema } from "./policies/api";
13import {9import {
14 ConnectSourcePayloadSchema,10 ConnectSourcePayloadSchema,
15 CreateSourcePayloadSchema,11 CreateSourcePayloadSchema,
16 UpdateSourcePayloadSchema,12 UpdateSourcePayloadSchema,
17} from "./sources/api";13} from "./sources/api";
18import { CreateWorkspacePayloadSchema } from "./workspaces/api";
19 14
20describe("control-plane payload schemas", () => {15describe("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});
packages/control-plane/src/api/policies/api.ts+13−79
@@ -1,13 +1,9 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
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";
13import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
@@ -21,91 +17,29 @@ import { · 7 unmodified lines
21} from "../errors";17} from "../errors";
22import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas";18import { OptionalTrimmedNonEmptyStringSchema } from "../string-schemas";
23 19
24export const CreatePolicyPayloadSchema = Schema.Struct({20const 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
28export const CreatePolicyPayloadSchema = LocalWorkspacePolicyPayloadSchema;
29
37export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type;30export type CreatePolicyPayload = typeof CreatePolicyPayloadSchema.Type;
38 31
39export const UpdatePolicyPayloadSchema = Schema.Struct({32export 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
52export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;34export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;
53 35
54const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
55const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);36const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
56const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema);37const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema);
57 38
58export class PoliciesApi extends HttpApiGroup.make("policies")39export 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)
packages/control-plane/src/api/policies/http.ts+12−91
@@ -1,117 +1,38 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import type { OrganizationId, WorkspaceId } from "#schema";2import * as Effect from "effect/Effect";
3
4import { requirePermission, withPolicy } from "#domain";
5import {3import {
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
18import { ControlPlaneApi } from "../api";11import { ControlPlaneApi } from "../api";
19import { withRequestActor, withWorkspaceRequestActor } from "../http-auth";12import { resolveRequestedLocalWorkspace } from "../local-context";
20
21const requireReadOrganizationPolicies = (organizationId: OrganizationId) =>
22 requirePermission({
23 permission: "policies:read",
24 organizationId,
25 });
26
27const requireWriteOrganizationPolicies = (organizationId: OrganizationId) =>
28 requirePermission({
29 permission: "policies:write",
30 organizationId,
31 });
32
33const requireReadPolicies = (workspaceId: WorkspaceId) =>
34 requirePermission({
35 permission: "policies:read",
36 workspaceId,
37 });
38
39const requireWritePolicies = (workspaceId: WorkspaceId) =>
40 requirePermission({
41 permission: "policies:write",
42 workspaceId,
43 });
44 13
45export const ControlPlanePoliciesLive = HttpApiBuilder.group(14export const ControlPlanePoliciesLive = HttpApiBuilder.group(
46 ControlPlaneApi,15 ControlPlaneApi,
47 "policies",16 "policies",
48 (handlers) =>17 (handlers) =>
49 handlers18 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,
packages/control-plane/src/api/sources/http.ts+44−54
@@ -3,14 +3,9 @@ import { · 2 unmodified lines
3 HttpServerRequest,3 HttpServerRequest,
4 HttpServerResponse,4 HttpServerResponse,
5} from "@effect/platform";5} from "@effect/platform";
6import type {6import type { ExecutionInteraction, Source, WorkspaceId } from "#schema";
7 ExecutionInteraction,
8 Source,
9 WorkspaceId,
10} from "#schema";
11import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
12 8
13import { requirePermission, withPolicy } from "#domain";
14import {9import {
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";
41import { ControlPlaneApi } from "../api";36import { ControlPlaneApi } from "../api";
42import { withRequestActor, withWorkspaceRequestActor } from "../http-auth";37import { resolveRequestedLocalWorkspace } from "../local-context";
43
44const requireReadSources = (workspaceId: WorkspaceId) =>
45 requirePermission({
46 permission: "sources:read",
47 workspaceId,
48 });
49
50const requireWriteSources = (workspaceId: WorkspaceId) =>
51 requirePermission({
52 permission: "sources:write",
53 workspaceId,
54 });
55 38
56const readHeader = (headers: unknown, name: string): string | null => {39const 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 handlers649 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,
packages/control-plane/src/api/workspaces/api.ts+0−83
@@ -1,83 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 OrganizationIdSchema,
4 WorkspaceIdSchema,
5 WorkspaceSchema,
6} from "#schema";
7import * as Schema from "effect/Schema";
8
9import {
10 ControlPlaneBadRequestError,
11 ControlPlaneForbiddenError,
12 ControlPlaneNotFoundError,
13 ControlPlaneStorageError,
14 ControlPlaneUnauthorizedError,
15} from "../errors";
16import {
17 OptionalTrimmedNonEmptyStringSchema,
18 TrimmedNonEmptyStringSchema,
19} from "../string-schemas";
20
21export const CreateWorkspacePayloadSchema = Schema.Struct({
22 name: TrimmedNonEmptyStringSchema,
23});
24
25export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type;
26
27export const UpdateWorkspacePayloadSchema = Schema.Struct({
28 name: OptionalTrimmedNonEmptyStringSchema,
29});
30
31export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type;
32
33const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
34const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
35
36export 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") {}
packages/control-plane/src/api/workspaces/http.ts+0−89
@@ -1,89 +0,0 @@
1import { HttpApiBuilder } from "@effect/platform";
2import type { OrganizationId, WorkspaceId } from "#schema";
3
4import { requirePermission, withPolicy } from "#domain";
5import {
6 createWorkspace,
7 getWorkspace,
8 listWorkspaces,
9 removeWorkspace,
10 updateWorkspace,
11} from "../../runtime/organizations-operations";
12
13import { ControlPlaneApi } from "../api";
14import {
15 withRequestActor,
16 withWorkspaceRequestActor,
17} from "../http-auth";
18
19const requireReadWorkspace = (workspaceId: WorkspaceId) =>
20 requirePermission({
21 permission: "workspace:read",
22 workspaceId,
23 });
24
25const requireManageWorkspace = (workspaceId: WorkspaceId) =>
26 requirePermission({
27 permission: "workspace:manage",
28 workspaceId,
29 });
30
31const requireOrganizationWorkspaceRead = (organizationId: OrganizationId) =>
32 requirePermission({
33 permission: "workspace:read",
34 organizationId,
35 });
36
37const requireOrganizationWorkspaceManage = (organizationId: OrganizationId) =>
38 requirePermission({
39 permission: "workspace:manage",
40 organizationId,
41 });
42
43export 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);
packages/control-plane/src/auth-headers.ts+0−7
@@ -1,7 +0,0 @@
1export 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;
packages/control-plane/src/client.ts+2−19
@@ -1,35 +1,18 @@
1import {1import {
2 FetchHttpClient,2 FetchHttpClient,
3 HttpApiClient,3 HttpApiClient,
4 HttpClient,
5 HttpClientRequest,
6} from "@effect/platform";4} from "@effect/platform";
7import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
8 6
9import { ControlPlaneApi } from "./api/api";7import { ControlPlaneApi } from "./api/api";
10import { ControlPlaneAuthHeaders } from "./auth-headers";
11 8
12export const createControlPlaneClient = (input: {9export 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
34export type ControlPlaneClient = Effect.Effect.Success<17export type ControlPlaneClient = Effect.Effect.Success<
35 ReturnType<typeof createControlPlaneClient>18 ReturnType<typeof createControlPlaneClient>
packages/control-plane/src/domain/auth/actor.test.ts+0−98
@@ -1,98 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { assertInstanceOf, assertTrue } from "@effect/vitest/utils";
3import { AccountIdSchema, WorkspaceIdSchema } from "#schema";
4import * as Effect from "effect/Effect";
5
6import {
7 createActor,
8 ActorForbiddenError,
9 ActorUnauthenticatedError,
10} from "./actor";
11
12describe("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});
packages/control-plane/src/domain/auth/actor.ts+0−239
@@ -1,239 +0,0 @@
1import {
2 RolePermissions,
3 type OrganizationId,
4 type OrganizationMembership,
5 type Permission,
6 type Principal,
7 type WorkspaceId,
8 type WorkspaceMembership,
9} from "#schema";
10import * as Context from "effect/Context";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14
15export class ActorUnauthenticatedError extends Data.TaggedError(
16 "ActorUnauthenticatedError",
17)<{
18 message: string;
19}> {}
20
21export class ActorForbiddenError extends Data.TaggedError("ActorForbiddenError")<{
22 permission: Permission;
23 scope: string;
24}> {}
25
26export type PermissionRequest = {
27 permission: Permission;
28 workspaceId?: WorkspaceId;
29 organizationId?: OrganizationId;
30};
31
32export 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
46export class Actor extends Context.Tag("#domain/auth/Actor")<
47 Actor,
48 ActorShape
49>() {}
50
51export type CreateActorInput = {
52 principal: Principal | null;
53 workspaceMemberships: ReadonlyArray<WorkspaceMembership>;
54 organizationMemberships: ReadonlyArray<OrganizationMembership>;
55};
56
57const isWorkspaceMembershipActive = (
58 membership: WorkspaceMembership,
59): boolean => membership.status === "active";
60
61const isOrganizationMembershipActive = (
62 membership: OrganizationMembership,
63): boolean => membership.status === "active";
64
65const permissionsByRole = new Map(
66 Object.entries(RolePermissions).map(([role, permissions]) => [
67 role,
68 new Set<Permission>(permissions),
69 ]),
70);
71
72const permissionsForRole = (
73 role: keyof typeof RolePermissions,
74): ReadonlySet<Permission> => permissionsByRole.get(role) ?? new Set<Permission>();
75
76const 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
97const 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
118const 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
140const 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
152const toWorkspaceAccessPermissionRequest = (
153 workspaceId: WorkspaceId,
154): PermissionRequest => ({
155 permission: "workspace:read",
156 workspaceId,
157});
158
159const 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
211export 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
221export 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
238export const ActorLive = (actor: ActorShape): Layer.Layer<Actor> =>
239 Layer.succeed(Actor, actor);
packages/control-plane/src/domain/auth/index.ts+0−21
@@ -1,21 +0,0 @@
1export {
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
13export {
14 all,
15 any,
16 policy,
17 requirePermission,
18 requireWorkspaceAccess,
19 withPolicy,
20 type Policy,
21} from "./policy";
packages/control-plane/src/domain/auth/policy.test.ts+0−34
@@ -1,34 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { AccountIdSchema, WorkspaceIdSchema } from "#schema";
3import * as Effect from "effect/Effect";
4
5import { Actor, createAllowAllActor } from "./actor";
6import { requirePermission, withPolicy } from "./policy";
7
8describe("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});
packages/control-plane/src/domain/auth/policy.ts+0−55
@@ -1,55 +0,0 @@
1import type {
2 OrganizationId,
3 Permission,
4 WorkspaceId,
5} from "#schema";
6import * as Effect from "effect/Effect";
7
8import {
9 Actor,
10 ActorForbiddenError,
11 type ActorShape,
12} from "./actor";
13
14export type Policy<E = never, R = never> = Effect.Effect<
15 void,
16 ActorForbiddenError | E,
17 Actor | R
18>;
19
20export 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
33export const requirePermission = (request: {
34 permission: Permission;
35 workspaceId?: WorkspaceId;
36 organizationId?: OrganizationId;
37}): Policy =>
38 Effect.flatMap(Actor, (actor) => actor.requirePermission(request));
39
40export const requireWorkspaceAccess = (workspaceId: WorkspaceId): Policy =>
41 Effect.flatMap(Actor, (actor) => actor.requireWorkspaceAccess(workspaceId));
42
43export 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
48export const all = <E, R>(
49 policies: ReadonlyArray<Policy<E, R>>,
50): Policy<E, R> =>
51 Effect.forEach(policies, (item) => item, { discard: true });
52
53export const any = <E, R>(
54 policies: readonly [Policy<E, R>, ...Array<Policy<E, R>>],
55): Policy<E, R> => Effect.firstSuccessOf(policies);
packages/control-plane/src/domain/index.ts+0−1
@@ -1 +0,0 @@
1export * from "./auth";
packages/control-plane/src/index.ts+0−2
@@ -3,5 +3,3 @@ export * from "./persistence/index"; · 2 unmodified lines
3export * from "./api/index";3export * from "./api/index";
4export * from "./runtime/index";4export * from "./runtime/index";
5export * from "./client";5export * from "./client";
6
7export * as Domain from "./domain/index";
packages/control-plane/src/persistence/control-plane-rows.test.ts+0−947
@@ -1,947 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";
3import {
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";
21import * as Effect from "effect/Effect";
22import * as Option from "effect/Option";
23import * as Schema from "effect/Schema";
24import * as Scope from "effect/Scope";
25
26import {
27 createSqlControlPlanePersistence,
28 type SqlControlPlanePersistence,
29} from "./index";
30import { drizzleSchema } from "./schema";
31
32const 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
44const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
45const encodeStaticBearerArtifactConfig = Schema.encodeSync(
46 StaticBearerAuthArtifactConfigJsonSchema,
47);
48const encodeStaticOAuth2ArtifactConfig = Schema.encodeSync(
49 StaticOAuth2AuthArtifactConfigJsonSchema,
50);
51
52const openApiBindingConfigJson = (specUrl: string): string =>
53 JSON.stringify({
54 adapterKey: "openapi",
55 version: 1,
56 payload: {
57 specUrl,
58 defaultHeaders: null,
59 },
60 });
61
62const 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
84const 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
160const 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
249describe("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});
packages/control-plane/src/persistence/index.ts+0−10
@@ -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";
29import { drizzleSchema, tableNames, type DrizzleTables } from "./schema";24import { drizzleSchema, tableNames, type DrizzleTables } from "./schema";
30import {25import {
@@ -55,9 +50,6 @@ export { · 24 unmodified lines
55 50
56const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema) => ({51const 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});
packages/control-plane/src/persistence/post-migrations.test.ts+86−108
@@ -7,7 +7,6 @@ import { describe, expect, it } from "@effect/vitest"; · 6 unmodified lines
7import {7import {
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";
29import { runCodeMigrations } from "./code-migrations";28import { runCodeMigrations } from "./code-migrations";
30import { loadSourceById } from "../runtime/source-store";
31 29
32const openApiBindingConfigJson = (specUrl: string): string =>30const 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,
packages/control-plane/src/persistence/repos/index.ts+0−5
@@ -1,7 +1,4 @@
1export { createAccountsRepo } from "./accounts-repo";1export { createAccountsRepo } from "./accounts-repo";
2export { createOrganizationsRepo } from "./organizations-repo";
3export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";
4export { createWorkspacesRepo } from "./workspaces-repo";
5export { createSourcesRepo } from "./sources-repo";2export { createSourcesRepo } from "./sources-repo";
6export { createSourceRecipesRepo } from "./source-recipes-repo";3export { createSourceRecipesRepo } from "./source-recipes-repo";
7export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";4export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";
@@ -14,7 +11,5 @@ export { createAuthLeasesRepo } from "./auth-leases-repo"; · 6 unmodified lines
14export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";11export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
15export { createSecretMaterialsRepo } from "./secret-materials-repo";12export { createSecretMaterialsRepo } from "./secret-materials-repo";
16export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";13export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
17export { createPoliciesRepo } from "./policies-repo";
18export { createLocalInstallationsRepo } from "./local-installations-repo";
19export { createExecutionsRepo } from "./executions-repo";14export { createExecutionsRepo } from "./executions-repo";
20export { createExecutionInteractionsRepo } from "./execution-interactions-repo";15export { createExecutionInteractionsRepo } from "./execution-interactions-repo";
packages/control-plane/src/persistence/repos/local-installations-repo.ts+0−48
@@ -1,48 +0,0 @@
1import {
2 type LocalInstallation,
3 LocalInstallationSchema,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { eq } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption } from "./shared";
12
13const decodeLocalInstallation = Schema.decodeUnknownSync(LocalInstallationSchema);
14
15export 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});
packages/control-plane/src/persistence/repos/organization-memberships-repo.ts+0−110
@@ -1,110 +0,0 @@
1import {
2 type OrganizationMembership,
3 OrganizationMembershipSchema,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { and, asc, eq } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeOrganizationMembership = Schema.decodeUnknownSync(
14 OrganizationMembershipSchema,
15);
16
17export 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});
packages/control-plane/src/persistence/repos/organizations-repo.ts+0−233
@@ -1,233 +0,0 @@
1import {
2 type Organization,
3 type OrganizationMembership,
4 OrganizationMembershipSchema,
5 OrganizationSchema,
6} from "#schema";
7import * as Option from "effect/Option";
8import { Schema } from "effect";
9import { asc, eq, inArray } from "drizzle-orm";
10
11import type { DrizzleClient } from "../client";
12import type { DrizzleTables } from "../schema";
13import {
14 cleanupOrphanedSourceRecipes,
15 firstOption,
16 postgresSecretHandlesFromAuthArtifacts,
17 withoutCreatedAt,
18} from "./shared";
19
20const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema);
21const decodeOrganizationMembership = Schema.decodeUnknownSync(
22 OrganizationMembershipSchema,
23);
24
25export 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});
packages/control-plane/src/persistence/repos/policies-repo.ts+0−118
@@ -1,118 +0,0 @@
1import { type Policy, PolicySchema } from "#schema";
2import * as Option from "effect/Option";
3import { Schema } from "effect";
4import { and, asc, desc, eq, or } from "drizzle-orm";
5
6import type { DrizzleClient } from "../client";
7import type { DrizzleTables } from "../schema";
8import { firstOption } from "./shared";
9
10const decodePolicy = Schema.decodeUnknownSync(PolicySchema);
11
12export 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});
packages/control-plane/src/persistence/repos/workspaces-repo.ts+0−158
@@ -1,158 +0,0 @@
1import { type Workspace, WorkspaceSchema } from "#schema";
2import * as Option from "effect/Option";
3import { Schema } from "effect";
4import { and, asc, eq, inArray } from "drizzle-orm";
5
6import type { DrizzleClient } from "../client";
7import type { DrizzleTables } from "../schema";
8import {
9 cleanupOrphanedSourceRecipes,
10 firstOption,
11 postgresSecretHandlesFromAuthArtifacts,
12} from "./shared";
13
14const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
15
16export 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});
packages/control-plane/src/persistence/schema.ts+0−167
@@ -12,9 +12,6 @@ import { sql } from "drizzle-orm"; · 11 unmodified lines
12 12
13export const tableNames = {13export 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
57export 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
74export 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
114export 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
134export const sourcesTable = pgTable(52export 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
523export 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
587export 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
603export const executionsTable = pgTable(441export const executionsTable = pgTable(
604 tableNames.executions,442 tableNames.executions,
605 {443 {
@@ -653,9 +491,6 @@ export const executionInteractionsTable = pgTable( · 47 unmodified lines
653 491
654export const drizzleSchema = {492export 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};
packages/control-plane/src/runtime/actor-resolver.ts+0−160
@@ -1,160 +0,0 @@
1import * as PlatformHeaders from "@effect/platform/Headers";
2import { ControlPlaneAuthHeaders } from "../auth-headers";
3import {
4 ControlPlaneActorResolver,
5 type ControlPlaneActorResolverShape,
6 deriveWorkspaceMembershipsForPrincipal,
7} from "#api";
8import {
9 ActorUnauthenticatedError,
10 createActor,
11} from "#domain";
12import {
13 SqlControlPlaneRowsService,
14 type SqlControlPlaneRows,
15} from "#persistence";
16import {
17 PrincipalProviderSchema,
18 PrincipalSchema,
19 type Principal,
20} from "#schema";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as ParseResult from "effect/ParseResult";
25import * as Schema from "effect/Schema";
26
27export { ControlPlaneAuthHeaders };
28
29const decodePrincipal = Schema.decodeUnknown(PrincipalSchema);
30const decodePrincipalProvider = Schema.decodeUnknown(PrincipalProviderSchema);
31
32const 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
45const 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
60const 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
98export 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
152export 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 );
packages/control-plane/src/runtime/control-plane-runtime.test.ts+97−428
@@ -1,4 +1,7 @@
1import { createServer } from "node:http";1import { createServer } from "node:http";
2import { mkdtempSync, readFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
2 5
3import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
4import { assertTrue } from "@effect/vitest/utils";7import { 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";
18import type { ToolPath } from "@executor/codemode-core";19import type { ToolPath } from "@executor/codemode-core";
19 20
20import {21import {
21 createSqlControlPlaneRuntime,22 createControlPlaneRuntime,
22 LiveExecutionManagerService,23 LiveExecutionManagerService,
23} from "./index";24} from "./index";
25import { createSourceFromPayload } from "./source-definitions";
24import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";26import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
27import { persistSource } from "./source-store";
25import { withControlPlaneClient } from "./test-http-client";28import { withControlPlaneClient } from "./test-http-client";
26 29
27const makeRuntime = Effect.acquireRelease(30const 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
32const openApiBindingConfigJson = (specUrl: string): string =>
33 JSON.stringify({
34 adapterKey: "openapi",
35 version: 1,
36 payload: {
37 specUrl,
38 defaultHeaders: null,
39 },
40 });
41
42type OpenApiSpecServer = {38type 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
125describe("control-plane-runtime", () => {121describe("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});
packages/control-plane/src/runtime/execution-http.test.ts+7−2
@@ -1,3 +1,6 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
1import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
@@ -6,7 +9,7 @@ import { makeToolInvokerFromTools } from "@executor/codemode-core"; · 2 unmodified lines
6import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";9import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
7 10
8import {11import {
9 createSqlControlPlaneRuntime,12 createControlPlaneRuntime,
10} from "./index";13} from "./index";
11import { withControlPlaneClient } from "./test-http-client";14import { withControlPlaneClient } from "./test-http-client";
12 15
@@ -37,8 +40,9 @@ const makeExecutionResolver = () => { · 24 unmodified lines
37};40};
38 41
39const makeRuntime = Effect.acquireRelease(42const 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});
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+7−2
@@ -5,6 +5,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 4 unmodified lines
5import { Client } from "@modelcontextprotocol/sdk/client/index.js";5import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";6import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";7import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8import { mkdtempSync } from "node:fs";
9import { tmpdir } from "node:os";
10import { join } from "node:path";
8import { describe, expect, it } from "@effect/vitest";11import { describe, expect, it } from "@effect/vitest";
9import { assertTrue } from "@effect/vitest/utils";12import { assertTrue } from "@effect/vitest/utils";
10import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
@@ -15,7 +18,7 @@ import { discoverMcpToolsFromConnector } from "@executor/codemode-mcp"; · 4 unmodified lines
15import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";18import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
16 19
17import {20import {
18 createSqlControlPlaneRuntime,21 createControlPlaneRuntime,
19 type ResolveExecutionEnvironment,22 type ResolveExecutionEnvironment,
20} from "./index";23} from "./index";
21import { withControlPlaneClient } from "./test-http-client";24import { 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});
packages/control-plane/src/runtime/execution-service.ts+1−1
@@ -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";
6import type {6import type {
7 CreateExecutionPayload,7 CreateExecutionPayload,
8 ResumeExecutionPayload,8 ResumeExecutionPayload,
packages/control-plane/src/runtime/executor-tools.ts+29−5
@@ -19,10 +19,24 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines
19import * as Cause from "effect/Cause";19import * as Cause from "effect/Cause";
20import * as Exit from "effect/Exit";20import * as Exit from "effect/Exit";
21import * as Schema from "effect/Schema";21import * as Schema from "effect/Schema";
22import type { RuntimeLocalWorkspaceState } from "./local-runtime-context";
23import { 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). */
24const runEffect = async <A>(effect: Effect.Effect<A, unknown, never>): Promise<A> => {26const 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
123const toSerializableValue = <A>(value: A): A =>
124 JSON.parse(JSON.stringify(value)) as A;
125
109const resolveLocalCredentialUrl = (input: {126const 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: {
packages/control-plane/src/runtime/index.ts+117−34
@@ -6,13 +6,12 @@ import * as Scope from "effect/Scope"; · 5 unmodified lines
6 6
7import {7import {
8 type ControlPlaneApiRuntimeContext,8 type ControlPlaneApiRuntimeContext,
9 ControlPlaneActorResolver,
10 type ControlPlaneActorResolverShape,
11} from "#api";9} from "#api";
12import {10import {
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
20import type { LocalInstallation } from "#schema";19import type { LocalInstallation } from "#schema";
21 20
22import {21import {
23 ControlPlaneAuthHeaders,22 type ResolveExecutionEnvironment,
24 RuntimeActorResolverLive,23} from "./execution-state";
25 createHeaderActorResolver,
26} from "./actor-resolver";
27import { type ResolveExecutionEnvironment } from "./execution-state";
28import {24import {
25 createLiveExecutionManager,
29 LiveExecutionManagerLive,26 LiveExecutionManagerLive,
30 LiveExecutionManagerService,27 LiveExecutionManagerService,
31} from "./live-execution";28} from "./live-execution";
32import { getOrProvisionLocalInstallation } from "./local-installation";29import { getOrProvisionLocalInstallation } from "./local-installation";
30import {
31 loadLocalExecutorConfig,
32 resolveLocalWorkspaceContext,
33} from "./local-config";
34import { RuntimeLocalWorkspaceService } from "./local-runtime-context";
35import { synchronizeLocalWorkspaceState } from "./local-workspace-sync";
33import {36import {
34 ControlPlaneStore,37 ControlPlaneStore,
35 ControlPlaneStoreLive,38 ControlPlaneStoreLive,
36} from "./store";39} from "./store";
37import {40import {
41 createRuntimeSourceAuthService,
38 RuntimeSourceAuthServiceLive,42 RuntimeSourceAuthServiceLive,
39 RuntimeSourceAuthServiceTag,43 RuntimeSourceAuthServiceTag,
40} from "./source-auth-service";44} from "./source-auth-service";
41import type { ResolveSecretMaterial } from "./secret-material-providers";45import type { ResolveSecretMaterial } from "./secret-material-providers";
46import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
42import {47import {
48 createWorkspaceExecutionEnvironmentResolver,
43 RuntimeExecutionResolverLive,49 RuntimeExecutionResolverLive,
44 RuntimeExecutionResolverService,50 RuntimeExecutionResolverService,
45} from "./workspace-execution-environment";51} from "./workspace-execution-environment";
46 52
47export {
48 ControlPlaneAuthHeaders,
49 createHeaderActorResolver,
50};
51
52export * from "./execution-state";53export * from "./execution-state";
53export * from "./executor-tools";54export * from "./executor-tools";
54export * from "./live-execution";55export * from "./live-execution";
56export * from "./local-config";
55export * from "./local-installation";57export * from "./local-installation";
58export * from "./local-source-artifacts";
56export * from "./schema-type-signature";59export * from "./schema-type-signature";
57export * from "./source-auth-service";60export * from "./source-auth-service";
58export * from "./secret-material-providers";61export * from "./secret-material-providers";
59export * from "./source-credential-interactions";62export * from "./source-credential-interactions";
63export * from "./source-adapters/mcp";
60export * from "./store";64export * from "./store";
61export * from "./workspace-execution-environment";65export * from "./workspace-execution-environment";
62export * from "./source-inspection";66export * from "./source-inspection";
@@ -64,21 +68,21 @@ export * from "./source-discovery"; · 1 unmodified line
64export * from "./execution-service";68export * from "./execution-service";
65 69
66export type RuntimeControlPlaneOptions = {70export 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
73const detailsFromCause = (cause: unknown): string =>77const detailsFromCause = (cause: unknown): string =>
74 cause instanceof Error ? cause.message : String(cause);78 cause instanceof Error ? cause.message : String(cause);
75 79
76const toLocalInstallationBootstrapError = (80const 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
95const runtimeContextTags = [99const 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
103const createRuntimeLayerFromContext = (107const createRuntimeLayerFromContext = (
104 context: Context.Context<108 context: Context.Context<
105 ControlPlaneActorResolver109 ControlPlaneStore
106 | ControlPlaneStore
107 | LiveExecutionManagerService110 | LiveExecutionManagerService
111 | RuntimeLocalWorkspaceService
108 | RuntimeSourceAuthServiceTag112 | RuntimeSourceAuthServiceTag
109 | RuntimeExecutionResolverService113 | 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 ControlPlaneActorResolver119 ControlPlaneStore
116 | ControlPlaneStore
117 | LiveExecutionManagerService120 | LiveExecutionManagerService
121 | RuntimeLocalWorkspaceService
118 | RuntimeSourceAuthServiceTag122 | RuntimeSourceAuthServiceTag
119 | RuntimeExecutionResolverService123 | 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
150export type SqlControlPlaneRuntime = {153export 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
157export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions160export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions
158 & RuntimeControlPlaneOptions;161 & RuntimeControlPlaneOptions;
159 162
160export const createSqlControlPlaneRuntime = (163export 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,
packages/control-plane/src/runtime/invocation-policy-engine.test.ts+25−58
@@ -1,9 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import type { Policy } from "#schema";3import type { LocalWorkspacePolicy } from "#schema";
4import {4import {
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
17const workspaceId = WorkspaceIdSchema.make("ws_policy_engine");15const workspaceId = WorkspaceIdSchema.make("ws_policy_engine");
18const organizationId = OrganizationIdSchema.make("org_policy_engine");
19const sourceId = SourceIdSchema.make("src_policy_engine");16const sourceId = SourceIdSchema.make("src_policy_engine");
20 17
21const now = 1_700_000_000_000;18const now = 1_700_000_000_000;
@@ -32,20 +29,14 @@ const baseDescriptor: InvocationDescriptor = { · 10 unmodified lines
32};29};
33 30
34const basePolicy = (31const 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
56describe("invocation-policy-engine", () => {47describe("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});
packages/control-plane/src/runtime/invocation-policy-engine.ts+18−195
@@ -1,6 +1,5 @@
1import type {1import 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
25export type InvocationPolicyContext = {24export 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
32export type InvocationAuthorizationDecision = {28export 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
38const namespaceFromToolPath = (toolPath: string): string | null => {34const 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
47const 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 = pattern35 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
62const parseArgumentConditions = (41const 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
96const asString = (value: unknown): string => value == null ? "" : String(value);
97
98const 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
117const 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
136const 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
159const 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
182const 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
222const defaultDecisionForInvocation = (44const defaultDecisionForInvocation = (
223 descriptor: InvocationDescriptor,45 descriptor: InvocationDescriptor,
@@ -238,7 +60,7 @@ const defaultDecisionForInvocation = ( · 14 unmodified lines
238};60};
239 61
240const resolvePolicyDecision = (62const 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
266export const evaluateInvocationPolicy = (input: {88export 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.policies94 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};
packages/control-plane/src/runtime/local-config-secrets.ts+9−0
@@ -0,0 +1,9 @@
1export const CONFIG_SECRET_PROVIDER_PREFIX = "config:";
2
3export const toConfigSecretProviderId = (providerAlias: string): string =>
4 `${CONFIG_SECRET_PROVIDER_PREFIX}${providerAlias}`;
5
6export const fromConfigSecretProviderId = (providerId: string): string | null =>
7 providerId.startsWith(CONFIG_SECRET_PROVIDER_PREFIX)
8 ? providerId.slice(CONFIG_SECRET_PROVIDER_PREFIX.length)
9 : null;
packages/control-plane/src/runtime/local-config.test.ts+128−0
@@ -0,0 +1,128 @@
1import { mkdtempSync } from "node:fs";
2import { mkdir, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7
8import {
9 loadLocalExecutorConfig,
10 resolveDefaultHomeConfigCandidates,
11 resolveHomeConfigPath,
12 resolveLocalWorkspaceContext,
13} from "./local-config";
14
15const makeWorkspaceRoot = () =>
16 mkdtempSync(join(tmpdir(), "executor-local-config-"));
17
18describe("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});
packages/control-plane/src/runtime/local-config.ts+450−0
@@ -0,0 +1,450 @@
1import { homedir } from "node:os";
2import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3import { FileSystem } from "@effect/platform";
4import { NodeFileSystem } from "@effect/platform-node";
5import {
6 type ParseError as JsoncParseError,
7 parse as parseJsoncDocument,
8 printParseErrorCode,
9} from "jsonc-parser";
10
11import {
12 LocalExecutorConfigSchema,
13 type LocalExecutorConfig,
14 type LocalConfigPolicy,
15 type LocalConfigSecretProvider,
16 type LocalConfigSource,
17} from "#schema";
18import * as Effect from "effect/Effect";
19import * as Schema from "effect/Schema";
20import {
21 LocalExecutorConfigDecodeError,
22 LocalFileSystemError,
23 unknownLocalErrorDetails,
24} from "./local-errors";
25
26const decodeLocalExecutorConfig = Schema.decodeUnknownSync(LocalExecutorConfigSchema);
27
28const PROJECT_CONFIG_BASENAME = "executor.jsonc";
29const PROJECT_CONFIG_FALLBACK_BASENAME = "executor.json";
30const PROJECT_CONFIG_DIRECTORY = ".executor";
31const EXECUTOR_CONFIG_DIR_ENV = "EXECUTOR_CONFIG_DIR";
32
33const 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
42const 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
50const trimOrUndefined = (value: string | undefined | null): string | undefined => {
51 const trimmed = value?.trim();
52 return trimmed && trimmed.length > 0 ? trimmed : undefined;
53};
54
55const 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
86const legacyExecutorConfigDirectory = (input: {
87 homeDirectory?: string;
88} = {}): string =>
89 join(input.homeDirectory ?? homedir(), ".config", "executor");
90
91export 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
113export 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
134const 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
155const 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
183const 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
196const 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
209const 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
222export 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
250const 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
268const 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
284const 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
319export 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
330export type LoadedLocalExecutorConfig = {
331 config: LocalExecutorConfig | null;
332 homeConfig: LocalExecutorConfig | null;
333 projectConfig: LocalExecutorConfig | null;
334 homeConfigPath: string;
335 projectConfigPath: string;
336};
337
338export 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
363export 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
391export 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
412export const encodeLocalExecutorConfig = (config: LocalExecutorConfig): string =>
413 `${JSON.stringify(config, null, 2)}\n`;
414
415export 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
432export 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
449export const defaultWorkspaceDisplayName = (context: ResolvedLocalWorkspaceContext): string =>
450 trimOrUndefined(context.workspaceName) ?? "workspace";
packages/control-plane/src/runtime/local-errors.ts+54−0
@@ -0,0 +1,54 @@
1import * as Data from "effect/Data";
2
3export const unknownLocalErrorDetails = (cause: unknown): string =>
4 cause instanceof Error ? cause.message : String(cause);
5
6export class LocalFileSystemError extends Data.TaggedError("LocalFileSystemError")<{
7 readonly message: string;
8 readonly action: string;
9 readonly path: string;
10 readonly details: string;
11}> {}
12
13export class LocalExecutorConfigDecodeError extends Data.TaggedError("LocalExecutorConfigDecodeError")<{
14 readonly message: string;
15 readonly path: string;
16 readonly details: string;
17}> {}
18
19export class LocalWorkspaceStateDecodeError extends Data.TaggedError("LocalWorkspaceStateDecodeError")<{
20 readonly message: string;
21 readonly path: string;
22 readonly details: string;
23}> {}
24
25export class LocalSourceArtifactDecodeError extends Data.TaggedError("LocalSourceArtifactDecodeError")<{
26 readonly message: string;
27 readonly path: string;
28 readonly details: string;
29}> {}
30
31export class RuntimeLocalWorkspaceUnavailableError extends Data.TaggedError("RuntimeLocalWorkspaceUnavailableError")<{
32 readonly message: string;
33}> {}
34
35export class RuntimeLocalWorkspaceMismatchError extends Data.TaggedError("RuntimeLocalWorkspaceMismatchError")<{
36 readonly message: string;
37 readonly requestedWorkspaceId: string;
38 readonly activeWorkspaceId: string;
39}> {}
40
41export class LocalConfiguredSourceNotFoundError extends Data.TaggedError("LocalConfiguredSourceNotFoundError")<{
42 readonly message: string;
43 readonly sourceId: string;
44}> {}
45
46export class LocalSourceArtifactMissingError extends Data.TaggedError("LocalSourceArtifactMissingError")<{
47 readonly message: string;
48 readonly sourceId: string;
49}> {}
50
51export class LocalUnsupportedSourceKindError extends Data.TaggedError("LocalUnsupportedSourceKindError")<{
52 readonly message: string;
53 readonly kind: string;
54}> {}
packages/control-plane/src/runtime/local-installation.test.ts+22−18
@@ -1,45 +1,49 @@
1import { existsSync, mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
1import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4 6
5import { createSqlControlPlaneRuntime } from "./index";7import { createControlPlaneRuntime } from "./index";
6import { getOrProvisionLocalInstallation } from "./local-installation";8import { getOrProvisionLocalInstallation } from "./local-installation";
9import { resolveLocalWorkspaceContext } from "./local-config";
10
11const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));
7 12
8const makeRuntime = Effect.acquireRelease(13const 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
13describe("local-installation", () => {21describe("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 );
packages/control-plane/src/runtime/local-installation.ts+25−104
@@ -1,119 +1,40 @@
1import { type SqlControlPlaneRows } from "#persistence";1import { createHash } from "node:crypto";
2
2import {3import {
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";
14import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16 9
17const LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");10import type { ResolvedLocalWorkspaceContext } from "./local-config";
18 11
19const buildAccount = (now: number): Account => {12const LOCAL_ACCOUNT_ID = AccountIdSchema.make("acc_local_default");
20 const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`);
21 13
22 return {14const 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
33const buildOrganization = (17const 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
46const buildOwnerMembership = (20const 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
63const buildWorkspace = (25export 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
76export const loadLocalInstallation = (32export 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 );37export const getOrProvisionLocalInstallation = (input: {
82 38 context: ResolvedLocalWorkspaceContext;
83export 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
114export const getOrProvisionLocalInstallation = (
115 rows: SqlControlPlaneRows,
116): Effect.Effect<LocalInstallation, unknown> =>
117 Effect.flatMap(loadLocalInstallation(rows), (existing) =>
118 existing ? Effect.succeed(existing) : provisionLocalInstallation(rows)
119 );
packages/control-plane/src/runtime/local-operations.ts+13−22
@@ -17,6 +17,8 @@ import { LiveExecutionManagerService } from "./live-execution"; · 16 unmodified lines
17import {17import {
18 operationErrors,18 operationErrors,
19} from "./operation-errors";19} from "./operation-errors";
20import { resolveLocalWorkspaceContext } from "./local-config";
21import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context";
20import { RuntimeSourceAuthServiceTag } from "./source-auth-service";22import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
21import {23import {
22 createSourceCredentialSelectionBearerContent,24 createSourceCredentialSelectionBearerContent,
@@ -180,29 +182,18 @@ const loadSourceCredentialInteraction = (input: { · 157 unmodified lines
180 182
181export const getLocalInstallation = () =>183export 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 ControlPlanePersistenceError189 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
208export const getSourceCredentialInteraction = (input: {199export const getSourceCredentialInteraction = (input: {
packages/control-plane/src/runtime/local-runtime-context.ts+71−0
@@ -0,0 +1,71 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type { AccountId, WorkspaceId } from "#schema";
6import type {
7 LoadedLocalExecutorConfig,
8 ResolvedLocalWorkspaceContext,
9} from "./local-config";
10import {
11 RuntimeLocalWorkspaceMismatchError,
12 RuntimeLocalWorkspaceUnavailableError,
13} from "./local-errors";
14
15export type RuntimeLocalWorkspaceState = {
16 context: ResolvedLocalWorkspaceContext;
17 installation: {
18 workspaceId: WorkspaceId;
19 accountId: AccountId;
20 };
21 loadedConfig: LoadedLocalExecutorConfig;
22};
23
24export class RuntimeLocalWorkspaceService extends Context.Tag(
25 "#runtime/RuntimeLocalWorkspaceService",
26)<RuntimeLocalWorkspaceService, RuntimeLocalWorkspaceState>() {}
27
28export 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
35export 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
68export const requireRuntimeLocalAccountId = (workspaceId?: WorkspaceId) =>
69 requireRuntimeLocalWorkspace(workspaceId).pipe(
70 Effect.map((runtimeLocalWorkspace) => runtimeLocalWorkspace.installation.accountId),
71 );
packages/control-plane/src/runtime/local-source-artifacts.ts+230−0
@@ -0,0 +1,230 @@
1import { join } from "node:path";
2import { FileSystem } from "@effect/platform";
3import { NodeFileSystem } from "@effect/platform-node";
4
5import {
6 SourceIdSchema,
7 SourceRecipeIdSchema,
8 StoredSourceRecipeDocumentRecordSchema,
9 StoredSourceRecipeOperationRecordSchema,
10 StoredSourceRecipeRevisionRecordSchema,
11 StoredSourceRecipeSchemaBundleRecordSchema,
12 TimestampMsSchema,
13 type Source,
14 type SourceRecipeId,
15} from "#schema";
16import * as Effect from "effect/Effect";
17import * as Schema from "effect/Schema";
18
19import type { ResolvedLocalWorkspaceContext } from "./local-config";
20import {
21 LocalFileSystemError,
22 LocalSourceArtifactDecodeError,
23 unknownLocalErrorDetails,
24} from "./local-errors";
25import {
26 createSourceRecipeRevisionRecord,
27 stableSourceRecipeId,
28} from "./source-definitions";
29import {
30 contentHash,
31 type SourceRecipeMaterialization,
32} from "./source-recipe-support";
33
34const LOCAL_SOURCE_ARTIFACT_VERSION = 1 as const;
35
36export 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
47export type LocalSourceArtifact = typeof LocalSourceArtifactSchema.Type;
48
49const decodeLocalSourceArtifact = Schema.decodeUnknownSync(LocalSourceArtifactSchema);
50
51const 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
60const 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
68const localSourceArtifactPath = (input: {
69 context: ResolvedLocalWorkspaceContext;
70 sourceId: string;
71}): string =>
72 join(
73 input.context.artifactsDirectory,
74 "sources",
75 `${input.sourceId}.json`,
76 );
77
78const 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
127const bindRevisionId = <T extends { recipeRevisionId: string }>(
128 items: readonly T[],
129 recipeRevisionId: string,
130): T[] =>
131 items.map((item) => ({
132 ...item,
133 recipeRevisionId,
134 }));
135
136export 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
165export 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
197export 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
214export 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 }));
packages/control-plane/src/runtime/local-workspace-state.ts+124−0
@@ -0,0 +1,124 @@
1import { join } from "node:path";
2import { FileSystem } from "@effect/platform";
3import { NodeFileSystem } from "@effect/platform-node";
4
5import {
6 PolicyIdSchema,
7 SourceStatusSchema,
8 TimestampMsSchema,
9} from "#schema";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12
13import type { ResolvedLocalWorkspaceContext } from "./local-config";
14import {
15 LocalFileSystemError,
16 LocalWorkspaceStateDecodeError,
17 unknownLocalErrorDetails,
18} from "./local-errors";
19
20const WORKSPACE_STATE_BASENAME = "workspace-state.json";
21
22const 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
30const LocalWorkspacePolicyStateSchema = Schema.Struct({
31 id: PolicyIdSchema,
32 createdAt: TimestampMsSchema,
33 updatedAt: TimestampMsSchema,
34});
35
36export 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
48export type LocalWorkspaceSourceState = typeof LocalWorkspaceSourceStateSchema.Type;
49export type LocalWorkspacePolicyState = typeof LocalWorkspacePolicyStateSchema.Type;
50export type LocalWorkspaceState = typeof LocalWorkspaceStateSchema.Type;
51
52const decodeLocalWorkspaceState = Schema.decodeUnknownSync(LocalWorkspaceStateSchema);
53
54const 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
63const 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
71const defaultLocalWorkspaceState = (): LocalWorkspaceState => ({
72 version: 1,
73 sources: {},
74 policies: {},
75});
76
77export const localWorkspaceStatePath = (
78 context: ResolvedLocalWorkspaceContext,
79): string => join(context.stateDirectory, WORKSPACE_STATE_BASENAME);
80
81export 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
109export 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 }));
packages/control-plane/src/runtime/local-workspace-sync.ts+93−0
@@ -0,0 +1,93 @@
1import * as Effect from "effect/Effect";
2
3import {
4 type LoadedLocalExecutorConfig,
5 type ResolvedLocalWorkspaceContext,
6} from "./local-config";
7import {
8 loadLocalWorkspaceState,
9 writeLocalWorkspaceState,
10 type LocalWorkspaceState,
11} from "./local-workspace-state";
12
13const 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
21export 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
42const 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
82export 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 });
packages/control-plane/src/runtime/operation-errors.ts+1−1
@@ -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";
6import { ControlPlanePersistenceError } from "#persistence";6import { ControlPlanePersistenceError } from "#persistence";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8 8
packages/control-plane/src/runtime/operations-shared.ts+1−56
@@ -1,21 +1,14 @@
1import {1import {
2 ControlPlaneBadRequestError,2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,3 ControlPlaneStorageError,
5} from "#api";4} from "../api/errors";
6import { ControlPlanePersistenceError } from "#persistence";5import { ControlPlanePersistenceError } from "#persistence";
7import type { Organization } from "#schema";
8import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10 7
11import {8import {
12 asOperationErrors,9 asOperationErrors,
13 operationErrors,
14 type OperationErrorsLike,10 type OperationErrorsLike,
15} from "./operation-errors";11} from "./operation-errors";
16import type { ControlPlaneStoreShape } from "./store";
17import { slugify } from "./slug";
18
19export type Mutable<T> = { -readonly [K in keyof T]: T[K] };12export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
20 13
21const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>14const isUniqueViolation = (error: ControlPlanePersistenceError): boolean =>
@@ -59,51 +52,3 @@ export const parseJsonString = ( · 37 unmodified lines
59 );52 );
60 },53 },
61 });54 });
62
63export 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
88export 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 });
packages/control-plane/src/runtime/organizations-operations.ts+0−434
@@ -1,434 +0,0 @@
1import type {
2 CreateMembershipPayload,
3 UpdateMembershipPayload,
4} from "../api/memberships/api";
5import type {
6 CreateOrganizationPayload,
7 UpdateOrganizationPayload,
8} from "../api/organizations/api";
9import type {
10 CreateWorkspacePayload,
11 UpdateWorkspacePayload,
12} from "../api/workspaces/api";
13import {
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";
24import * as Effect from "effect/Effect";
25import * as Option from "effect/Option";
26
27import {
28 ensureOrganizationExists,
29 ensureUniqueOrganizationSlug,
30 mapPersistenceError,
31 type Mutable,
32} from "./operations-shared";
33import {
34 operationErrors,
35} from "./operation-errors";
36import {
37 ControlPlaneStore,
38 type ControlPlaneStoreShape,
39} from "./store";
40
41const 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
49const 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
56const 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
64const 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
91export const listOrganizations = (input: { accountId: AccountId }) =>
92 Effect.flatMap(ControlPlaneStore, (store) => listOrganizationsWithStore(store, input));
93
94export 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
140export 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
178export 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
212export 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
219export 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
229export 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
274export 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
321export 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
339export 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
349export 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
377export 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
396export 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
429export 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 );
packages/control-plane/src/runtime/policies-operations.ts+297−306
@@ -1,27 +1,37 @@
1import { createHash } from "node:crypto";
2
1import type {3import type {
2 CreatePolicyPayload,4 CreatePolicyPayload,
3 UpdatePolicyPayload,5 UpdatePolicyPayload,
4} from "../api/policies/api";6} from "../api/policies/api";
5import {7import {
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";
12import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14 15
15import {16import {
16 mapPersistenceError,17 loadLocalExecutorConfig,
17 parseJsonString,18 writeProjectLocalExecutorConfig,
18 type Mutable,19} from "./local-config";
19} from "./operations-shared";20import {
21 requireRuntimeLocalWorkspace,
22} from "./local-runtime-context";
23import {
24 loadLocalWorkspaceState,
25 type LocalWorkspaceState,
26 writeLocalWorkspaceState,
27} from "./local-workspace-state";
28import {
29 derivePolicyConfigKey,
30} from "./local-workspace-sync";
20import {31import {
21 type OperationErrors,32 type OperationErrors,
22 operationErrors,33 operationErrors,
23} from "./operation-errors";34} from "./operation-errors";
24import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
25 35
26const policyOps = {36const 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
34type PolicyScopeContext = {44const 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
40const loadWorkspacePolicyContext = (47const 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
55const 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
77export 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
65const loadOrganizationPolicyContext = (100const 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
124const 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
137export 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
90const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean =>151export const createPolicy = (input: {
91 policy.scopeType === scope.scopeType152 workspaceId: WorkspaceId;
92 && policy.organizationId === scope.organizationId
93 && policy.workspaceId === scope.workspaceId;
94
95const 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
139const 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
161const 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
240const 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
260export 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
269export 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
278export 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
288export 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
298export 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
308export const getPolicy = (input: {220export 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,
318export 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
333export const updatePolicy = (input: {246export 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
348export 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
358export const removePolicy = (input: {319export 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 });
packages/control-plane/src/runtime/secret-material-providers.ts+178−1
@@ -1,8 +1,13 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { spawn } from "node:child_process";2import { spawn } from "node:child_process";
3import { promises as fs } from "node:fs";
4import { lstatSync, realpathSync } from "node:fs";
5import { isAbsolute } from "node:path";
3 6
4import type { SqlControlPlaneRows } from "#persistence";7import type { SqlControlPlaneRows } from "#persistence";
5import {8import {
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
10import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";16import * as Option from "effect/Option";
12 17
18import { resolveConfigRelativePath } from "./local-config";
19import { fromConfigSecretProviderId } from "./local-config-secrets";
20
13export const ENV_SECRET_PROVIDER_ID = "env";21export const ENV_SECRET_PROVIDER_ID = "env";
14export const PARAMS_SECRET_PROVIDER_ID = "params";22export const PARAMS_SECRET_PROVIDER_ID = "params";
15export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";23export 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
48type SecretMaterialProvider = {58type 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
500const 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
512const 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
524const 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
573const 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
484export const createDefaultSecretMaterialResolver = (input: {646export 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,
packages/control-plane/src/runtime/source-adapters/mcp.ts+1−1
@@ -187,7 +187,7 @@ export const persistMcpRecipeRevisionFromManifestEntries = (input: { · 186 unmodified lines
187 });187 });
188 });188 });
189 189
190const materializationFromMcpManifestEntries = (input: {190export const materializationFromMcpManifestEntries = (input: {
191 recipeRevisionId: SourceRecipeRevisionId;191 recipeRevisionId: SourceRecipeRevisionId;
192 endpoint: string;192 endpoint: string;
193 manifestEntries: readonly McpToolManifestEntry[];193 manifestEntries: readonly McpToolManifestEntry[];
packages/control-plane/src/runtime/source-auth-service.test.ts+67−60
@@ -1,4 +1,7 @@
1import { mkdtempSync } from "node:fs";
1import { createServer } from "node:http";2import { createServer } from "node:http";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
2 5
3import { describe, expect, it } from "vitest";6import { describe, expect, it } from "vitest";
4import * as Schema from "effect/Schema";7import * 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";
28import { createLiveExecutionManager } from "./live-execution";31import { createLiveExecutionManager } from "./live-execution";
32import {
33 loadLocalExecutorConfig,
34 resolveLocalWorkspaceContext,
35} from "./local-config";
36import {
37 readLocalSourceArtifact,
38} from "./local-source-artifacts";
39import {
40 RuntimeLocalWorkspaceService,
41 type RuntimeLocalWorkspaceState,
42} from "./local-runtime-context";
29import { createDefaultSecretMaterialResolver } from "./secret-material-providers";43import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
30import { resolveSourceAuthMaterial } from "./source-auth-material";44import { 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
180const 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
166describe("source-auth-service", () => {203describe("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
packages/control-plane/src/runtime/source-auth-service.ts+105−62
@@ -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";
47import {
48 RuntimeLocalWorkspaceService,
49 type RuntimeLocalWorkspaceState,
50} from "./local-runtime-context";
46import {51import {
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
2177export const RuntimeSourceAuthServiceLive = (input: {2214export 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 );
packages/control-plane/src/runtime/source-inspection.test.ts+150−118
@@ -1,11 +1,14 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
1import { describe, expect, it } from "vitest";5import { describe, expect, it } from "vitest";
2import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
3 7
4import {8import {
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
23import {
24 loadLocalExecutorConfig,
25 resolveLocalWorkspaceContext,
26 writeProjectLocalExecutorConfig,
27} from "./local-config";
28import {
29 buildLocalSourceArtifact,
30 writeLocalSourceArtifact,
31} from "./local-source-artifacts";
20import {32import {
21 getSourceInspection,33 getSourceInspection,
22 getSourceInspectionSchemaBundle,34 getSourceInspectionSchemaBundle,
23 getSourceInspectionToolDetail,35 getSourceInspectionToolDetail,
24} from "./source-inspection";36} from "./source-inspection";
37import {
38 RuntimeLocalWorkspaceService,
39 type RuntimeLocalWorkspaceState,
40} from "./local-runtime-context";
41import {
42 writeLocalWorkspaceState,
43} from "./local-workspace-state";
25import { ControlPlaneStore } from "./store";44import { ControlPlaneStore } from "./store";
26 45
27const makePersistence = () =>46const makePersistence = () =>
@@ -31,30 +50,28 @@ const makePersistence = () => · 3 unmodified lines
31 }),50 }),
32 );51 );
33 52
34const seedWorkspace = async (input: {53const 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
59const makeSource = (input: {76const 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
153const 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
136describe("source-inspection", () => {173describe("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
packages/control-plane/src/runtime/source-inspection.ts+40−51
@@ -16,7 +16,7 @@ import { SourceRecipeSchemaBundleIdSchema } from "#schema"; · 15 unmodified lines
16import {16import {
17 ControlPlaneNotFoundError,17 ControlPlaneNotFoundError,
18 ControlPlaneStorageError,18 ControlPlaneStorageError,
19} from "#api";19} from "../api/errors";
20import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";21import * 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";
28import {28import {
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")?.id135 schemaBundleId: recipe.schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
147 ?? schemaBundles[0]?.id136 ?? 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.recipeRevisionId414 );
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(
packages/control-plane/src/runtime/source-materialization.ts+82−7
@@ -2,18 +2,33 @@ import type { SqlControlPlaneRows } from "#persistence"; · 1 unmodified line
2import type {2import type {
3 AccountId,3 AccountId,
4 Source,4 Source,
5 SourceStatus,
5} from "#schema";6} from "#schema";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7 8
9import {
10 buildLocalSourceArtifact,
11 writeLocalSourceArtifact,
12} from "./local-source-artifacts";
13import {
14 requireRuntimeLocalWorkspace,
15} from "./local-runtime-context";
16import {
17 loadLocalWorkspaceState,
18 writeLocalWorkspaceState,
19 type LocalWorkspaceState,
20} from "./local-workspace-state";
8import { resolveSourceAuthMaterial } from "./source-auth-material";21import { resolveSourceAuthMaterial } from "./source-auth-material";
9import {22import {
10 getSourceAdapterForSource,23 getSourceAdapterForSource,
11} from "./source-adapters";24} from "./source-adapters";
12import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp";25import {
26 materializationFromMcpManifestEntries,
27 persistMcpRecipeRevisionFromManifestEntries,
28} from "./source-adapters/mcp";
13import type {29import type {
14 ResolveSecretMaterial as ResolveSourceSecretMaterial,30 ResolveSecretMaterial as ResolveSourceSecretMaterial,
15} from "./secret-material-providers";31} from "./secret-material-providers";
16import { persistRecipeMaterialization } from "./source-recipe-support";
17 32
18const shouldIndexSource = (source: Source): boolean =>33const shouldIndexSource = (source: Source): boolean =>
19 source.enabled34 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 persistMcpRecipeRevisionFromManifestEntries117 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 });
packages/control-plane/src/runtime/source-recipes-runtime.test.ts+362−244
@@ -1,3 +1,7 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
1import { describe, expect, it } from "vitest";5import { describe, expect, it } from "vitest";
2import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
3import {7import {
@@ -8,8 +12,8 @@ import { · 4 unmodified lines
8 12
9import {13import {
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
28import {
29 loadLocalExecutorConfig,
30 resolveLocalWorkspaceContext,
31 writeProjectLocalExecutorConfig,
32} from "./local-config";
33import {
34 buildLocalSourceArtifact,
35 writeLocalSourceArtifact,
36} from "./local-source-artifacts";
37import {
38 RuntimeLocalWorkspaceService,
39 type RuntimeLocalWorkspaceState,
40} from "./local-runtime-context";
41import {
42 writeLocalWorkspaceState,
43} from "./local-workspace-state";
24import {44import {
25 expandRecipeTools,45 expandRecipeTools,
26 loadSourceWithRecipe,46 loadSourceWithRecipe,
@@ -38,6 +58,36 @@ const makePersistence = () => · 11 unmodified lines
38 }),58 }),
39 );59 );
40 60
61const 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
83const withRuntimeLocalWorkspace = <A, E>(
84 effect: Effect.Effect<A, E, never>,
85 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
86) =>
87 effect.pipe(
88 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
89 );
90
41const openApiBindingConfigJson = (specUrl: string): string =>91const 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
224const seedWorkspace = async (input: {274const 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
342const 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 }
packages/control-plane/src/runtime/source-recipes-runtime.ts+116−265
@@ -13,8 +13,14 @@ import type { · 12 unmodified lines
13 WorkspaceId,13 WorkspaceId,
14} from "#schema";14} from "#schema";
15import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";
17 16
17import {
18 readLocalSourceArtifact,
19} from "./local-source-artifacts";
20import { LocalSourceArtifactMissingError } from "./local-errors";
21import {
22 requireRuntimeLocalWorkspace,
23} from "./local-runtime-context";
18import { namespaceFromSourceName } from "./source-names";24import { namespaceFromSourceName } from "./source-names";
19import {25import {
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
226export const loadSourceWithRecipe = (input: {217export 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
432export const loadWorkspaceSourceRecipeToolByPath = (input: {366export 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 });
packages/control-plane/src/runtime/source-store.test.ts+113−348
@@ -1,10 +1,13 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
1import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
2 6
3import {7import {
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";
23import {26import {
24 stableSourceRecipeId,27 loadLocalExecutorConfig,
25 stableSourceRecipeRevisionId,28 resolveLocalWorkspaceContext,
26} from "./source-definitions";29} from "./local-config";
30import {
31 RuntimeLocalWorkspaceService,
32 type RuntimeLocalWorkspaceState,
33} from "./local-runtime-context";
34import {
35 loadLocalWorkspaceState,
36} from "./local-workspace-state";
27import { persistSource, removeSourceById } from "./source-store";37import { persistSource, removeSourceById } from "./source-store";
28 38
29const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =39const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
@@ -40,6 +50,33 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 10 unmodified lines
40 50
41const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);51const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
42 52
53const 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
72const withRuntimeLocalWorkspace = <A, E>(
73 effect: Effect.Effect<A, E, never>,
74 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
75) =>
76 effect.pipe(
77 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
78 );
79
43const makeOpenApiSource = (input: {80const 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});
packages/control-plane/src/runtime/source-store.ts+508−164
@@ -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";
11import { SourceIdSchema } from "#schema";
10import { type SqlControlPlaneRows } from "#persistence";12import { type SqlControlPlaneRows } from "#persistence";
11import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13 14
14import {15import {
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";
20import {
21 sourceAuthFromAuthArtifact,
22} from "./auth-artifacts";
23import {
24 loadLocalExecutorConfig,
25 type LoadedLocalExecutorConfig,
26 type ResolvedLocalWorkspaceContext,
27 writeProjectLocalExecutorConfig,
28} from "./local-config";
29import {
30 readLocalSourceArtifact,
31 removeLocalSourceArtifact,
32} from "./local-source-artifacts";
33import {
34 LocalConfiguredSourceNotFoundError,
35 LocalExecutorConfigDecodeError,
36 LocalFileSystemError,
37 LocalUnsupportedSourceKindError,
38 LocalWorkspaceStateDecodeError,
39 RuntimeLocalWorkspaceMismatchError,
40 RuntimeLocalWorkspaceUnavailableError,
41} from "./local-errors";
42import {
43 requireRuntimeLocalWorkspace,
44} from "./local-runtime-context";
45import {
46 loadLocalWorkspaceState,
47 writeLocalWorkspaceState,
48 type LocalWorkspaceState,
49} from "./local-workspace-state";
50import {
51 fromConfigSecretProviderId,
52 toConfigSecretProviderId,
53} from "./local-config-secrets";
23import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";54import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
24import { authArtifactSecretMaterialRefs } from "./auth-artifacts";55import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
25import { removeAuthLeaseAndSecrets } from "./auth-leases";56import { removeAuthLeaseAndSecrets } from "./auth-leases";
57import { getSourceAdapter } from "./source-adapters";
58import { slugify } from "./slug";
26 59
27const secretRefKey = (ref: { providerId: string; handle: string }): string =>60const 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
115const 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
124const cloneJson = <T>(value: T): T =>
125 JSON.parse(JSON.stringify(value)) as T;
126
127const 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
145const 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
154const 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
207const 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
243const 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,
257never> =>
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
271const 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
82export const loadSourcesInWorkspace = (381export 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
115export const loadSourceById = (rows: SqlControlPlaneRows, input: {419export 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,459const 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
154const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {517const 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
191const 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
225export const removeSourceById = (rows: SqlControlPlaneRows, input: {554export 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 === nextRecipeId643 ...(projectConfig.sources ?? {}),
283 ? existing.value.recipeRevisionId644 };
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 });
packages/control-plane/src/runtime/sources-operations.ts+90−85
@@ -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";
5import { Actor } from "#domain";
6import {5import {
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
140export const listSources = (workspaceId: WorkspaceId) =>139export 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
155export const createSource = (input: {157export 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
194export const getSource = (input: {197export 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
219export const updateSource = (input: {223export 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
274export const removeSource = (input: {279export const removeSource = (input: {
275 workspaceId: WorkspaceId;280 workspaceId: WorkspaceId;
packages/control-plane/src/runtime/test-http-client.ts+5−18
@@ -1,8 +1,6 @@
1import {1import {
2 HttpApiBuilder,2 HttpApiBuilder,
3 HttpApiClient,3 HttpApiClient,
4 HttpClient,
5 HttpClientRequest,
6} from "@effect/platform";4} from "@effect/platform";
7import { NodeHttpServer } from "@effect/platform-node";5import { NodeHttpServer } from "@effect/platform-node";
8import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -14,11 +12,10 @@ import { · 5 unmodified lines
14} from "#api";12} from "#api";
15 13
16import {14import {
17 ControlPlaneAuthHeaders,15 type ControlPlaneRuntime,
18 type SqlControlPlaneRuntime,
19} from "./index";16} from "./index";
20 17
21const createClientLayer = (runtime: SqlControlPlaneRuntime) => {18const 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
30const createControlPlaneClient = (accountId?: string) =>27const createControlPlaneClient = (accountId?: string) =>
28 (void accountId,
31 HttpApiClient.make(ControlPlaneApi, {29 HttpApiClient.make(ControlPlaneApi, {
32 transformClient: accountId30 }));
33 ? (client) =>
34 client.pipe(
35 HttpClient.mapRequest(
36 HttpClientRequest.setHeader(
37 ControlPlaneAuthHeaders.accountId,
38 accountId,
39 ),
40 ),
41 )
42 : undefined,
43 });
44 31
45type ControlPlaneClient = Effect.Effect.Success<32type 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
49export const withControlPlaneClient = <A, E>(36export 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>,
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+316−197
@@ -1,5 +1,8 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { mkdtempSync } from "node:fs";
2import { createServer } from "node:http";3import { createServer } from "node:http";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
3 6
4import {7import {
5 HttpApi,8 HttpApi,
@@ -45,7 +48,6 @@ import * as Effect from "effect/Effect"; · 39 unmodified lines
45import * as Exit from "effect/Exit";48import * as Exit from "effect/Exit";
46import * as Layer from "effect/Layer";49import * as Layer from "effect/Layer";
47import * as Scope from "effect/Scope";50import * as Scope from "effect/Scope";
48import * as Option from "effect/Option";
49import { Schema } from "effect";51import { Schema } from "effect";
50import * as z from "zod/v4";52import * 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";
64import {
65 loadLocalExecutorConfig,
66 resolveLocalWorkspaceContext,
67 writeProjectLocalExecutorConfig,
68} from "./local-config";
62import { createLiveExecutionManager } from "./live-execution";69import { createLiveExecutionManager } from "./live-execution";
63import { persistSource } from "./source-store";70import {
64import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization";71 RuntimeLocalWorkspaceService,
72 type RuntimeLocalWorkspaceState,
73} from "./local-runtime-context";
74import {
75 buildLocalSourceArtifact,
76 readLocalSourceArtifact,
77 writeLocalSourceArtifact,
78} from "./local-source-artifacts";
79import {
80 loadLocalWorkspaceState,
81 writeLocalWorkspaceState,
82} from "./local-workspace-state";
83import {
84 materializationFromMcpManifestEntries,
85} from "./source-adapters/mcp";
65import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";86import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
66 87
67type CountedMcpServer = {88type CountedMcpServer = {
@@ -579,7 +600,7 @@ const makeGraphqlServer = Effect.acquireRelease( · 511 unmodified lines
579);600);
580 601
581const persistConnectedEchoTool = (input: {602const 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
637const persistConnectedGithubOpenApiSource = (input: {696const 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 > 0943 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
1057const 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
968const makeResolver = (persistence: SqlControlPlanePersistence) =>1072const 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
988const makeResolverWithLiveSourceAuth = (1098const 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
1000describe("workspace-execution-environment", () => {1114describe("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 });
packages/control-plane/src/runtime/workspace-execution-environment.ts+92−81
@@ -37,8 +37,10 @@ import { · 36 unmodified lines
37 type RuntimeSourceAuthService,37 type RuntimeSourceAuthService,
38} from "./source-auth-service";38} from "./source-auth-service";
39import {39import {
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";
44import { resolveSourceAuthMaterial } from "./source-auth-material";46import { 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";
58import {
59 loadRuntimeLocalWorkspacePolicies,
60} from "./policies-operations";
61import {
62 getRuntimeLocalWorkspaceOption,
63 RuntimeLocalWorkspaceService,
64 type RuntimeLocalWorkspaceState,
65} from "./local-runtime-context";
56 66
57const asToolPath = (value: string): ToolPath => value as ToolPath;67const 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
210const scoreRecipeTool = (227const 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.workspaces393 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
468const 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
474const createWorkspaceToolCatalog = (input: {481const 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
620const createWorkspaceToolInvoker = (input: {628const 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 makeToolInvokerFromTools636 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
packages/control-plane/src/schema/enums.ts+4−18
@@ -1,13 +1,3 @@
1export {
2 OrganizationStatusSchema,
3 type OrganizationStatus,
4} from "./models/auth/organization";
5export {
6 OrganizationMemberStatusSchema,
7 RoleSchema,
8 type OrganizationMemberStatus,
9 type Role,
10} from "./models/auth/organization-membership";
11export {1export {
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";
74export {64export {
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";
packages/control-plane/src/schema/ids.ts+0−8
@@ -1,10 +1,6 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2 2
3export const AccountIdSchema = Schema.String.pipe(Schema.brand("AccountId"));3export const AccountIdSchema = Schema.String.pipe(Schema.brand("AccountId"));
4export const OrganizationIdSchema = Schema.String.pipe(Schema.brand("OrganizationId"));
5export const OrganizationMemberIdSchema = Schema.String.pipe(
6 Schema.brand("OrganizationMemberId"),
7);
8export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));4export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId"));
9export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));5export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId"));
10export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId"));6export 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);
33export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));29export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId"));
34export const InstallationIdSchema = Schema.String.pipe(Schema.brand("InstallationId"));
35export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));30export const ExecutionIdSchema = Schema.String.pipe(Schema.brand("ExecutionId"));
36export const ExecutionInteractionIdSchema = Schema.String.pipe(31export const ExecutionInteractionIdSchema = Schema.String.pipe(
37 Schema.brand("ExecutionInteractionId"),32 Schema.brand("ExecutionInteractionId"),
38);33);
39 34
40export type AccountId = typeof AccountIdSchema.Type;35export type AccountId = typeof AccountIdSchema.Type;
41export type OrganizationId = typeof OrganizationIdSchema.Type;
42export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type;
43export type WorkspaceId = typeof WorkspaceIdSchema.Type;36export type WorkspaceId = typeof WorkspaceIdSchema.Type;
44export type SourceId = typeof SourceIdSchema.Type;37export type SourceId = typeof SourceIdSchema.Type;
45export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;38export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;
@@ -52,6 +45,5 @@ export type CredentialId = typeof CredentialIdSchema.Type; · 6 unmodified lines
52export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;45export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
53export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;46export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
54export type PolicyId = typeof PolicyIdSchema.Type;47export type PolicyId = typeof PolicyIdSchema.Type;
55export type InstallationId = typeof InstallationIdSchema.Type;
56export type ExecutionId = typeof ExecutionIdSchema.Type;48export type ExecutionId = typeof ExecutionIdSchema.Type;
57export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type;49export type ExecutionInteractionId = typeof ExecutionInteractionIdSchema.Type;
packages/control-plane/src/schema/index.ts+1−1
@@ -3,7 +3,6 @@ export * from "./enums"; · 2 unmodified lines
3export * from "./ids";3export * from "./ids";
4 4
5export * from "./models/auth";5export * from "./models/auth";
6export * from "./models/workspace";
7export * from "./models/source";6export * from "./models/source";
8export * from "./models/source-recipe";7export * from "./models/source-recipe";
9export * from "./models/auth-artifact";8export * from "./models/auth-artifact";
@@ -13,6 +12,7 @@ export * from "./models/secret-material"; · 3 unmodified lines
13export * from "./models/source-auth-session";12export * from "./models/source-auth-session";
14export * from "./models/policy";13export * from "./models/policy";
15export * from "./models/local-installation";14export * from "./models/local-installation";
15export * from "./models/local-config";
16export * from "./models/execution";16export * from "./models/execution";
17export * from "./models/source-inspection";17export * from "./models/source-inspection";
18export * from "./models/source-discovery";18export * from "./models/source-discovery";
packages/control-plane/src/schema/models/auth/index.ts+0−5
@@ -1,7 +1,2 @@
1export * from "./account";1export * from "./account";
2export * from "./membership";
3export * from "./organization";
4export * from "./organization-membership";
5export * from "./permission";
6export * from "./principal";2export * from "./principal";
7export * from "./role";
packages/control-plane/src/schema/models/auth/membership.ts+0−24
@@ -1,24 +0,0 @@
1import { Schema } from "effect";
2
3import { TimestampMsSchema } from "../../common";
4import { AccountIdSchema, WorkspaceIdSchema } from "../../ids";
5
6import { RoleSchema } from "./organization-membership";
7
8export const MembershipStatusSchema = Schema.Literal(
9 "active",
10 "suspended",
11 "revoked",
12);
13
14export const WorkspaceMembershipSchema = Schema.Struct({
15 accountId: AccountIdSchema,
16 workspaceId: WorkspaceIdSchema,
17 role: RoleSchema,
18 status: MembershipStatusSchema,
19 grantedAt: TimestampMsSchema,
20 updatedAt: TimestampMsSchema,
21});
22
23export type MembershipStatus = typeof MembershipStatusSchema.Type;
24export type WorkspaceMembership = typeof WorkspaceMembershipSchema.Type;
packages/control-plane/src/schema/models/auth/organization-membership.ts+0−55
@@ -1,55 +0,0 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";
7
8import { organizationMembershipsTable } from "../../../persistence/schema";
9import { TimestampMsSchema } from "../../common";
10import {
11 AccountIdSchema,
12 OrganizationIdSchema,
13 OrganizationMemberIdSchema,
14} from "../../ids";
15
16export const RoleSchema = Schema.Literal("viewer", "editor", "admin", "owner");
17
18export const OrganizationMemberStatusSchema = Schema.Literal(
19 "invited",
20 "active",
21 "suspended",
22 "removed",
23);
24
25const 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
38export const OrganizationMembershipSchema = createSelectSchema(
39 organizationMembershipsTable,
40 organizationMembershipSchemaOverrides,
41);
42
43export const OrganizationMembershipInsertSchema = createInsertSchema(
44 organizationMembershipsTable,
45 organizationMembershipSchemaOverrides,
46);
47
48export const OrganizationMembershipUpdateSchema = createUpdateSchema(
49 organizationMembershipsTable,
50 organizationMembershipSchemaOverrides,
51);
52
53export type Role = typeof RoleSchema.Type;
54export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type;
55export type OrganizationMembership = typeof OrganizationMembershipSchema.Type;
packages/control-plane/src/schema/models/auth/organization.ts+0−42
@@ -1,42 +0,0 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";
7
8import { organizationsTable } from "../../../persistence/schema";
9import { TimestampMsSchema } from "../../common";
10import { AccountIdSchema, OrganizationIdSchema } from "../../ids";
11
12export const OrganizationStatusSchema = Schema.Literal(
13 "active",
14 "suspended",
15 "archived",
16);
17
18const organizationSchemaOverrides = {
19 id: OrganizationIdSchema,
20 status: OrganizationStatusSchema,
21 createdByAccountId: Schema.NullOr(AccountIdSchema),
22 createdAt: TimestampMsSchema,
23 updatedAt: TimestampMsSchema,
24} as const;
25
26export const OrganizationSchema = createSelectSchema(
27 organizationsTable,
28 organizationSchemaOverrides,
29);
30
31export const OrganizationInsertSchema = createInsertSchema(
32 organizationsTable,
33 organizationSchemaOverrides,
34);
35
36export const OrganizationUpdateSchema = createUpdateSchema(
37 organizationsTable,
38 organizationSchemaOverrides,
39);
40
41export type OrganizationStatus = typeof OrganizationStatusSchema.Type;
42export type Organization = typeof OrganizationSchema.Type;
packages/control-plane/src/schema/models/auth/permission.ts+0−36
@@ -1,36 +0,0 @@
1import { Schema } from "effect";
2
3const 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
11export const PermissionDomainActions = permissionDomainActions;
12
13export type PermissionDomain = keyof typeof permissionDomainActions;
14type PermissionAction<D extends PermissionDomain> =
15 (typeof permissionDomainActions)[D][number];
16
17export type Permission = {
18 [D in PermissionDomain]: `${D}:${PermissionAction<D>}`;
19}[PermissionDomain];
20
21export const PermissionValues: ReadonlyArray<Permission> = Object.entries(
22 permissionDomainActions,
23).flatMap(([domain, actions]) =>
24 actions.map((action) => `${domain}:${action}` as Permission),
25);
26
27const permissionSet = new Set<string>(PermissionValues);
28
29const isPermission = (value: string): value is Permission =>
30 permissionSet.has(value);
31
32export const PermissionSchema = Schema.String.pipe(
33 Schema.filter(isPermission, {
34 message: () => "Invalid permission",
35 }),
36);
packages/control-plane/src/schema/models/auth/role.ts+0−32
@@ -1,32 +0,0 @@
1import { Schema } from "effect";
2
3import { RoleSchema } from "./organization-membership";
4import {
5 PermissionSchema,
6 PermissionValues,
7 type Permission,
8} from "./permission";
9
10type Role = typeof RoleSchema.Type;
11
12const readOnlyPermissions: ReadonlyArray<Permission> = PermissionValues.filter(
13 (permission) => permission.endsWith(":read"),
14);
15
16const writePermissions: ReadonlyArray<Permission> = PermissionValues.filter(
17 (permission) => permission.endsWith(":write"),
18);
19
20export const RolePermissions = {
21 viewer: readOnlyPermissions,
22 editor: [...readOnlyPermissions, ...writePermissions],
23 admin: PermissionValues,
24 owner: PermissionValues,
25} as const satisfies Record<Role, ReadonlyArray<Permission>>;
26
27export const RolePermissionsSchema = Schema.Record({
28 key: RoleSchema,
29 value: Schema.Array(PermissionSchema),
30});
31
32export type RolePermissions = typeof RolePermissionsSchema.Type;
packages/control-plane/src/schema/models/local-config.ts+185−0
@@ -0,0 +1,185 @@
1import { Schema } from "effect";
2
3import { SourceTransportSchema, StringMapSchema } from "./source";
4
5export const LocalConfigSecretProviderSourceSchema = Schema.Literal(
6 "env",
7 "file",
8 "exec",
9 "params",
10);
11
12export const LocalConfigEnvSecretProviderSchema = Schema.Struct({
13 source: Schema.Literal("env"),
14});
15
16export const LocalConfigFileSecretProviderModeSchema = Schema.Literal(
17 "singleValue",
18 "json",
19);
20
21export const LocalConfigFileSecretProviderSchema = Schema.Struct({
22 source: Schema.Literal("file"),
23 path: Schema.String,
24 mode: Schema.optional(LocalConfigFileSecretProviderModeSchema),
25});
26
27export 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
36export const LocalConfigSecretProviderSchema = Schema.Union(
37 LocalConfigEnvSecretProviderSchema,
38 LocalConfigFileSecretProviderSchema,
39 LocalConfigExecSecretProviderSchema,
40);
41
42export const LocalConfigExplicitSecretRefSchema = Schema.Struct({
43 source: LocalConfigSecretProviderSourceSchema,
44 provider: Schema.String,
45 id: Schema.String,
46});
47
48export const LocalConfigSecretInputSchema = Schema.Union(
49 Schema.String,
50 LocalConfigExplicitSecretRefSchema,
51);
52
53export const LocalConfigSourceConnectionSchema = Schema.Struct({
54 endpoint: Schema.String,
55 auth: Schema.optional(LocalConfigSecretInputSchema),
56});
57
58export const LocalConfigOpenApiBindingSchema = Schema.Struct({
59 specUrl: Schema.String,
60 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
61});
62
63export const LocalConfigGraphqlBindingSchema = Schema.Struct({
64 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
65});
66
67export 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
73export 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
81const 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
88export const LocalConfigOpenApiSourceSchema = Schema.extend(
89 LocalConfigSourceEntryBaseSchema,
90 Schema.Struct({
91 kind: Schema.Literal("openapi"),
92 binding: LocalConfigOpenApiBindingSchema,
93 }),
94);
95
96export const LocalConfigGraphqlSourceSchema = Schema.extend(
97 LocalConfigSourceEntryBaseSchema,
98 Schema.Struct({
99 kind: Schema.Literal("graphql"),
100 binding: LocalConfigGraphqlBindingSchema,
101 }),
102);
103
104export const LocalConfigMcpSourceSchema = Schema.extend(
105 LocalConfigSourceEntryBaseSchema,
106 Schema.Struct({
107 kind: Schema.Literal("mcp"),
108 binding: LocalConfigMcpBindingSchema,
109 }),
110);
111
112export const LocalConfigGoogleDiscoverySourceSchema = Schema.extend(
113 LocalConfigSourceEntryBaseSchema,
114 Schema.Struct({
115 kind: Schema.Literal("google_discovery"),
116 binding: LocalConfigGoogleDiscoveryBindingSchema,
117 }),
118);
119
120export const LocalConfigSourceSchema = Schema.Union(
121 LocalConfigOpenApiSourceSchema,
122 LocalConfigGraphqlSourceSchema,
123 LocalConfigMcpSourceSchema,
124 LocalConfigGoogleDiscoverySourceSchema,
125);
126
127export const LocalConfigPolicyActionSchema = Schema.Literal("allow", "deny");
128export const LocalConfigPolicyApprovalSchema = Schema.Literal("auto", "manual");
129
130export 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
138export 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
154export const LocalConfigWorkspaceSchema = Schema.Struct({
155 name: Schema.optional(Schema.String),
156});
157
158export 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
175export type LocalConfigSecretProviderSource =
176 typeof LocalConfigSecretProviderSourceSchema.Type;
177export type LocalConfigSecretProvider =
178 typeof LocalConfigSecretProviderSchema.Type;
179export type LocalConfigExplicitSecretRef =
180 typeof LocalConfigExplicitSecretRefSchema.Type;
181export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type;
182export type LocalConfigSource = typeof LocalConfigSourceSchema.Type;
183export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type;
184export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type;
185export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type;
packages/control-plane/src/schema/models/local-installation.ts+5−29
@@ -1,40 +1,16 @@
1import {1import * as Schema from "effect/Schema";
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6 2
7import { localInstallationsTable } from "../../persistence/schema";
8import { TimestampMsSchema } from "../common";
9import {3import {
10 AccountIdSchema,4 AccountIdSchema,
11 InstallationIdSchema,
12 OrganizationIdSchema,
13 WorkspaceIdSchema,5 WorkspaceIdSchema,
14} from "../ids";6} from "../ids";
15 7
16const localInstallationSchemaOverrides = {8export 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
25export const LocalInstallationSchema = createSelectSchema(
26 localInstallationsTable,
27 localInstallationSchemaOverrides,
28);
29
30export const LocalInstallationInsertSchema = createInsertSchema(
31 localInstallationsTable,
32 localInstallationSchemaOverrides,
33);
34 12
35export const LocalInstallationUpdateSchema = createUpdateSchema(13export const LocalInstallationInsertSchema = LocalInstallationSchema;
36 localInstallationsTable,14export const LocalInstallationUpdateSchema = Schema.partial(LocalInstallationSchema);
37 localInstallationSchemaOverrides,
38);
39 15
40export type LocalInstallation = typeof LocalInstallationSchema.Type;16export type LocalInstallation = typeof LocalInstallationSchema.Type;
packages/control-plane/src/schema/models/policy.ts+18−69
@@ -1,79 +1,28 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";1import { Schema } from "effect";
7 2
8import { policiesTable } from "../../persistence/schema";
9import { TimestampMsSchema } from "../common";3import { TimestampMsSchema } from "../common";
10import {4import { PolicyIdSchema, WorkspaceIdSchema } from "../ids";
11 AccountIdSchema,
12 OrganizationIdSchema,
13 PolicyIdSchema,
14 WorkspaceIdSchema,
15} from "../ids";
16 5
17export const PolicyScopeTypeSchema = Schema.Literal("organization", "workspace");6export const LocalWorkspacePolicyEffectSchema = Schema.Literal("allow", "deny");
7export const LocalWorkspacePolicyApprovalModeSchema = Schema.Literal("auto", "required");
18 8
19export const PolicyResourceTypeSchema = Schema.Literal(9export const LocalWorkspacePolicySchema = Schema.Struct({
20 "all_tools",
21 "source",
22 "namespace",
23 "tool_path",
24);
25
26export const PolicyMatchTypeSchema = Schema.Literal("glob", "exact");
27export const PolicyEffectSchema = Schema.Literal("allow", "deny");
28export const PolicyApprovalModeSchema = Schema.Literal("auto", "required");
29
30export const ArgumentConditionOperatorSchema = Schema.Literal(
31 "equals",
32 "contains",
33 "starts_with",
34 "not_equals",
35);
36
37export const PolicyArgumentConditionSchema = Schema.Struct({
38 key: Schema.String,
39 operator: ArgumentConditionOperatorSchema,
40 value: Schema.String,
41});
42
43const 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
59export const PolicySchema = createSelectSchema(policiesTable, policySchemaOverrides);
60
61export const PolicyInsertSchema = createInsertSchema(
62 policiesTable,
63 policySchemaOverrides,
64);
65 21
66export const PolicyUpdateSchema = createUpdateSchema(22export const LocalWorkspacePolicyInsertSchema = LocalWorkspacePolicySchema;
67 policiesTable,23export const LocalWorkspacePolicyUpdateSchema = Schema.partial(LocalWorkspacePolicySchema);
68 policySchemaOverrides,
69);
70 24
71export type PolicyResourceType = typeof PolicyResourceTypeSchema.Type;25export type LocalWorkspacePolicyEffect = typeof LocalWorkspacePolicyEffectSchema.Type;
72export type PolicyMatchType = typeof PolicyMatchTypeSchema.Type;26export type LocalWorkspacePolicyApprovalMode =
73export type PolicyEffect = typeof PolicyEffectSchema.Type;27 typeof LocalWorkspacePolicyApprovalModeSchema.Type;
74export type PolicyApprovalMode = typeof PolicyApprovalModeSchema.Type;28export type LocalWorkspacePolicy = typeof LocalWorkspacePolicySchema.Type;
75export type PolicyScopeType = typeof PolicyScopeTypeSchema.Type;
76export type ArgumentConditionOperator =
77 typeof ArgumentConditionOperatorSchema.Type;
78export type PolicyArgumentCondition = typeof PolicyArgumentConditionSchema.Type;
79export type Policy = typeof PolicySchema.Type;
packages/control-plane/src/schema/models/workspace.ts+0−32
@@ -1,32 +0,0 @@
1import {
2 createInsertSchema,
3 createSelectSchema,
4 createUpdateSchema,
5} from "drizzle-orm/effect-schema";
6import { Schema } from "effect";
7
8import { workspacesTable } from "../../persistence/schema";
9import { TimestampMsSchema } from "../common";
10import { AccountIdSchema, OrganizationIdSchema, WorkspaceIdSchema } from "../ids";
11
12const workspaceSchemaOverrides = {
13 id: WorkspaceIdSchema,
14 organizationId: OrganizationIdSchema,
15 createdByAccountId: Schema.NullOr(AccountIdSchema),
16 createdAt: TimestampMsSchema,
17 updatedAt: TimestampMsSchema,
18} as const;
19
20export const WorkspaceSchema = createSelectSchema(workspacesTable, workspaceSchemaOverrides);
21
22export const WorkspaceInsertSchema = createInsertSchema(
23 workspacesTable,
24 workspaceSchemaOverrides,
25);
26
27export const WorkspaceUpdateSchema = createUpdateSchema(
28 workspacesTable,
29 workspaceSchemaOverrides,
30);
31
32export type Workspace = typeof WorkspaceSchema.Type;
packages/control-plane/src/schema/schema.test.ts+0−9
@@ -3,19 +3,10 @@ import * as Either from "effect/Either"; · 2 unmodified lines
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4 4
5import {5import {
6 PermissionValues,
7 RolePermissions,
8 StoredSourceRecipeOperationRecordSchema,6 StoredSourceRecipeOperationRecordSchema,
9} from "./index";7} from "./index";
10 8
11describe("control-plane-schema", () => {9describe("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
packages/executor-mcp/src/index.ts+14−63
@@ -1,7 +1,6 @@
1import type {1import type {
2 ExecutionEnvelope,2 ExecutionEnvelope,
3 ExecutionInteraction,3 ControlPlaneRuntime,
4 SqlControlPlaneRuntime,
5} from "@executor/control-plane";4} from "@executor/control-plane";
6import {5import {
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
18import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";17import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
19import { z } from "zod/v4";18import { z } from "zod/v4";
20 19
20import {
21 buildPausedResultText,
22 parseInteractionPayload,
23} from "./paused-result";
24
21const pollingIntervalMs = 200;25const pollingIntervalMs = 200;
22 26
23const executeInputSchema = {27const executeInputSchema = {
@@ -34,14 +38,6 @@ const resumeInputSchema = { · 10 unmodified lines
34 }).optional(),38 }).optional(),
35};39};
36 40
37type ParsedInteractionPayload = {
38 mode: "form" | "url";
39 message: string;
40 url?: string;
41 requestedSchema?: Record<string, unknown>;
42 elicitationId?: string;
43};
44
45type ResumePayload = {41type 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
65const isRecord = (value: unknown): value is Record<string, unknown> =>
66 typeof value === "object" && value !== null && !Array.isArray(value);
67
68const parseJsonValue = (value: string | null): unknown => {61const 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
98const 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
136const runControlPlane = async <A, E, R>(89const 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
193const defaultExecuteDescription = buildExecuteWorkflowText();146const defaultExecuteDescription = buildExecuteWorkflowText();
194 147
195const loadExecuteDescription = (runtime: SqlControlPlaneRuntime): Promise<string> =>148const 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: interaction221 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
307const waitForInteractionProgress = async (input: {258const 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
333const driveExecutionWithElicitation = async (input: {284const 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
410const driveExecutionWithoutElicitation = async (input: {361const 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
449const createExecutorMcpServer = async (config: {400const 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
554export const createExecutorMcpRequestHandler = (505export 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>();
packages/executor-mcp/src/paused-result.test.ts+97−0
@@ -0,0 +1,97 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { ExecutionEnvelope, ExecutionInteraction } from "@executor/control-plane";
3
4import {
5 buildPausedResultText,
6 parseInteractionPayload,
7} from "./paused-result";
8
9const 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
29const 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
50describe("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});
packages/executor-mcp/src/paused-result.ts+95−0
@@ -0,0 +1,95 @@
1import type {
2 ExecutionEnvelope,
3 ExecutionInteraction,
4} from "@executor/control-plane";
5
6export type ParsedInteractionPayload = {
7 mode: "form" | "url";
8 message: string;
9 url?: string;
10 requestedSchema?: Record<string, unknown>;
11 elicitationId?: string;
12};
13
14const isRecord = (value: unknown): value is Record<string, unknown> =>
15 typeof value === "object" && value !== null && !Array.isArray(value);
16
17export 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
55const formatResumePayload = (executionId: string): string =>
56 JSON.stringify({ executionId }, null, 2);
57
58export 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};
packages/react/src/index.test.tsx+14−4
@@ -5,10 +5,10 @@ import { NodeHttpServer } from "@effect/platform-node"; · 4 unmodified lines
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import {6import {
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";
13import * as Context from "effect/Context";13import * as Context from "effect/Context";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
@@ -69,7 +69,7 @@ type RunningServer = { · 54 unmodified lines
69};69};
70 70
71type ApiServer = RunningServer & {71type ApiServer = RunningServer & {
72 runtime: SqlControlPlaneRuntime;72 runtime: ControlPlaneRuntime;
73};73};
74 74
75type HookHarness<T> = {75type HookHarness<T> = {
@@ -95,7 +95,10 @@ const closeScope = (scope: Scope.CloseableScope) => · 19 unmodified lines
95 95
96const startControlPlaneServer = async (): Promise<ApiServer> => {96const 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});
753import { mkdtempSync } from "node:fs";
754import { tmpdir } from "node:os";
755import { join } from "node:path";
packages/react/src/index.ts+37−25
@@ -230,14 +230,16 @@ const logExecutorDevError = (label: string, details: Record<string, unknown>): v · 229 unmodified lines
230};230};
231 231
232const runControlPlane = async <A>(input: {232const 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
259const controlPlaneRequest = <A>(input: {261const 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
268const localInstallationAtom = Atom.make(271const 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 }),
274const instanceConfigAtom = Atom.make(277 ).pipe(Atom.keepAlive),
275 controlPlaneRequest({278);
276 execute: (client) => client.local.config({}),279
277 }),280const instanceConfigAtom = Atom.family((baseUrl: string) =>
278).pipe(Atom.keepAlive);281 Atom.make(
279 282 controlPlaneRequest({
280const 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
289const 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
286const sourcesAtom = Atom.family((key: string) => {298const 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
455const useWorkspaceContext = (): Loadable<WorkspaceContext> => {467const 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
812export const useLocalInstallation = (): Loadable<LocalInstallation> =>824export const useLocalInstallation = (): Loadable<LocalInstallation> =>
813 useLoadableAtom(localInstallationAtom);825 useLoadableAtom(localInstallationAtom(apiBaseUrl));
814 826
815export const useInstanceConfig = (): Loadable<InstanceConfig> =>827export const useInstanceConfig = (): Loadable<InstanceConfig> =>
816 useLoadableAtom(instanceConfigAtom);828 useLoadableAtom(instanceConfigAtom(apiBaseUrl));
817 829
818export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>830export const useSecrets = (): Loadable<ReadonlyArray<SecretListItem>> =>
819 useLoadableAtom(secretsAtom);831 useLoadableAtom(secretsAtom(apiBaseUrl));
820 832
821export const useRefreshSecrets = (): (() => void) => {833export 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) {
packages/runtime-ses/src/index.ts+0−1
@@ -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
packages/runtime-ses/src/sandbox-worker.mjs+9−1
@@ -24,6 +24,14 @@ const formatLogArg = (value) => { · 23 unmodified lines
24 24
25const formatLogLine = (args) => args.map(formatLogArg).join(" ");25const formatLogLine = (args) => args.map(formatLogArg).join(" ");
26 26
27const toSerializableValue = (value) => {
28 if (typeof value === "undefined") {
29 return undefined;
30 }
31
32 return JSON.parse(JSON.stringify(value));
33};
34
27const buildExecutionSource = (code) => {35const 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) {
packages/server/src/index.ts+9−7
@@ -11,10 +11,10 @@ import { Readable } from "node:stream"; · 10 unmodified lines
11import { HttpApiBuilder, HttpServer } from "@effect/platform";11import { HttpApiBuilder, HttpServer } from "@effect/platform";
12import {12import {
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";
19import { createExecutorMcpRequestHandler } from "@executor/executor-mcp";19import { createExecutorMcpRequestHandler } from "@executor/executor-mcp";
20import * as Cause from "effect/Cause";20import * as Cause from "effect/Cause";
@@ -58,7 +58,7 @@ type StaticUiOptions = { · 37 unmodified lines
58};58};
59 59
60export type LocalExecutorServer = {60export 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
78export type LocalExecutorRequestHandler = {79export 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
85type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>;86type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>;
86type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>;87type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>;
87 88
88const disposeRuntime = (runtime: SqlControlPlaneRuntime) =>89const 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
210const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) =>212const createControlPlaneWebHandler = (runtime: ControlPlaneRuntime) =>
211 Effect.acquireRelease(213 Effect.acquireRelease(
212 Effect.sync(() =>214 Effect.sync(() =>
213 HttpApiBuilder.toWebHandler(215 HttpApiBuilder.toWebHandler(

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t