History

Complete local-first control-plane cut

Rhys Sullivan committed 8 months ago

Commit 02b7cb61 parent d64dce342 files · +1878 −4359Browse files

Unified · Split

42 files
  1. apps/executor/src/cli/main.ts +5 −5
  2. apps/executor/src/server/server.test.ts +7 −7
  3. packages/control-plane/src/api/api.ts +1 −7
  4. packages/control-plane/src/api/http.ts +0 −6
  5. packages/control-plane/src/api/index.ts +0 −21
  6. packages/control-plane/src/api/memberships/api.ts +0 −84
  7. packages/control-plane/src/api/memberships/http.ts +0 −67
  8. packages/control-plane/src/api/organizations/api.ts +0 −82
  9. packages/control-plane/src/api/organizations/http.ts +0 −75
  10. packages/control-plane/src/api/payload-schemas.test.ts +0 −35
  11. packages/control-plane/src/api/policies/api.ts +0 −49
  12. packages/control-plane/src/api/policies/http.ts +2 −67
  13. packages/control-plane/src/api/workspaces/api.ts +0 −83
  14. packages/control-plane/src/api/workspaces/http.ts +0 −89
  15. packages/control-plane/src/persistence/post-migrations.test.ts +47 −34
  16. packages/control-plane/src/runtime/actor-resolver.ts +32 −52
  17. packages/control-plane/src/runtime/control-plane-runtime.test.ts +4 −405
  18. packages/control-plane/src/runtime/execution-http.test.ts +2 −2
  19. packages/control-plane/src/runtime/execution-mcp-resume.test.ts +2 −2
  20. packages/control-plane/src/runtime/index.ts +14 −16
  21. packages/control-plane/src/runtime/local-installation.test.ts +8 −15
  22. packages/control-plane/src/runtime/local-installation.ts +31 −257
  23. packages/control-plane/src/runtime/local-operations.ts +1 −21
  24. packages/control-plane/src/runtime/local-runtime-context.ts +22 −1
  25. packages/control-plane/src/runtime/local-workspace-sync.ts +1 −45
  26. packages/control-plane/src/runtime/operations-shared.ts +0 −55
  27. packages/control-plane/src/runtime/organizations-operations.ts +0 −434
  28. packages/control-plane/src/runtime/policies-operations.ts +232 −573
  29. packages/control-plane/src/runtime/source-auth-service.test.ts +76 −60
  30. packages/control-plane/src/runtime/source-inspection.test.ts +162 −121
  31. packages/control-plane/src/runtime/source-materialization.ts +67 −105
  32. packages/control-plane/src/runtime/source-recipes-runtime.test.ts +371 −245
  33. packages/control-plane/src/runtime/source-recipes-runtime.ts +78 −198
  34. packages/control-plane/src/runtime/source-store.test.ts +130 −348
  35. packages/control-plane/src/runtime/source-store.ts +178 −428
  36. packages/control-plane/src/runtime/test-http-client.ts +3 −3
  37. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +377 −196
  38. packages/control-plane/src/runtime/workspace-execution-environment.ts +8 −33
  39. packages/control-plane/src/schema/models/local-config.ts +1 −0
  40. packages/control-plane/src/schema/models/local-installation.ts +5 −22
  41. packages/react/src/index.test.tsx +4 −4
  42. packages/server/src/index.ts +7 −7
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/server/server.test.ts+7−7
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
21 createControlPlaneClient,21 createControlPlaneClient,
22 type ControlPlaneClient,22 type ControlPlaneClient,
23 buildLocalSourceArtifact,23 buildLocalSourceArtifact,
24 deriveLocalInstallation,
24 materializationFromMcpManifestEntries,25 materializationFromMcpManifestEntries,
25 type ResolveExecutionEnvironment,26 type ResolveExecutionEnvironment,
26 resolveLocalWorkspaceContext,27 resolveLocalWorkspaceContext,
@@ -96,6 +97,7 @@ const writeConfiguredLocalMcpSource = (input: { · 69 unmodified lines
96 const context = await resolveLocalWorkspaceContext({97 const context = await resolveLocalWorkspaceContext({
97 workspaceRoot: input.workspaceRoot,98 workspaceRoot: input.workspaceRoot,
98 });99 });
100 const installation = deriveLocalInstallation(context);
99 101
100 await writeProjectLocalExecutorConfig({102 await writeProjectLocalExecutorConfig({
101 context,103 context,
@@ -118,7 +120,7 @@ const writeConfiguredLocalMcpSource = (input: { · 16 unmodified lines
118 120
119 const source = {121 const source = {
120 id: sourceId,122 id: sourceId,
121 workspaceId: "ws_local_server_test" as never,123 workspaceId: installation.workspaceId,
122 name: input.name ?? "Demo",124 name: input.name ?? "Demo",
123 kind: "mcp" as const,125 kind: "mcp" as const,
124 endpoint: input.endpoint,126 endpoint: input.endpoint,
@@ -1259,7 +1261,7 @@ describe("local-executor-server", () => { · 1134 unmodified lines
1259 15_000,1261 15_000,
1260 );1262 );
1261 1263
1262 it.scoped("gates mutating OpenAPI tools by default and allows them via organization policy", () =>1264 it.scoped("gates mutating OpenAPI tools by default and allows them via workspace policy", () =>
1263 Effect.gen(function* () {1265 Effect.gen(function* () {
1264 const openApiServer = yield* Effect.acquireRelease(1266 const openApiServer = yield* Effect.acquireRelease(
1265 Effect.promise(() => startMutatingOpenApiDemoServer()),1267 Effect.promise(() => startMutatingOpenApiDemoServer()),
@@ -1331,19 +1333,17 @@ describe("local-executor-server", () => { · 65 unmodified lines
1331 expect(approved.execution.status).toBe("completed");1333 expect(approved.execution.status).toBe("completed");
1332 expect(openApiServer.createdBodies).toHaveLength(1);1334 expect(openApiServer.createdBodies).toHaveLength(1);
1333 1335
1334 const policy = yield* client.policies.createOrganization({1336 const policy = yield* client.policies.create({
1335 path: {1337 path: {
1336 organizationId: installation.organizationId,1338 workspaceId: installation.workspaceId,
1337 },1339 },
1338 payload: {1340 payload: {
1339 resourceType: "tool_path",
1340 resourcePattern: "dns.records.createRecord",1341 resourcePattern: "dns.records.createRecord",
1341 matchType: "exact",
1342 effect: "allow",1342 effect: "allow",
1343 approvalMode: "auto",1343 approvalMode: "auto",
1344 },1344 },
1345 });1345 });
1346 expect(policy.scopeType).toBe("organization");1346 expect(policy.scopeType).toBe("workspace");
1347 1347
1348 const automatic = yield* client.executions.create({1348 const automatic = yield* client.executions.create({
1349 path: {1349 path: {
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/http.ts+0−6
@@ -8,19 +8,13 @@ import { · 7 unmodified lines
8} from "./auth/actor-resolver";8} from "./auth/actor-resolver";
9import { ControlPlaneExecutionsLive } from "./executions/http";9import { ControlPlaneExecutionsLive } from "./executions/http";
10import { ControlPlaneLocalLive } from "./local/http";10import { ControlPlaneLocalLive } from "./local/http";
11import { ControlPlaneMembershipsLive } from "./memberships/http";
12import { ControlPlaneOAuthLive } from "./oauth/http";11import { ControlPlaneOAuthLive } from "./oauth/http";
13import { ControlPlaneOrganizationsLive } from "./organizations/http";
14import { ControlPlanePoliciesLive } from "./policies/http";12import { ControlPlanePoliciesLive } from "./policies/http";
15import { ControlPlaneSourcesLive } from "./sources/http";13import { ControlPlaneSourcesLive } from "./sources/http";
16import { ControlPlaneWorkspacesLive } from "./workspaces/http";
17 14
18export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(15export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(
19 Layer.provide(ControlPlaneLocalLive),16 Layer.provide(ControlPlaneLocalLive),
20 Layer.provide(ControlPlaneOAuthLive),17 Layer.provide(ControlPlaneOAuthLive),
21 Layer.provide(ControlPlaneOrganizationsLive),
22 Layer.provide(ControlPlaneMembershipsLive),
23 Layer.provide(ControlPlaneWorkspacesLive),
24 Layer.provide(ControlPlaneSourcesLive),18 Layer.provide(ControlPlaneSourcesLive),
25 Layer.provide(ControlPlanePoliciesLive),19 Layer.provide(ControlPlanePoliciesLive),
26 Layer.provide(ControlPlaneExecutionsLive),20 Layer.provide(ControlPlaneExecutionsLive),
packages/control-plane/src/api/index.ts+0−21
@@ -60,27 +60,6 @@ export { · 59 unmodified lines
60 type CompleteSourceOAuthResult,60 type CompleteSourceOAuthResult,
61} from "./oauth/api";61} from "./oauth/api";
62 62
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 {63export {
85 ConnectSourcePayloadSchema,64 ConnectSourcePayloadSchema,
86 ConnectSourceResultSchema,65 ConnectSourceResultSchema,
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/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+0−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: " ",
packages/control-plane/src/api/policies/api.ts+0−49
@@ -1,7 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 AccountIdSchema,3 AccountIdSchema,
4 OrganizationIdSchema,
5 PolicyIdSchema,4 PolicyIdSchema,
6 PolicyApprovalModeSchema,5 PolicyApprovalModeSchema,
7 PolicyEffectSchema,6 PolicyEffectSchema,
@@ -51,58 +50,10 @@ export const UpdatePolicyPayloadSchema = Schema.Struct({ · 43 unmodified lines
51 50
52export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;51export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type;
53 52
54const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema);
55const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);53const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
56const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema);54const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema);
57 55
58export class PoliciesApi extends HttpApiGroup.make("policies")56export 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(57 .add(
107 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies`58 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies`
108 .addSuccess(Schema.Array(PolicySchema))59 .addSuccess(Schema.Array(PolicySchema))
packages/control-plane/src/api/policies/http.ts+2−67
@@ -1,34 +1,17 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import type { OrganizationId, WorkspaceId } from "#schema";2import type { WorkspaceId } from "#schema";
3 3
4import { requirePermission, withPolicy } from "#domain";4import { requirePermission, withPolicy } from "#domain";
5import {5import {
6 createOrganizationPolicy,
7 createPolicy,6 createPolicy,
8 getOrganizationPolicy,
9 getPolicy,7 getPolicy,
10 listOrganizationPolicies,
11 listPolicies,8 listPolicies,
12 removeOrganizationPolicy,
13 removePolicy,9 removePolicy,
14 updateOrganizationPolicy,
15 updatePolicy,10 updatePolicy,
16} from "../../runtime/policies-operations";11} from "../../runtime/policies-operations";
17 12
18import { ControlPlaneApi } from "../api";13import { ControlPlaneApi } from "../api";
19import { withRequestActor, withWorkspaceRequestActor } from "../http-auth";14import { withWorkspaceRequestActor } from "../http-auth";
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 15
33const requireReadPolicies = (workspaceId: WorkspaceId) =>16const requireReadPolicies = (workspaceId: WorkspaceId) =>
34 requirePermission({17 requirePermission({
@@ -47,54 +30,6 @@ export const ControlPlanePoliciesLive = HttpApiBuilder.group( · 12 unmodified lines
47 "policies",30 "policies",
48 (handlers) =>31 (handlers) =>
49 handlers32 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 }) =>33 .handle("list", ({ path }) =>
99 withWorkspaceRequestActor("policies.list", path.workspaceId, () =>34 withWorkspaceRequestActor("policies.list", path.workspaceId, () =>
100 withPolicy(requireReadPolicies(path.workspaceId))(35 withPolicy(requireReadPolicies(path.workspaceId))(
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/persistence/post-migrations.test.ts+47−34
@@ -27,7 +27,6 @@ import { · 26 unmodified lines
27 type SqlControlPlanePersistence,27 type SqlControlPlanePersistence,
28} from "./index";28} from "./index";
29import { runCodeMigrations } from "./code-migrations";29import { runCodeMigrations } from "./code-migrations";
30import { loadSourceById } from "../runtime/source-store";
31 30
32const openApiBindingConfigJson = (specUrl: string): string =>31const openApiBindingConfigJson = (specUrl: string): string =>
33 JSON.stringify({32 JSON.stringify({
@@ -693,31 +692,40 @@ describe("code-migrations", () => { · 659 unmodified lines
693 }),692 }),
694 );693 );
695 694
696 const openApiSource = await Effect.runPromise(loadSourceById(695 const openApiSource = await Effect.runPromise(
697 upgradedPersistence.rows,696 upgradedPersistence.rows.sources.getByWorkspaceAndId(
698 {
699 workspaceId,697 workspaceId,
700 sourceId: SourceIdSchema.make("src_legacy_openapi"),698 SourceIdSchema.make("src_legacy_openapi"),
701 },699 ),
702 ));700 );
703 expect(openApiSource.binding).toEqual({701 expect(
704 specUrl: "https://api.example.com/openapi.json",702 JSON.parse(Option.getOrThrow(openApiSource).bindingConfigJson),
705 defaultHeaders: {703 ).toEqual({
706 accept: "application/json",704 adapterKey: "openapi",
705 version: 1,
706 payload: {
707 specUrl: "https://api.example.com/openapi.json",
708 defaultHeaders: {
709 accept: "application/json",
710 },
707 },711 },
708 });712 });
709 expect(openApiSource.bindingVersion).toBe(1);
710 713
711 const graphqlSource = await Effect.runPromise(loadSourceById(714 const graphqlSource = await Effect.runPromise(
712 upgradedPersistence.rows,715 upgradedPersistence.rows.sources.getByWorkspaceAndId(
713 {
714 workspaceId,716 workspaceId,
715 sourceId: SourceIdSchema.make("src_legacy_graphql"),717 SourceIdSchema.make("src_legacy_graphql"),
716 },718 ),
717 ));719 );
718 expect(graphqlSource.binding).toEqual({720 expect(
719 defaultHeaders: {721 JSON.parse(Option.getOrThrow(graphqlSource).bindingConfigJson),
720 accept: "application/json",722 ).toEqual({
723 adapterKey: "graphql",
724 version: 1,
725 payload: {
726 defaultHeaders: {
727 accept: "application/json",
728 },
721 },729 },
722 });730 });
723 731
@@ -726,20 +734,25 @@ describe("code-migrations", () => { · 2 unmodified lines
726 );734 );
727 expect(Option.getOrNull(graphqlRecipe)?.adapterKey).toBe("graphql");735 expect(Option.getOrNull(graphqlRecipe)?.adapterKey).toBe("graphql");
728 736
729 const mcpSource = await Effect.runPromise(loadSourceById(737 const mcpSource = await Effect.runPromise(
730 upgradedPersistence.rows,738 upgradedPersistence.rows.sources.getByWorkspaceAndId(
731 {
732 workspaceId,739 workspaceId,
733 sourceId: SourceIdSchema.make("src_legacy_mcp"),740 SourceIdSchema.make("src_legacy_mcp"),
734 },741 ),
735 ));742 );
736 expect(mcpSource.binding).toEqual({743 expect(
737 transport: "streamable-http",744 JSON.parse(Option.getOrThrow(mcpSource).bindingConfigJson),
738 queryParams: {745 ).toEqual({
739 tenant: "acme",746 adapterKey: "mcp",
740 },747 version: 1,
741 headers: {748 payload: {
742 "x-tenant": "acme",749 transport: "streamable-http",
750 queryParams: {
751 tenant: "acme",
752 },
753 headers: {
754 "x-tenant": "acme",
755 },
743 },756 },
744 });757 });
745 758
packages/control-plane/src/runtime/actor-resolver.ts+32−52
@@ -3,16 +3,11 @@ import { ControlPlaneAuthHeaders } from "../auth-headers"; · 2 unmodified lines
3import {3import {
4 ControlPlaneActorResolver,4 ControlPlaneActorResolver,
5 type ControlPlaneActorResolverShape,5 type ControlPlaneActorResolverShape,
6 deriveWorkspaceMembershipsForPrincipal,
7} from "#api";6} from "#api";
8import {7import {
9 ActorUnauthenticatedError,8 ActorUnauthenticatedError,
10 createActor,9 createActor,
11} from "#domain";10} from "#domain";
12import {
13 SqlControlPlaneRowsService,
14 type SqlControlPlaneRows,
15} from "#persistence";
16import {11import {
17 PrincipalProviderSchema,12 PrincipalProviderSchema,
18 PrincipalSchema,13 PrincipalSchema,
@@ -23,6 +18,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
23import * as Option from "effect/Option";18import * as Option from "effect/Option";
24import * as ParseResult from "effect/ParseResult";19import * as ParseResult from "effect/ParseResult";
25import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
21import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context";
26 22
27export { ControlPlaneAuthHeaders };23export { ControlPlaneAuthHeaders };
28 24
@@ -95,57 +91,44 @@ const readPrincipalFromHeaders = ( · 66 unmodified lines
95 );91 );
96 });92 });
97 93
98export const createHeaderActorResolver = (94const createLocalActor = (principal: Principal) =>
99 rows: SqlControlPlaneRows,95 createActor({
100): ControlPlaneActorResolverShape => ({96 principal,
97 workspaceMemberships: [],
98 organizationMemberships: [],
99 });
100
101export const createHeaderActorResolver = (): ControlPlaneActorResolverShape => ({
101 resolveActor: ({ headers }) =>102 resolveActor: ({ headers }) =>
102 Effect.gen(function* () {103 Effect.gen(function* () {
103 const principal = yield* readPrincipalFromHeaders(headers);104 const principal = yield* readPrincipalFromHeaders(headers);
104 const organizationMemberships = yield* rows.organizationMemberships105 return yield* createLocalActor(principal);
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 }),106 }),
118 107
119 resolveWorkspaceActor: ({ workspaceId, headers }) =>108 resolveWorkspaceActor: ({ workspaceId, headers }) =>
120 Effect.gen(function* () {109 Effect.gen(function* () {
121 const principal = yield* readPrincipalFromHeaders(headers);110 const principal = yield* readPrincipalFromHeaders(headers);
122 const organizationMemberships = yield* rows.organizationMemberships111 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
123 .listByAccountId(principal.accountId)112 if (
124 .pipe(113 runtimeLocalWorkspace !== null
125 Effect.mapError((cause) =>114 && runtimeLocalWorkspace.installation.workspaceId === workspaceId
126 toUnauthenticatedError("Failed loading memberships", cause),115 && runtimeLocalWorkspace.installation.accountId === principal.accountId
127 ),116 ) {
128 );117 return yield* createActor({
129 118 principal,
130 const workspaceOption = yield* rows.workspaces.getById(workspaceId).pipe(119 workspaceMemberships: [{
131 Effect.mapError((cause) =>120 accountId: principal.accountId,
132 toUnauthenticatedError("Failed loading workspace context", cause),121 workspaceId,
133 ),122 role: "owner",
134 );123 status: "active",
135 const workspace = Option.getOrNull(workspaceOption);124 grantedAt: 0,
136 125 updatedAt: 0,
137 const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({126 }],
138 principalAccountId: principal.accountId,127 organizationMemberships: [],
139 workspaceId,128 });
140 workspace,129 }
141 organizationMemberships,130
142 });131 return yield* createLocalActor(principal);
143
144 return yield* createActor({
145 principal,
146 workspaceMemberships,
147 organizationMemberships,
148 });
149 }),132 }),
150});133});
151 134
@@ -154,7 +137,4 @@ export const RuntimeActorResolverLive = ( · 2 unmodified lines
154) =>137) =>
155 actorResolver138 actorResolver
156 ? Layer.succeed(ControlPlaneActorResolver, actorResolver)139 ? Layer.succeed(ControlPlaneActorResolver, actorResolver)
157 : Layer.effect(140 : Layer.succeed(ControlPlaneActorResolver, createHeaderActorResolver());
158 ControlPlaneActorResolver,
159 Effect.map(SqlControlPlaneRowsService, (rows) => createHeaderActorResolver(rows)),
160 );
packages/control-plane/src/runtime/control-plane-runtime.test.ts+4−405
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
19import type { ToolPath } from "@executor/codemode-core";19import type { ToolPath } from "@executor/codemode-core";
20 20
21import {21import {
22 createSqlControlPlaneRuntime,22 createControlPlaneRuntime,
23 LiveExecutionManagerService,23 LiveExecutionManagerService,
24} from "./index";24} from "./index";
25import { createSourceFromPayload } from "./source-definitions";25import { createSourceFromPayload } from "./source-definitions";
@@ -28,7 +28,7 @@ import { persistSource } from "./source-store"; · 2 unmodified lines
28import { withControlPlaneClient } from "./test-http-client";28import { withControlPlaneClient } from "./test-http-client";
29 29
30const makeRuntime = Effect.acquireRelease(30const makeRuntime = Effect.acquireRelease(
31 createSqlControlPlaneRuntime({31 createControlPlaneRuntime({
32 localDataDir: ":memory:",32 localDataDir: ":memory:",
33 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")),33 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-control-plane-runtime-")),
34 }),34 }),
@@ -123,7 +123,7 @@ describe("control-plane-runtime", () => { · 88 unmodified lines
123 Effect.gen(function* () {123 Effect.gen(function* () {
124 const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-local-config-runtime-"));124 const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-local-config-runtime-"));
125 const runtime = yield* Effect.acquireRelease(125 const runtime = yield* Effect.acquireRelease(
126 createSqlControlPlaneRuntime({126 createControlPlaneRuntime({
127 localDataDir: ":memory:",127 localDataDir: ":memory:",
128 workspaceRoot,128 workspaceRoot,
129 }),129 }),
@@ -175,150 +175,9 @@ describe("control-plane-runtime", () => { · 45 unmodified lines
175 };175 };
176 expect(removedConfig.sources?.github).toBeUndefined();176 expect(removedConfig.sources?.github).toBeUndefined();
177 }),177 }),
178 60_000,
178 );179 );
179 180
180 it.scoped("supports full CRUD flow over HTTP API", () =>
181 Effect.gen(function* () {
182 const runtime = yield* makeRuntime;
183 const openApiServer = yield* makeOpenApiSpecServer;
184
185 const createOrg = yield* withControlPlaneClient(
186 { runtime, accountId: "acc_1" },
187 (client) =>
188 client.organizations.create({
189 payload: {
190 name: "Acme",
191 },
192 }),
193 );
194 const organizationId = createOrg.id;
195
196 const createWorkspace = yield* withControlPlaneClient(
197 { runtime, accountId: "acc_1" },
198 (client) =>
199 client.workspaces.create({
200 path: { organizationId },
201 payload: { name: "Primary" },
202 }),
203 );
204 expect(createWorkspace.createdByAccountId).toBe("acc_1");
205 const workspaceId = createWorkspace.id;
206
207 yield* withControlPlaneClient(
208 { runtime, accountId: "acc_1" },
209 (client) =>
210 client.sources.create({
211 path: { workspaceId },
212 payload: {
213 name: "Github",
214 kind: "openapi",
215 endpoint: openApiServer.baseUrl,
216 binding: {
217 specUrl: openApiServer.specUrl,
218 defaultHeaders: null,
219 },
220 auth: {
221 kind: "none",
222 },
223 },
224 }),
225 );
226
227 yield* withControlPlaneClient(
228 { runtime, accountId: "acc_1" },
229 (client) =>
230 client.policies.create({
231 path: { workspaceId },
232 payload: {
233 resourceType: "tool_path",
234 resourcePattern: "source.github.*",
235 matchType: "glob",
236 effect: "allow",
237 approvalMode: "auto",
238 priority: 50,
239 enabled: true,
240 },
241 }),
242 );
243
244 const listSources = yield* withControlPlaneClient(
245 { runtime, accountId: "acc_1" },
246 (client) =>
247 client.sources.list({
248 path: { workspaceId },
249 }),
250 );
251 expect(listSources.length).toBe(1);
252
253 const listPolicies = yield* withControlPlaneClient(
254 { runtime, accountId: "acc_1" },
255 (client) =>
256 client.policies.list({
257 path: { workspaceId },
258 }),
259 );
260 expect(listPolicies.length).toBe(1);
261 }),
262 );
263
264 it.scoped("discovers and connects an OpenAPI source through the HTTP API", () =>
265 Effect.gen(function* () {
266 const runtime = yield* makeRuntime;
267 const openApiServer = yield* makeOpenApiSpecServer;
268
269 const organization = yield* withControlPlaneClient(
270 { runtime, accountId: "acc_discover" },
271 (client) =>
272 client.organizations.create({
273 payload: { name: "Discovery Org" },
274 }),
275 );
276 const workspace = yield* withControlPlaneClient(
277 { runtime, accountId: "acc_discover" },
278 (client) =>
279 client.workspaces.create({
280 path: { organizationId: organization.id },
281 payload: { name: "Discovery Workspace" },
282 }),
283 );
284
285 const discovered = yield* withControlPlaneClient(
286 { runtime, accountId: "acc_discover" },
287 (client) =>
288 client.sources.discover({
289 payload: {
290 url: openApiServer.specUrl,
291 },
292 }),
293 );
294 expect(discovered.detectedKind).toBe("openapi");
295 expect(discovered.specUrl).toBe(openApiServer.specUrl);
296 expect(discovered.endpoint).toBe(openApiServer.baseUrl);
297
298 const connected = yield* withControlPlaneClient(
299 { runtime, accountId: "acc_discover" },
300 (client) =>
301 client.sources.connect({
302 path: { workspaceId: workspace.id },
303 payload: {
304 kind: "openapi",
305 endpoint: discovered.endpoint,
306 specUrl: discovered.specUrl ?? openApiServer.specUrl,
307 name: discovered.name,
308 namespace: discovered.namespace,
309 auth: { kind: "none" },
310 },
311 }),
312 );
313 expect(connected.kind).toBe("connected");
314 expect(connected.source.kind).toBe("openapi");
315 expect(connected.source.status).toBe("connected");
316 expect(connected.source.binding).toEqual({
317 specUrl: openApiServer.specUrl,
318 defaultHeaders: null,
319 });
320 }),
321 );
322 181
323 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", () =>
324 Effect.gen(function* () {183 Effect.gen(function* () {
@@ -608,264 +467,4 @@ describe("control-plane-runtime", () => { · 283 unmodified lines
608 }),467 }),
609 );468 );
610 469
611 it.scoped("scopes organization list/get to memberships", () =>
612 Effect.gen(function* () {
613 const runtime = yield* makeRuntime;
614
615 const orgOne = yield* withControlPlaneClient(
616 { runtime, accountId: "acc_1" },
617 (client) =>
618 client.organizations.create({
619 payload: { name: "Org One" },
620 }),
621 );
622
623 const orgTwo = yield* withControlPlaneClient(
624 { runtime, accountId: "acc_2" },
625 (client) =>
626 client.organizations.create({
627 payload: { name: "Org Two" },
628 }),
629 );
630
631 const listForAcc1 = yield* withControlPlaneClient(
632 { runtime, accountId: "acc_1" },
633 (client) => client.organizations.list({}),
634 );
635
636 expect(listForAcc1.length).toBe(1);
637
638 const getOtherOrgError = yield* expectLeft(
639 withControlPlaneClient(
640 { runtime, accountId: "acc_1" },
641 (client) =>
642 client.organizations.get({
643 path: { organizationId: orgTwo.id },
644 }),
645 ),
646 );
647
648 assertTrue(orgOne.id.length > 0);
649 expect(getOtherOrgError._tag).toBe("ControlPlaneNotFoundError");
650 }),
651 );
652
653 it.scoped("prevents viewers from workspace manage actions", () =>
654 Effect.gen(function* () {
655 const runtime = yield* makeRuntime;
656
657 const organization = yield* withControlPlaneClient(
658 { runtime, accountId: "owner_acc" },
659 (client) =>
660 client.organizations.create({
661 payload: { name: "Secured Org" },
662 }),
663 );
664
665 const workspace = yield* withControlPlaneClient(
666 { runtime, accountId: "owner_acc" },
667 (client) =>
668 client.workspaces.create({
669 path: { organizationId: organization.id },
670 payload: { name: "Secured WS" },
671 }),
672 );
673
674 yield* withControlPlaneClient(
675 { runtime, accountId: "owner_acc" },
676 (client) =>
677 client.memberships.create({
678 path: { organizationId: organization.id },
679 payload: {
680 accountId: "viewer_acc" as AccountId,
681 role: "viewer",
682 status: "active",
683 },
684 }),
685 );
686
687 const viewerDeleteError = yield* expectLeft(
688 withControlPlaneClient(
689 { runtime, accountId: "viewer_acc" },
690 (client) =>
691 client.workspaces.remove({
692 path: { workspaceId: workspace.id },
693 }),
694 ),
695 );
696
697 expect(viewerDeleteError._tag).toBe("ControlPlaneForbiddenError");
698 }),
699 );
700
701 it.scoped("blocks organization manage across tenants", () =>
702 Effect.gen(function* () {
703 const runtime = yield* makeRuntime;
704
705 const orgA = yield* withControlPlaneClient(
706 { runtime, accountId: "acc_a" },
707 (client) =>
708 client.organizations.create({
709 payload: { name: "Org A" },
710 }),
711 );
712
713 const orgB = yield* withControlPlaneClient(
714 { runtime, accountId: "acc_b" },
715 (client) =>
716 client.organizations.create({
717 payload: { name: "Org B" },
718 }),
719 );
720
721 const patchOtherOrgError = yield* expectLeft(
722 withControlPlaneClient(
723 { runtime, accountId: "acc_a" },
724 (client) =>
725 client.organizations.update({
726 path: { organizationId: orgB.id },
727 payload: { name: "Renamed" },
728 }),
729 ),
730 );
731 assertTrue(orgA.id.length > 0);
732 expect(patchOtherOrgError._tag).toBe("ControlPlaneForbiddenError");
733
734 const deleteOtherOrgError = yield* expectLeft(
735 withControlPlaneClient(
736 { runtime, accountId: "acc_a" },
737 (client) =>
738 client.organizations.remove({
739 path: { organizationId: orgB.id },
740 }),
741 ),
742 );
743 expect(deleteOtherOrgError._tag).toBe("ControlPlaneForbiddenError");
744 }),
745 );
746
747 it.scoped("suspended creators cannot manage previously created workspaces", () =>
748 Effect.gen(function* () {
749 const runtime = yield* makeRuntime;
750
751 const organization = yield* withControlPlaneClient(
752 { runtime, accountId: "creator_acc" },
753 (client) =>
754 client.organizations.create({
755 payload: { name: "Creator Org" },
756 }),
757 );
758
759 const workspace = yield* withControlPlaneClient(
760 { runtime, accountId: "creator_acc" },
761 (client) =>
762 client.workspaces.create({
763 path: { organizationId: organization.id },
764 payload: { name: "Creator WS" },
765 }),
766 );
767
768 yield* withControlPlaneClient(
769 { runtime, accountId: "creator_acc" },
770 (client) =>
771 client.memberships.update({
772 path: {
773 organizationId: organization.id,
774 accountId: "creator_acc" as AccountId,
775 },
776 payload: { status: "suspended" },
777 }),
778 );
779
780 const deleteWorkspaceError = yield* expectLeft(
781 withControlPlaneClient(
782 { runtime, accountId: "creator_acc" },
783 (client) =>
784 client.workspaces.remove({
785 path: { workspaceId: workspace.id },
786 }),
787 ),
788 );
789
790 expect(deleteWorkspaceError._tag).toBe("ControlPlaneForbiddenError");
791 }),
792 );
793
794 it.scoped("deleting organization cascades and blocks stale org operations", () =>
795 Effect.gen(function* () {
796 const runtime = yield* makeRuntime;
797
798 const organization = yield* withControlPlaneClient(
799 { runtime, accountId: "acc_del" },
800 (client) =>
801 client.organizations.create({
802 payload: { name: "Delete Me" },
803 }),
804 );
805
806 const workspace = yield* withControlPlaneClient(
807 { runtime, accountId: "acc_del" },
808 (client) =>
809 client.workspaces.create({
810 path: { organizationId: organization.id },
811 payload: { name: "Delete WS" },
812 }),
813 );
814
815 const deleteOrg = yield* withControlPlaneClient(
816 { runtime, accountId: "acc_del" },
817 (client) =>
818 client.organizations.remove({
819 path: { organizationId: organization.id },
820 }),
821 );
822 assertTrue(deleteOrg.removed);
823
824 const listStaleWorkspacesError = yield* expectLeft(
825 withControlPlaneClient(
826 { runtime, accountId: "acc_del" },
827 (client) =>
828 client.workspaces.list({
829 path: { organizationId: organization.id },
830 }),
831 ),
832 );
833 expect(listStaleWorkspacesError._tag).toBe("ControlPlaneForbiddenError");
834
835 const createStaleWorkspaceError = yield* expectLeft(
836 withControlPlaneClient(
837 { runtime, accountId: "acc_del" },
838 (client) =>
839 client.workspaces.create({
840 path: { organizationId: organization.id },
841 payload: { name: "Should Fail" },
842 }),
843 ),
844 );
845 expect(createStaleWorkspaceError._tag).toBe("ControlPlaneForbiddenError");
846
847 const getDeletedWorkspaceError = yield* expectLeft(
848 withControlPlaneClient(
849 { runtime, accountId: "acc_del" },
850 (client) =>
851 client.workspaces.get({
852 path: { workspaceId: workspace.id },
853 }),
854 ),
855 );
856 expect(getDeletedWorkspaceError._tag).toBe("ControlPlaneForbiddenError");
857 }),
858 );
859
860 it.scoped("rejects unauthenticated calls", () =>
861 Effect.gen(function* () {
862 const runtime = yield* makeRuntime;
863
864 const error = yield* expectLeft(
865 withControlPlaneClient({ runtime }, (client) => client.organizations.list({})),
866 );
867
868 expect(error._tag).toBe("ControlPlaneUnauthorizedError");
869 }),
870 );
871});470});
packages/control-plane/src/runtime/execution-http.test.ts+2−2
@@ -9,7 +9,7 @@ import { makeToolInvokerFromTools } from "@executor/codemode-core"; · 8 unmodified lines
9import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";9import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
10 10
11import {11import {
12 createSqlControlPlaneRuntime,12 createControlPlaneRuntime,
13} from "./index";13} from "./index";
14import { withControlPlaneClient } from "./test-http-client";14import { withControlPlaneClient } from "./test-http-client";
15 15
@@ -40,7 +40,7 @@ const makeExecutionResolver = () => { · 24 unmodified lines
40};40};
41 41
42const makeRuntime = Effect.acquireRelease(42const makeRuntime = Effect.acquireRelease(
43 createSqlControlPlaneRuntime({43 createControlPlaneRuntime({
44 localDataDir: ":memory:",44 localDataDir: ":memory:",
45 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")),45 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-http-")),
46 executionResolver: makeExecutionResolver(),46 executionResolver: makeExecutionResolver(),
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+2−2
@@ -18,7 +18,7 @@ import { discoverMcpToolsFromConnector } from "@executor/codemode-mcp"; · 17 unmodified lines
18import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";18import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
19 19
20import {20import {
21 createSqlControlPlaneRuntime,21 createControlPlaneRuntime,
22 type ResolveExecutionEnvironment,22 type ResolveExecutionEnvironment,
23} from "./index";23} from "./index";
24import { withControlPlaneClient } from "./test-http-client";24import { withControlPlaneClient } from "./test-http-client";
@@ -284,7 +284,7 @@ describe("execution-mcp-resume", () => { · 259 unmodified lines
284 Effect.gen(function* () {284 Effect.gen(function* () {
285 const mcpServer = yield* makeFormElicitationServer;285 const mcpServer = yield* makeFormElicitationServer;
286 const runtime = yield* Effect.acquireRelease(286 const runtime = yield* Effect.acquireRelease(
287 createSqlControlPlaneRuntime({287 createControlPlaneRuntime({
288 localDataDir: ":memory:",288 localDataDir: ":memory:",
289 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")),289 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-execution-mcp-resume-")),
290 executionResolver: makeMcpExecutionResolver(mcpServer.endpoint),290 executionResolver: makeMcpExecutionResolver(mcpServer.endpoint),
packages/control-plane/src/runtime/index.ts+14−16
@@ -88,12 +88,12 @@ export type RuntimeControlPlaneOptions = { · 87 unmodified lines
88const detailsFromCause = (cause: unknown): string =>88const detailsFromCause = (cause: unknown): string =>
89 cause instanceof Error ? cause.message : String(cause);89 cause instanceof Error ? cause.message : String(cause);
90 90
91const toLocalInstallationBootstrapError = (91const toLocalRuntimeBootstrapError = (
92 cause: unknown,92 cause: unknown,
93): SqlPersistenceBootstrapError => {93): SqlPersistenceBootstrapError => {
94 const details = detailsFromCause(cause);94 const details = detailsFromCause(cause);
95 return new SqlPersistenceBootstrapError({95 return new SqlPersistenceBootstrapError({
96 message: `Failed provisioning local installation: ${details}`,96 message: `Failed initializing local runtime: ${details}`,
97 details,97 details,
98 });98 });
99};99};
@@ -165,19 +165,19 @@ export const createRuntimeControlPlaneLayer = ( · 65 unmodified lines
165 );165 );
166};166};
167 167
168export type SqlControlPlaneRuntime = {168export type ControlPlaneRuntime = {
169 persistence: SqlControlPlanePersistence;169 persistence: SqlControlPlanePersistence;
170 localInstallation: LocalInstallation;170 localInstallation: LocalInstallation;
171 runtimeLayer: RuntimeControlPlaneLayer;171 runtimeLayer: RuntimeControlPlaneLayer;
172 close: () => Promise<void>;172 close: () => Promise<void>;
173};173};
174 174
175export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions175export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions
176 & RuntimeControlPlaneOptions;176 & RuntimeControlPlaneOptions;
177 177
178export const createSqlControlPlaneRuntime = (178export const createControlPlaneRuntime = (
179 options: CreateSqlControlPlaneRuntimeOptions,179 options: CreateControlPlaneRuntimeOptions,
180): Effect.Effect<SqlControlPlaneRuntime, SqlPersistenceBootstrapError> =>180): Effect.Effect<ControlPlaneRuntime, SqlPersistenceBootstrapError> =>
181 Effect.gen(function* () {181 Effect.gen(function* () {
182 const scope = yield* Scope.make();182 const scope = yield* Scope.make();
183 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(183 const persistenceAndRowsLayer = SqlControlPlaneRowsLive.pipe(
@@ -198,7 +198,7 @@ export const createSqlControlPlaneRuntime = ( · 14 unmodified lines
198 resolveLocalWorkspaceContext({198 resolveLocalWorkspaceContext({
199 workspaceRoot: options.workspaceRoot,199 workspaceRoot: options.workspaceRoot,
200 }),200 }),
201 catch: toLocalInstallationBootstrapError,201 catch: toLocalRuntimeBootstrapError,
202 }).pipe(202 }).pipe(
203 Effect.catchAll((error) =>203 Effect.catchAll((error) =>
204 closeScope(scope).pipe(204 closeScope(scope).pipe(
@@ -210,7 +210,7 @@ export const createSqlControlPlaneRuntime = ( · 5 unmodified lines
210 rows,210 rows,
211 context: localWorkspaceContext,211 context: localWorkspaceContext,
212 }).pipe(212 }).pipe(
213 Effect.mapError(toLocalInstallationBootstrapError),213 Effect.mapError(toLocalRuntimeBootstrapError),
214 Effect.catchAll((error) =>214 Effect.catchAll((error) =>
215 closeScope(scope).pipe(215 closeScope(scope).pipe(
216 Effect.zipRight(Effect.fail(error)),216 Effect.zipRight(Effect.fail(error)),
@@ -219,7 +219,7 @@ export const createSqlControlPlaneRuntime = ( · 2 unmodified lines
219 219
220 const loadedLocalConfig = yield* Effect.tryPromise({220 const loadedLocalConfig = yield* Effect.tryPromise({
221 try: () => loadLocalExecutorConfig(localWorkspaceContext),221 try: () => loadLocalExecutorConfig(localWorkspaceContext),
222 catch: toLocalInstallationBootstrapError,222 catch: toLocalRuntimeBootstrapError,
223 }).pipe(223 }).pipe(
224 Effect.catchAll((error) =>224 Effect.catchAll((error) =>
225 closeScope(scope).pipe(225 closeScope(scope).pipe(
@@ -228,14 +228,10 @@ export const createSqlControlPlaneRuntime = ( · 2 unmodified lines
228 );228 );
229 229
230 const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({230 const effectiveLocalConfig = yield* synchronizeLocalWorkspaceState({
231 rows,
232 context: localWorkspaceContext,231 context: localWorkspaceContext,
233 loadedConfig: loadedLocalConfig,232 loadedConfig: loadedLocalConfig,
234 installation: {
235 workspaceId: localInstallation.workspaceId,
236 },
237 }).pipe(233 }).pipe(
238 Effect.mapError(toLocalInstallationBootstrapError),234 Effect.mapError(toLocalRuntimeBootstrapError),
239 Effect.catchAll((error) =>235 Effect.catchAll((error) =>
240 closeScope(scope).pipe(236 closeScope(scope).pipe(
241 Effect.zipRight(Effect.fail(error)),237 Effect.zipRight(Effect.fail(error)),
@@ -262,6 +258,7 @@ export const createSqlControlPlaneRuntime = ( · 20 unmodified lines
262 installation: {258 installation: {
263 workspaceId: localInstallation.workspaceId,259 workspaceId: localInstallation.workspaceId,
264 accountId: localInstallation.accountId,260 accountId: localInstallation.accountId,
261 organizationId: localInstallation.organizationId,
265 },262 },
266 loadedConfig: {263 loadedConfig: {
267 ...loadedLocalConfig,264 ...loadedLocalConfig,
@@ -277,7 +274,7 @@ export const createSqlControlPlaneRuntime = ( · 9 unmodified lines
277 resolveSecretMaterial,274 resolveSecretMaterial,
278 });275 });
279 const actorResolver =276 const actorResolver =
280 options.actorResolver ?? createHeaderActorResolver(rows);277 options.actorResolver ?? createHeaderActorResolver();
281 278
282 const runtimeContext = Context.empty().pipe(279 const runtimeContext = Context.empty().pipe(
283 Context.add(ControlPlaneActorResolver, actorResolver),280 Context.add(ControlPlaneActorResolver, actorResolver),
@@ -288,6 +285,7 @@ export const createSqlControlPlaneRuntime = ( · 4 unmodified lines
288 installation: {285 installation: {
289 workspaceId: localInstallation.workspaceId,286 workspaceId: localInstallation.workspaceId,
290 accountId: localInstallation.accountId,287 accountId: localInstallation.accountId,
288 organizationId: localInstallation.organizationId,
291 },289 },
292 loadedConfig: {290 loadedConfig: {
293 ...loadedLocalConfig,291 ...loadedLocalConfig,
packages/control-plane/src/runtime/local-installation.test.ts+8−15
@@ -2,17 +2,16 @@ import { existsSync, mkdtempSync } from "node:fs"; · 1 unmodified line
2import { tmpdir } from "node:os";2import { tmpdir } from "node:os";
3import { join } from "node:path";3import { join } from "node:path";
4import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
5import { assertTrue } from "@effect/vitest/utils";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
7 6
8import { createSqlControlPlaneRuntime } from "./index";7import { createControlPlaneRuntime } from "./index";
9import { getOrProvisionLocalInstallation } from "./local-installation";8import { getOrProvisionLocalInstallation } from "./local-installation";
10import { resolveLocalWorkspaceContext } from "./local-config";9import { resolveLocalWorkspaceContext } from "./local-config";
11 10
12const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));11const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-"));
13 12
14const makeRuntime = Effect.acquireRelease(13const makeRuntime = Effect.acquireRelease(
15 createSqlControlPlaneRuntime({14 createControlPlaneRuntime({
16 localDataDir: ":memory:",15 localDataDir: ":memory:",
17 workspaceRoot: TEST_WORKSPACE_ROOT,16 workspaceRoot: TEST_WORKSPACE_ROOT,
18 }),17 }),
@@ -20,24 +19,18 @@ const makeRuntime = Effect.acquireRelease( · 1 unmodified line
20);19);
21 20
22describe("local-installation", () => {21describe("local-installation", () => {
23 it.scoped("provisions a local account, organization, and workspace on first boot", () =>22 it.scoped("derives a stable local identity on first boot", () =>
24 Effect.gen(function* () {23 Effect.gen(function* () {
25 const runtime = yield* makeRuntime;24 const runtime = yield* makeRuntime;
26 const installation = runtime.localInstallation;25 const installation = runtime.localInstallation;
27 26
28 const account = yield* runtime.persistence.rows.accounts.getById(installation.accountId);27 expect(installation.id.startsWith("local_")).toBe(true);
29 const organization = yield* runtime.persistence.rows.organizations.getById(28 expect(installation.accountId).toBe("acc_local_default");
30 installation.organizationId,29 expect(installation.organizationId).toBe("org_local_personal");
31 );30 expect(installation.workspaceId.startsWith("ws_local_")).toBe(true);
32 const workspace = yield* runtime.persistence.rows.workspaces.getById(
33 installation.workspaceId,
34 );
35
36 assertTrue(account._tag === "Some");
37 assertTrue(organization._tag === "Some");
38 assertTrue(workspace._tag === "Some");
39 expect(existsSync(join(TEST_WORKSPACE_ROOT, ".executor", "executor.jsonc"))).toBe(false);31 expect(existsSync(join(TEST_WORKSPACE_ROOT, ".executor", "executor.jsonc"))).toBe(false);
40 }),32 }),
33 60_000,
41 );34 );
42 35
43 it.scoped("is idempotent when loading the default local installation", () =>36 it.scoped("is idempotent when loading the default local installation", () =>
packages/control-plane/src/runtime/local-installation.ts+31−257
@@ -1,283 +1,57 @@
1import { type SqlControlPlaneRows } from "#persistence";1import { createHash } from "node:crypto";
2
3import type { SqlControlPlaneRows } from "#persistence";
2import {4import {
3 AccountIdSchema,5 AccountIdSchema,
4 InstallationIdSchema,6 InstallationIdSchema,
5 OrganizationIdSchema,7 OrganizationIdSchema,
6 OrganizationMemberIdSchema,
7 WorkspaceIdSchema,8 WorkspaceIdSchema,
8 type Account,
9 type LocalInstallation,9 type LocalInstallation,
10 type Organization,
11 type OrganizationMembership,
12 type Workspace,
13} from "#schema";10} from "#schema";
14import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16
17import {
18 defaultWorkspaceDisplayName,
19 type ResolvedLocalWorkspaceContext,
20} from "./local-config";
21 12
22const LEGACY_LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default");13import type { ResolvedLocalWorkspaceContext } from "./local-config";
23const LOCAL_ACCOUNT_SUBJECT = "local:default";
24const PERSONAL_ORGANIZATION_SLUG = "personal";
25 14
26const trimOrNull = (value: string | null | undefined): string | null => {15const LOCAL_ACCOUNT_ID = AccountIdSchema.make("acc_local_default");
27 if (value == null) {16const LOCAL_ORGANIZATION_ID = OrganizationIdSchema.make("org_local_personal");
28 return null;
29 }
30 const trimmed = value.trim();
31 return trimmed.length > 0 ? trimmed : null;
32};
33 17
34const buildAccount = (now: number): Account => {18const stableHash = (value: string): string =>
35 const id = AccountIdSchema.make(`acc_${crypto.randomUUID()}`);19 createHash("sha256").update(value).digest("hex").slice(0, 16);
36 20
37 return {21const normalizeSlashPath = (value: string): string =>
38 id,22 value.replaceAll("\\", "/");
39 provider: "local",
40 subject: LOCAL_ACCOUNT_SUBJECT,
41 email: null,
42 displayName: "Local User",
43 createdAt: now,
44 updatedAt: now,
45 };
46};
47
48const buildOrganization = (
49 accountId: Account["id"],
50 now: number,
51): Organization => ({
52 id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`),
53 slug: PERSONAL_ORGANIZATION_SLUG,
54 name: "Personal",
55 status: "active",
56 createdByAccountId: accountId,
57 createdAt: now,
58 updatedAt: now,
59});
60
61const buildOwnerMembership = (
62 organizationId: Organization["id"],
63 accountId: Account["id"],
64 now: number,
65): OrganizationMembership => ({
66 id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`),
67 organizationId,
68 accountId,
69 role: "owner",
70 status: "active",
71 billable: true,
72 invitedByAccountId: null,
73 joinedAt: now,
74 createdAt: now,
75 updatedAt: now,
76});
77
78const buildWorkspace = (
79 organizationId: Organization["id"],
80 accountId: Account["id"],
81 workspaceName: string,
82 now: number,
83): Workspace => ({
84 id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`),
85 organizationId,
86 name: workspaceName,
87 createdByAccountId: accountId,
88 createdAt: now,
89 updatedAt: now,
90});
91 23
92const chooseWorkspaceName = (24const deriveWorkspaceId = (context: ResolvedLocalWorkspaceContext) =>
93 desiredName: string,25 WorkspaceIdSchema.make(
94 existing: ReadonlyArray<Workspace>,26 `ws_local_${stableHash(normalizeSlashPath(context.workspaceRoot))}`,
95): string => {27 );
96 const trimmed = trimOrNull(desiredName) ?? "Workspace";
97 const matching = existing.find((workspace) => workspace.name === trimmed);
98 if (!matching) {
99 return trimmed;
100 }
101 return `${trimmed}-${matching.id.slice(-6)}`;
102};
103
104const ensureLocalAccount = (
105 rows: SqlControlPlaneRows,
106 now: number,
107): Effect.Effect<Account, unknown> =>
108 Effect.gen(function* () {
109 const existing = yield* rows.accounts.getByProviderAndSubject("local", LOCAL_ACCOUNT_SUBJECT);
110 if (Option.isSome(existing)) {
111 return existing.value;
112 }
113
114 const account = buildAccount(now);
115 yield* rows.accounts.upsert(account);
116 return account;
117 });
118
119const ensurePersonalOrganization = (
120 rows: SqlControlPlaneRows,
121 accountId: Account["id"],
122 now: number,
123): Effect.Effect<Organization, unknown> =>
124 Effect.gen(function* () {
125 const existing = yield* rows.organizations.getBySlug(PERSONAL_ORGANIZATION_SLUG);
126 if (Option.isSome(existing)) {
127 return existing.value;
128 }
129
130 const organization = buildOrganization(accountId, now);
131 const membership = buildOwnerMembership(organization.id, accountId, now);
132 yield* rows.organizations.insertWithOwnerMembership(organization, membership);
133 return organization;
134 });
135
136const ensureOwnerMembership = (
137 rows: SqlControlPlaneRows,
138 organizationId: Organization["id"],
139 accountId: Account["id"],
140 now: number,
141): Effect.Effect<void, unknown> =>
142 Effect.gen(function* () {
143 const existing = yield* rows.organizationMemberships.getByOrganizationAndAccount(
144 organizationId,
145 accountId,
146 );
147 if (Option.isSome(existing)) {
148 return;
149 }
150
151 yield* rows.organizationMemberships.upsert(
152 buildOwnerMembership(organizationId, accountId, now),
153 );
154 });
155
156const provisionWorkspaceForContext = (input: {
157 rows: SqlControlPlaneRows;
158 accountId: Account["id"];
159 organizationId: Organization["id"];
160 context: ResolvedLocalWorkspaceContext;
161 now: number;
162}): Effect.Effect<Workspace, unknown> =>
163 Effect.gen(function* () {
164 const workspaces = yield* input.rows.workspaces.listByOrganizationId(input.organizationId);
165 const workspaceName = chooseWorkspaceName(
166 defaultWorkspaceDisplayName(input.context),
167 workspaces,
168 );
169 const workspace = buildWorkspace(
170 input.organizationId,
171 input.accountId,
172 workspaceName,
173 input.now,
174 );
175 yield* input.rows.workspaces.insert(workspace);
176 return workspace;
177 });
178 28
179const createInstallationForWorkspace = (input: {29export const deriveLocalInstallation = (
180 installationId: LocalInstallation["id"];30 context: ResolvedLocalWorkspaceContext,
181 accountId: Account["id"];31): LocalInstallation => ({
182 organizationId: Organization["id"];32 id: InstallationIdSchema.make(context.installationId),
183 workspaceId: Workspace["id"];33 accountId: LOCAL_ACCOUNT_ID,
184 now: number;34 organizationId: LOCAL_ORGANIZATION_ID,
185}): LocalInstallation => ({35 workspaceId: deriveWorkspaceId(context),
186 id: input.installationId,36 // Derived local identity is deterministic; timestamps are not authoritative state.
187 accountId: input.accountId,37 createdAt: 0,
188 organizationId: input.organizationId,38 updatedAt: 0,
189 workspaceId: input.workspaceId,
190 createdAt: input.now,
191 updatedAt: input.now,
192});39});
193 40
194export const loadLocalInstallation = (41export const loadLocalInstallation = (
195 rows: SqlControlPlaneRows,42 _rows: SqlControlPlaneRows,
196 context: ResolvedLocalWorkspaceContext,43 context: ResolvedLocalWorkspaceContext,
197): Effect.Effect<LocalInstallation | null, unknown> =>44): Effect.Effect<LocalInstallation, never> =>
198 rows.localInstallations.getById(InstallationIdSchema.make(context.installationId)).pipe(45 Effect.succeed(deriveLocalInstallation(context));
199 Effect.map((result) => (Option.isSome(result) ? result.value : null)),
200 );
201
202const migrateLegacyLocalInstallation = (input: {
203 rows: SqlControlPlaneRows;
204 context: ResolvedLocalWorkspaceContext;
205 now: number;
206}): Effect.Effect<LocalInstallation | null, unknown> =>
207 Effect.gen(function* () {
208 const legacy = yield* input.rows.localInstallations.getById(LEGACY_LOCAL_INSTALLATION_ID);
209 if (Option.isNone(legacy)) {
210 return null;
211 }
212
213 const migrated = createInstallationForWorkspace({
214 installationId: InstallationIdSchema.make(input.context.installationId),
215 accountId: legacy.value.accountId,
216 organizationId: legacy.value.organizationId,
217 workspaceId: legacy.value.workspaceId,
218 now: input.now,
219 });
220 yield* input.rows.localInstallations.upsert(migrated);
221
222 yield* input.rows.workspaces.update(legacy.value.workspaceId, {
223 name: defaultWorkspaceDisplayName(input.context),
224 updatedAt: input.now,
225 });
226
227 return migrated;
228 });
229 46
230export const provisionLocalInstallation = (input: {47export const provisionLocalInstallation = (input: {
231 rows: SqlControlPlaneRows;48 rows: SqlControlPlaneRows;
232 context: ResolvedLocalWorkspaceContext;49 context: ResolvedLocalWorkspaceContext;
233}): Effect.Effect<LocalInstallation, unknown> =>50}): Effect.Effect<LocalInstallation, never> =>
234 Effect.gen(function* () {51 loadLocalInstallation(input.rows, input.context);
235 const now = Date.now();
236 const installationId = InstallationIdSchema.make(input.context.installationId);
237
238 const existing = yield* input.rows.localInstallations.getById(installationId);
239 if (Option.isSome(existing)) {
240 return existing.value;
241 }
242
243 const migrated = yield* migrateLegacyLocalInstallation({
244 rows: input.rows,
245 context: input.context,
246 now,
247 });
248 if (migrated !== null) {
249 return migrated;
250 }
251
252 const account = yield* ensureLocalAccount(input.rows, now);
253 const organization = yield* ensurePersonalOrganization(input.rows, account.id, now);
254 yield* ensureOwnerMembership(input.rows, organization.id, account.id, now);
255 const workspace = yield* provisionWorkspaceForContext({
256 rows: input.rows,
257 accountId: account.id,
258 organizationId: organization.id,
259 context: input.context,
260 now,
261 });
262 const installation = createInstallationForWorkspace({
263 installationId,
264 accountId: account.id,
265 organizationId: organization.id,
266 workspaceId: workspace.id,
267 now,
268 });
269
270 yield* input.rows.localInstallations.upsert(installation);
271
272 return installation;
273 });
274 52
275export const getOrProvisionLocalInstallation = (input: {53export const getOrProvisionLocalInstallation = (input: {
276 rows: SqlControlPlaneRows;54 rows: SqlControlPlaneRows;
277 context: ResolvedLocalWorkspaceContext;55 context: ResolvedLocalWorkspaceContext;
278}): Effect.Effect<LocalInstallation, unknown> =>56}): Effect.Effect<LocalInstallation, never> =>
279 Effect.flatMap(loadLocalInstallation(input.rows, input.context), (existing) =>57 loadLocalInstallation(input.rows, input.context);
280 existing
281 ? Effect.succeed(existing)
282 : provisionLocalInstallation(input),
283 );
packages/control-plane/src/runtime/local-operations.ts+1−21
@@ -187,27 +187,7 @@ export const getLocalInstallation = () => · 186 unmodified lines
187 const context = runtimeLocalWorkspace?.context187 const context = runtimeLocalWorkspace?.context
188 ?? (yield* Effect.promise(() => resolveLocalWorkspaceContext()));188 ?? (yield* Effect.promise(() => resolveLocalWorkspaceContext()));
189 189
190 const installation = yield* loadLocalInstallation(store, context).pipe(190 return yield* loadLocalInstallation(store, context);
191 Effect.mapError((error) =>
192 error instanceof ControlPlanePersistenceError
193 ? localOps.installation.storage(error)
194 : localOps.installation.unknownStorage(
195 error,
196 "Failed loading local installation",
197 ),
198 ),
199 );
200
201 if (installation === null) {
202 return yield* Effect.fail(
203 localOps.installation.notFound(
204 "Local installation not found",
205 "No local installation has been provisioned",
206 ),
207 );
208 }
209
210 return installation;
211 });191 });
212 192
213export const getSourceCredentialInteraction = (input: {193export const getSourceCredentialInteraction = (input: {
packages/control-plane/src/runtime/local-runtime-context.ts+22−1
@@ -2,7 +2,7 @@ import * as Context from "effect/Context"; · 1 unmodified line
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4 4
5import type { AccountId, WorkspaceId } from "#schema";5import type { AccountId, OrganizationId, WorkspaceId } from "#schema";
6import type {6import type {
7 LoadedLocalExecutorConfig,7 LoadedLocalExecutorConfig,
8 ResolvedLocalWorkspaceContext,8 ResolvedLocalWorkspaceContext,
@@ -13,6 +13,7 @@ export type RuntimeLocalWorkspaceState = { · 4 unmodified lines
13 installation: {13 installation: {
14 workspaceId: WorkspaceId;14 workspaceId: WorkspaceId;
15 accountId: AccountId;15 accountId: AccountId;
16 organizationId: OrganizationId;
16 };17 };
17 loadedConfig: LoadedLocalExecutorConfig;18 loadedConfig: LoadedLocalExecutorConfig;
18};19};
@@ -27,3 +28,23 @@ export const getRuntimeLocalWorkspaceOption = () => · 8 unmodified lines
27 ).pipe(28 ).pipe(
28 Effect.map((option) => (Option.isSome(option) ? option.value : null)),29 Effect.map((option) => (Option.isSome(option) ? option.value : null)),
29 ) as Effect.Effect<RuntimeLocalWorkspaceState | null, never, never>;30 ) as Effect.Effect<RuntimeLocalWorkspaceState | null, never, never>;
31
32export const requireRuntimeLocalWorkspace = (workspaceId?: WorkspaceId) =>
33 Effect.flatMap(getRuntimeLocalWorkspaceOption(), (runtimeLocalWorkspace) => {
34 if (runtimeLocalWorkspace === null) {
35 return Effect.fail(new Error("Runtime local workspace is unavailable"));
36 }
37
38 if (
39 workspaceId !== undefined
40 && runtimeLocalWorkspace.installation.workspaceId !== workspaceId
41 ) {
42 return Effect.fail(
43 new Error(
44 `Workspace ${workspaceId} is not the active local workspace ${runtimeLocalWorkspace.installation.workspaceId}`,
45 ),
46 );
47 }
48
49 return Effect.succeed(runtimeLocalWorkspace);
50 });
packages/control-plane/src/runtime/local-workspace-sync.ts+1−45
@@ -1,13 +1,7 @@
1import type { SqlControlPlaneRows } from "#persistence";1import { type Policy } from "#schema";
2import {
3 type Policy,
4 type Workspace,
5} from "#schema";
6import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8 3
9import {4import {
10 defaultWorkspaceDisplayName,
11 type LoadedLocalExecutorConfig,5 type LoadedLocalExecutorConfig,
12 type ResolvedLocalWorkspaceContext,6 type ResolvedLocalWorkspaceContext,
13} from "./local-config";7} from "./local-config";
@@ -45,33 +39,6 @@ export const derivePolicyConfigKey = ( · 31 unmodified lines
45 return candidate;39 return candidate;
46};40};
47 41
48const ensureWorkspaceMetadata = (input: {
49 rows: SqlControlPlaneRows;
50 installation: {
51 workspaceId: Workspace["id"];
52 };
53 context: ResolvedLocalWorkspaceContext;
54 config: LoadedLocalExecutorConfig["config"];
55}): Effect.Effect<void, Error, never> =>
56 Effect.gen(function* () {
57 const workspace = yield* input.rows.workspaces.getById(input.installation.workspaceId);
58 if (Option.isNone(workspace)) {
59 return;
60 }
61
62 const desiredName =
63 trimOrNull(input.config?.workspace?.name)
64 ?? defaultWorkspaceDisplayName(input.context);
65 if (workspace.value.name === desiredName) {
66 return;
67 }
68
69 yield* input.rows.workspaces.update(workspace.value.id, {
70 name: desiredName,
71 updatedAt: Date.now(),
72 });
73 });
74
75const pruneLocalWorkspaceState = (input: {42const pruneLocalWorkspaceState = (input: {
76 context: ResolvedLocalWorkspaceContext;43 context: ResolvedLocalWorkspaceContext;
77 loadedConfig: LoadedLocalExecutorConfig;44 loadedConfig: LoadedLocalExecutorConfig;
@@ -122,21 +89,10 @@ const pruneLocalWorkspaceState = (input: { · 44 unmodified lines
122 });89 });
123 90
124export const synchronizeLocalWorkspaceState = (input: {91export const synchronizeLocalWorkspaceState = (input: {
125 rows: SqlControlPlaneRows;
126 context: ResolvedLocalWorkspaceContext;92 context: ResolvedLocalWorkspaceContext;
127 loadedConfig: LoadedLocalExecutorConfig;93 loadedConfig: LoadedLocalExecutorConfig;
128 installation: {
129 workspaceId: Workspace["id"];
130 };
131}): Effect.Effect<LoadedLocalExecutorConfig["config"], Error, never> =>94}): Effect.Effect<LoadedLocalExecutorConfig["config"], Error, never> =>
132 Effect.gen(function* () {95 Effect.gen(function* () {
133 yield* ensureWorkspaceMetadata({
134 rows: input.rows,
135 installation: input.installation,
136 context: input.context,
137 config: input.loadedConfig.config,
138 });
139
140 yield* pruneLocalWorkspaceState({96 yield* pruneLocalWorkspaceState({
141 context: input.context,97 context: input.context,
142 loadedConfig: input.loadedConfig,98 loadedConfig: input.loadedConfig,
packages/control-plane/src/runtime/operations-shared.ts+0−55
@@ -1,21 +1,14 @@
1import {1import {
2 ControlPlaneBadRequestError,2 ControlPlaneBadRequestError,
3 ControlPlaneNotFoundError,
4 ControlPlaneStorageError,3 ControlPlaneStorageError,
5} from "../api/errors";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+232−573
@@ -7,20 +7,18 @@ import type { · 6 unmodified lines
7import {7import {
8 PolicyIdSchema,8 PolicyIdSchema,
9 type LocalExecutorConfig,9 type LocalExecutorConfig,
10 type OrganizationId,
11 type Policy,10 type Policy,
12 type PolicyId,11 type PolicyId,
13 type WorkspaceId,12 type WorkspaceId,
14} from "#schema";13} from "#schema";
15import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";
17 15
18import {16import {
19 loadLocalExecutorConfig,17 loadLocalExecutorConfig,
20 writeProjectLocalExecutorConfig,18 writeProjectLocalExecutorConfig,
21} from "./local-config";19} from "./local-config";
22import {20import {
23 getRuntimeLocalWorkspaceOption,21 requireRuntimeLocalWorkspace,
24} from "./local-runtime-context";22} from "./local-runtime-context";
25import {23import {
26 loadLocalWorkspaceState,24 loadLocalWorkspaceState,
@@ -29,17 +27,10 @@ import { · 2 unmodified lines
29import {27import {
30 derivePolicyConfigKey,28 derivePolicyConfigKey,
31} from "./local-workspace-sync";29} from "./local-workspace-sync";
32import {
33 mapPersistenceError,
34 parseJsonString,
35 type Mutable,
36} from "./operations-shared";
37import {30import {
38 type OperationErrors,31 type OperationErrors,
39 operationErrors,32 operationErrors,
40} from "./operation-errors";33} from "./operation-errors";
41import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
42
43const policyOps = {34const policyOps = {
44 list: operationErrors("policies.list"),35 list: operationErrors("policies.list"),
45 create: operationErrors("policies.create"),36 create: operationErrors("policies.create"),
@@ -57,15 +48,6 @@ type PolicyScopeContext = { · 11 unmodified lines
57const cloneJson = <T>(value: T): T =>48const cloneJson = <T>(value: T): T =>
58 JSON.parse(JSON.stringify(value)) as T;49 JSON.parse(JSON.stringify(value)) as T;
59 50
60const trimOrNull = (value: string | null | undefined): string | null => {
61 if (value == null) {
62 return null;
63 }
64
65 const trimmed = value.trim();
66 return trimmed.length > 0 ? trimmed : null;
67};
68
69const localPolicyIdForConfigKey = (input: {51const localPolicyIdForConfigKey = (input: {
70 workspaceRoot: string;52 workspaceRoot: string;
71 configKey: string;53 configKey: string;
@@ -121,24 +103,9 @@ const ensureLocalWorkspacePolicyCompatible = ( · 49 unmodified lines
121 }103 }
122 });104 });
123 105
124export const loadRuntimeLocalWorkspacePolicies = (input: {106export const loadRuntimeLocalWorkspacePolicies = (workspaceId: WorkspaceId) =>
125 store: ControlPlaneStoreShape;
126 workspaceId: WorkspaceId;
127}) =>
128 Effect.gen(function* () {107 Effect.gen(function* () {
129 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();108 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId);
130 if (
131 runtimeLocalWorkspace === null
132 || runtimeLocalWorkspace.installation.workspaceId !== input.workspaceId
133 ) {
134 return null;
135 }
136
137 const workspace = yield* input.store.workspaces.getById(input.workspaceId);
138 if (Option.isNone(workspace)) {
139 return null;
140 }
141
142 const loadedConfig = yield* Effect.tryPromise({109 const loadedConfig = yield* Effect.tryPromise({
143 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),110 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),
144 catch: (cause) =>111 catch: (cause) =>
@@ -160,8 +127,8 @@ export const loadRuntimeLocalWorkspacePolicies = (input: { · 15 unmodified lines
160 }),127 }),
161 configKey,128 configKey,
162 scopeType: "workspace" as const,129 scopeType: "workspace" as const,
163 organizationId: workspace.value.organizationId,130 organizationId: runtimeLocalWorkspace.installation.organizationId,
164 workspaceId: input.workspaceId,131 workspaceId,
165 targetAccountId: null,132 targetAccountId: null,
166 clientId: null,133 clientId: null,
167 resourceType: "tool_path" as const,134 resourceType: "tool_path" as const,
@@ -181,61 +148,31 @@ export const loadRuntimeLocalWorkspacePolicies = (input: { · 13 unmodified lines
181 runtimeLocalWorkspace,148 runtimeLocalWorkspace,
182 loadedConfig,149 loadedConfig,
183 workspaceState,150 workspaceState,
184 workspace: workspace.value,
185 policies,151 policies,
186 };152 };
187 });153 });
188 154
189const loadWorkspacePolicyContext = (155const loadWorkspacePolicyContext = (
190 store: ControlPlaneStoreShape,
191 operation: OperationErrors,156 operation: OperationErrors,
192 workspaceId: WorkspaceId,157 workspaceId: WorkspaceId,
193) =>158) =>
194 Effect.gen(function* () {159 Effect.gen(function* () {
195 const workspace = yield* operation.child("workspace").mapStorage(160 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId).pipe(
196 store.workspaces.getById(workspaceId),161 Effect.mapError((cause) =>
197 );
198 if (Option.isNone(workspace)) {
199 return yield* Effect.fail(
200 operation.notFound(162 operation.notFound(
201 "Workspace not found",163 "Workspace not found",
202 `workspaceId=${workspaceId}`,164 cause instanceof Error ? cause.message : String(cause),
203 ),165 ),
204 );166 ),
205 }167 );
206 168
207 return {169 return {
208 scopeType: "workspace",170 scopeType: "workspace",
209 organizationId: workspace.value.organizationId,171 organizationId: runtimeLocalWorkspace.installation.organizationId,
210 workspaceId,172 workspaceId,
211 } satisfies PolicyScopeContext;173 } satisfies PolicyScopeContext;
212 });174 });
213 175
214const loadOrganizationPolicyContext = (
215 store: ControlPlaneStoreShape,
216 operation: OperationErrors,
217 organizationId: OrganizationId,
218) =>
219 Effect.gen(function* () {
220 const organization = yield* operation.child("organization").mapStorage(
221 store.organizations.getById(organizationId),
222 );
223 if (Option.isNone(organization)) {
224 return yield* Effect.fail(
225 operation.notFound(
226 "Organization not found",
227 `organizationId=${organizationId}`,
228 ),
229 );
230 }
231
232 return {
233 scopeType: "organization",
234 organizationId,
235 workspaceId: null,
236 } satisfies PolicyScopeContext;
237 });
238
239const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean =>176const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean =>
240 policy.scopeType === scope.scopeType177 policy.scopeType === scope.scopeType
241 && policy.organizationId === scope.organizationId178 && policy.organizationId === scope.organizationId
@@ -270,527 +207,249 @@ const writeLocalPolicyFiles = (input: { · 28 unmodified lines
270 ),207 ),
271 );208 );
272 209
273const createScopedPolicy = (input: {
274 scope: PolicyScopeContext;
275 payload: CreatePolicyPayload;
276}) =>
277 Effect.flatMap(ControlPlaneStore, (store) =>
278 Effect.gen(function* () {
279 const now = Date.now();
280 const operation = policyOps.create;
281
282 if (input.scope.workspaceId !== null) {
283 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
284 store,
285 workspaceId: input.scope.workspaceId,
286 }).pipe(
287 Effect.mapError((cause) =>
288 operation.unknownStorage(
289 cause,
290 "Failed loading local workspace policies",
291 ),
292 ),
293 );
294 if (localWorkspace !== null) {
295 yield* ensureLocalWorkspacePolicyCompatible(operation, input.payload);
296
297 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
298 const policies = {
299 ...(projectConfig.policies ?? {}),
300 };
301 const configKey = derivePolicyConfigKey({
302 configKey: null,
303 resourcePattern: input.payload.resourcePattern ?? "*",
304 effect: input.payload.effect ?? "allow",
305 approvalMode: input.payload.approvalMode ?? "auto",
306 }, new Set(Object.keys(policies)));
307 const id = localWorkspace.workspaceState.policies[configKey]?.id
308 ?? localPolicyIdForConfigKey({
309 workspaceRoot: localWorkspace.runtimeLocalWorkspace.context.workspaceRoot,
310 configKey,
311 });
312 policies[configKey] = {
313 match: input.payload.resourcePattern ?? "*",
314 action: input.payload.effect ?? "allow",
315 approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto",
316 ...(input.payload.enabled === false ? { enabled: false } : {}),
317 ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}),
318 };
319 const existingState = localWorkspace.workspaceState.policies[configKey];
320 const workspaceState = {
321 ...localWorkspace.workspaceState,
322 policies: {
323 ...localWorkspace.workspaceState.policies,
324 [configKey]: {
325 id,
326 createdAt: existingState?.createdAt ?? now,
327 updatedAt: now,
328 },
329 },
330 };
331 yield* writeLocalPolicyFiles({
332 operation,
333 context: localWorkspace.runtimeLocalWorkspace.context,
334 projectConfig: {
335 ...projectConfig,
336 policies,
337 },
338 workspaceState,
339 });
340
341 return {
342 id,
343 configKey,
344 scopeType: "workspace",
345 organizationId: input.scope.organizationId,
346 workspaceId: input.scope.workspaceId,
347 targetAccountId: null,
348 clientId: null,
349 resourceType: "tool_path",
350 resourcePattern: policies[configKey]!.match,
351 matchType: "glob",
352 effect: policies[configKey]!.action,
353 approvalMode: policies[configKey]!.approval === "manual" ? "required" : "auto",
354 argumentConditionsJson: null,
355 priority: policies[configKey]!.priority ?? 0,
356 enabled: policies[configKey]!.enabled ?? true,
357 createdAt: workspaceState.policies[configKey]!.createdAt,
358 updatedAt: now,
359 } satisfies Policy;
360 }
361 }
362
363 const policy: Policy = {
364 id: PolicyIdSchema.make(`pol_${crypto.randomUUID()}`),
365 configKey: null,
366 scopeType: input.scope.scopeType,
367 organizationId: input.scope.organizationId,
368 workspaceId: input.scope.workspaceId,
369 targetAccountId: input.payload.targetAccountId ?? null,
370 clientId: input.payload.clientId ?? null,
371 resourceType: input.payload.resourceType ?? "tool_path",
372 resourcePattern: input.payload.resourcePattern ?? "*",
373 matchType: input.payload.matchType ?? "glob",
374 effect: input.payload.effect ?? "allow",
375 approvalMode: input.payload.approvalMode ?? "auto",
376 argumentConditionsJson: input.payload.argumentConditionsJson ?? null,
377 priority: input.payload.priority ?? 0,
378 enabled: input.payload.enabled ?? true,
379 createdAt: now,
380 updatedAt: now,
381 };
382
383 if (policy.argumentConditionsJson !== null) {
384 yield* parseJsonString(
385 operation,
386 "argumentConditionsJson",
387 policy.argumentConditionsJson,
388 );
389 }
390
391 yield* mapPersistenceError(
392 operation,
393 store.policies.insert(policy),
394 );
395
396 if (policy.workspaceId === null) {
397 return policy;
398 }
399
400 return policy;
401 }));
402
403const getScopedPolicy = (input: {
404 scope: PolicyScopeContext;
405 policyId: PolicyId;
406}) =>
407 Effect.flatMap(ControlPlaneStore, (store) =>
408 Effect.gen(function* () {
409 if (input.scope.workspaceId !== null) {
410 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
411 store,
412 workspaceId: input.scope.workspaceId,
413 }).pipe(
414 Effect.mapError((cause) =>
415 policyOps.get.unknownStorage(
416 cause,
417 "Failed loading local workspace policies",
418 ),
419 ),
420 );
421 if (localWorkspace !== null) {
422 const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
423 if (policy === null) {
424 return yield* Effect.fail(
425 policyOps.get.notFound(
426 "Policy not found",
427 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
428 ),
429 );
430 }
431
432 return policy;
433 }
434 }
435
436 const existing = yield* policyOps.get.mapStorage(
437 store.policies.getById(input.policyId),
438 );
439
440 if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) {
441 return yield* Effect.fail(
442 policyOps.get.notFound(
443 "Policy not found",
444 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
445 ),
446 );
447 }
448
449 return existing.value;
450 }));
451
452const updateScopedPolicy = (input: {
453 scope: PolicyScopeContext;
454 policyId: PolicyId;
455 payload: UpdatePolicyPayload;
456}) =>
457 Effect.flatMap(ControlPlaneStore, (store) =>
458 Effect.gen(function* () {
459 if (input.scope.workspaceId !== null) {
460 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
461 store,
462 workspaceId: input.scope.workspaceId,
463 }).pipe(
464 Effect.mapError((cause) =>
465 policyOps.update.unknownStorage(
466 cause,
467 "Failed loading local workspace policies",
468 ),
469 ),
470 );
471 if (localWorkspace !== null) {
472 yield* ensureLocalWorkspacePolicyCompatible(policyOps.update, input.payload);
473 const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
474 if (existing === null || existing.configKey === null) {
475 return yield* Effect.fail(
476 policyOps.update.notFound(
477 "Policy not found",
478 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
479 ),
480 );
481 }
482
483 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
484 const policies = {
485 ...(projectConfig.policies ?? {}),
486 };
487 const existingConfig = policies[existing.configKey] ?? {
488 match: existing.resourcePattern,
489 action: existing.effect,
490 approval: existing.approvalMode === "required" ? "manual" : "auto",
491 };
492 policies[existing.configKey] = {
493 ...existingConfig,
494 ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}),
495 ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}),
496 ...(input.payload.approvalMode !== undefined
497 ? { approval: input.payload.approvalMode === "required" ? "manual" : "auto" }
498 : {}),
499 ...(input.payload.enabled !== undefined ? { enabled: input.payload.enabled } : {}),
500 ...(input.payload.priority !== undefined ? { priority: input.payload.priority } : {}),
501 };
502 const existingState = localWorkspace.workspaceState.policies[existing.configKey];
503 const updatedAt = Date.now();
504 const workspaceState = {
505 ...localWorkspace.workspaceState,
506 policies: {
507 ...localWorkspace.workspaceState.policies,
508 [existing.configKey]: {
509 id: existing.id,
510 createdAt: existingState?.createdAt ?? existing.createdAt,
511 updatedAt,
512 },
513 },
514 };
515 yield* writeLocalPolicyFiles({
516 operation: policyOps.update,
517 context: localWorkspace.runtimeLocalWorkspace.context,
518 projectConfig: {
519 ...projectConfig,
520 policies,
521 },
522 workspaceState,
523 });
524
525 return {
526 ...existing,
527 resourcePattern: policies[existing.configKey]!.match,
528 effect: policies[existing.configKey]!.action,
529 approvalMode: policies[existing.configKey]!.approval === "manual" ? "required" : "auto",
530 priority: policies[existing.configKey]!.priority ?? 0,
531 enabled: policies[existing.configKey]!.enabled ?? true,
532 updatedAt,
533 } satisfies Policy;
534 }
535 }
536
537 const existing = yield* policyOps.update.mapStorage(
538 store.policies.getById(input.policyId),
539 );
540 if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) {
541 return yield* Effect.fail(
542 policyOps.update.notFound(
543 "Policy not found",
544 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
545 ),
546 );
547 }
548
549 const patch: Partial<
550 Omit<Mutable<Policy>, "id" | "scopeType" | "organizationId" | "workspaceId" | "createdAt">
551 > = {
552 updatedAt: Date.now(),
553 };
554
555 if (input.payload.targetAccountId !== undefined) {
556 patch.targetAccountId = input.payload.targetAccountId;
557 }
558 if (input.payload.clientId !== undefined) {
559 patch.clientId = input.payload.clientId;
560 }
561 if (input.payload.resourceType !== undefined) {
562 patch.resourceType = input.payload.resourceType;
563 }
564 if (input.payload.resourcePattern !== undefined) {
565 patch.resourcePattern = input.payload.resourcePattern;
566 }
567 if (input.payload.matchType !== undefined) {
568 patch.matchType = input.payload.matchType;
569 }
570 if (input.payload.effect !== undefined) {
571 patch.effect = input.payload.effect;
572 }
573 if (input.payload.approvalMode !== undefined) {
574 patch.approvalMode = input.payload.approvalMode;
575 }
576 if (input.payload.argumentConditionsJson !== undefined) {
577 if (input.payload.argumentConditionsJson !== null) {
578 yield* parseJsonString(
579 policyOps.update,
580 "argumentConditionsJson",
581 input.payload.argumentConditionsJson,
582 );
583 }
584 patch.argumentConditionsJson = input.payload.argumentConditionsJson;
585 }
586 if (input.payload.priority !== undefined) {
587 patch.priority = input.payload.priority;
588 }
589 if (input.payload.enabled !== undefined) {
590 patch.enabled = input.payload.enabled;
591 }
592
593 const updated = yield* mapPersistenceError(
594 policyOps.update,
595 store.policies.update(input.policyId, patch),
596 );
597 if (Option.isNone(updated)) {
598 return yield* Effect.fail(
599 policyOps.update.notFound(
600 "Policy not found",
601 `scopeType=${input.scope.scopeType} organizationId=${input.scope.organizationId} workspaceId=${input.scope.workspaceId} policyId=${input.policyId}`,
602 ),
603 );
604 }
605
606 if (updated.value.workspaceId === null) {
607 return updated.value;
608 }
609
610 return updated.value;
611 }));
612
613const removeScopedPolicy = (input: {
614 scope: PolicyScopeContext;
615 policyId: PolicyId;
616}) =>
617 Effect.flatMap(ControlPlaneStore, (store) =>
618 Effect.gen(function* () {
619 if (input.scope.workspaceId !== null) {
620 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({
621 store,
622 workspaceId: input.scope.workspaceId,
623 }).pipe(
624 Effect.mapError((cause) =>
625 policyOps.remove.unknownStorage(
626 cause,
627 "Failed loading local workspace policies",
628 ),
629 ),
630 );
631 if (localWorkspace !== null) {
632 const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
633 if (existing === null || existing.configKey === null) {
634 return { removed: false };
635 }
636
637 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
638 const policies = {
639 ...(projectConfig.policies ?? {}),
640 };
641 delete policies[existing.configKey];
642 const {
643 [existing.configKey]: _removedPolicy,
644 ...remainingPolicies
645 } = localWorkspace.workspaceState.policies;
646 yield* writeLocalPolicyFiles({
647 operation: policyOps.remove,
648 context: localWorkspace.runtimeLocalWorkspace.context,
649 projectConfig: {
650 ...projectConfig,
651 policies,
652 },
653 workspaceState: {
654 ...localWorkspace.workspaceState,
655 policies: remainingPolicies,
656 },
657 });
658 return { removed: true };
659 }
660 }
661
662 const existing = yield* policyOps.remove.mapStorage(
663 store.policies.getById(input.policyId),
664 );
665 if (Option.isNone(existing) || !policyMatchesScope(existing.value, input.scope)) {
666 return { removed: false };
667 }
668
669 const removed = yield* policyOps.remove.mapStorage(
670 store.policies.removeById(input.policyId),
671 );
672
673 return { removed };
674 }));
675
676export const listOrganizationPolicies = (organizationId: OrganizationId) =>
677 Effect.flatMap(ControlPlaneStore, (store) =>
678 Effect.gen(function* () {
679 yield* loadOrganizationPolicyContext(store, policyOps.list, organizationId);
680 return yield* policyOps.list.mapStorage(
681 store.policies.listByOrganizationId(organizationId),
682 );
683 }));
684
685export const listPolicies = (workspaceId: WorkspaceId) =>210export const listPolicies = (workspaceId: WorkspaceId) =>
686 Effect.flatMap(ControlPlaneStore, (store) =>211 Effect.gen(function* () {
687 Effect.gen(function* () {212 yield* loadWorkspacePolicyContext(policyOps.list, workspaceId);
688 yield* loadWorkspacePolicyContext(store, policyOps.list, workspaceId);213 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(workspaceId).pipe(
689 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies({214 Effect.mapError((cause) =>
690 store,215 policyOps.list.unknownStorage(
691 workspaceId,216 cause,
692 }).pipe(217 "Failed loading local workspace policies",
693 Effect.mapError((cause) =>
694 policyOps.list.unknownStorage(
695 cause,
696 "Failed loading local workspace policies",
697 ),
698 ),218 ),
699 );219 ),
700 if (localWorkspace !== null) {220 );
701 return localWorkspace.policies;221 return localWorkspace.policies;
702 }222 });
703 return yield* policyOps.list.mapStorage(
704 store.policies.listByWorkspaceId(workspaceId),
705 );
706 }));
707
708export const createOrganizationPolicy = (input: {
709 organizationId: OrganizationId;
710 payload: CreatePolicyPayload;
711}) =>
712 Effect.flatMap(ControlPlaneStore, (store) =>
713 Effect.flatMap(
714 loadOrganizationPolicyContext(store, policyOps.create, input.organizationId),
715 (scope) => createScopedPolicy({ scope, payload: input.payload }),
716 ));
717 223
718export const createPolicy = (input: {224export const createPolicy = (input: {
719 workspaceId: WorkspaceId;225 workspaceId: WorkspaceId;
720 payload: CreatePolicyPayload;226 payload: CreatePolicyPayload;
721}) =>227}) =>
722 Effect.flatMap(ControlPlaneStore, (store) =>228 Effect.gen(function* () {
723 Effect.flatMap(229 const scope = yield* loadWorkspacePolicyContext(policyOps.create, input.workspaceId);
724 loadWorkspacePolicyContext(store, policyOps.create, input.workspaceId),230 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
725 (scope) => createScopedPolicy({ scope, payload: input.payload }),231 Effect.mapError((cause) =>
726 ));232 policyOps.create.unknownStorage(
233 cause,
234 "Failed loading local workspace policies",
235 ),
236 ),
237 );
238 yield* ensureLocalWorkspacePolicyCompatible(policyOps.create, input.payload);
727 239
728export const getOrganizationPolicy = (input: {240 const now = Date.now();
729 organizationId: OrganizationId;241 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
730 policyId: PolicyId;242 const policies = {
731}) =>243 ...(projectConfig.policies ?? {}),
732 Effect.flatMap(ControlPlaneStore, (store) =>244 };
733 Effect.flatMap(245 const configKey = derivePolicyConfigKey({
734 loadOrganizationPolicyContext(store, policyOps.get, input.organizationId),246 configKey: null,
735 (scope) => getScopedPolicy({ scope, policyId: input.policyId }),247 resourcePattern: input.payload.resourcePattern ?? "*",
736 ));248 effect: input.payload.effect ?? "allow",
249 approvalMode: input.payload.approvalMode ?? "auto",
250 }, new Set(Object.keys(policies)));
251 const id = localWorkspace.workspaceState.policies[configKey]?.id
252 ?? localPolicyIdForConfigKey({
253 workspaceRoot: localWorkspace.runtimeLocalWorkspace.context.workspaceRoot,
254 configKey,
255 });
256
257 policies[configKey] = {
258 match: input.payload.resourcePattern ?? "*",
259 action: input.payload.effect ?? "allow",
260 approval: (input.payload.approvalMode ?? "auto") === "required" ? "manual" : "auto",
261 ...(input.payload.enabled === false ? { enabled: false } : {}),
262 ...((input.payload.priority ?? 0) !== 0 ? { priority: input.payload.priority ?? 0 } : {}),
263 };
264
265 const existingState = localWorkspace.workspaceState.policies[configKey];
266 const workspaceState = {
267 ...localWorkspace.workspaceState,
268 policies: {
269 ...localWorkspace.workspaceState.policies,
270 [configKey]: {
271 id,
272 createdAt: existingState?.createdAt ?? now,
273 updatedAt: now,
274 },
275 },
276 };
277 yield* writeLocalPolicyFiles({
278 operation: policyOps.create,
279 context: localWorkspace.runtimeLocalWorkspace.context,
280 projectConfig: {
281 ...projectConfig,
282 policies,
283 },
284 workspaceState,
285 });
286
287 return {
288 id,
289 configKey,
290 scopeType: "workspace",
291 organizationId: scope.organizationId,
292 workspaceId: scope.workspaceId,
293 targetAccountId: null,
294 clientId: null,
295 resourceType: "tool_path",
296 resourcePattern: policies[configKey]!.match,
297 matchType: "glob",
298 effect: policies[configKey]!.action,
299 approvalMode: policies[configKey]!.approval === "manual" ? "required" : "auto",
300 argumentConditionsJson: null,
301 priority: policies[configKey]!.priority ?? 0,
302 enabled: policies[configKey]!.enabled ?? true,
303 createdAt: workspaceState.policies[configKey]!.createdAt,
304 updatedAt: now,
305 } satisfies Policy;
306 });
737 307
738export const getPolicy = (input: {308export const getPolicy = (input: {
739 workspaceId: WorkspaceId;309 workspaceId: WorkspaceId;
740 policyId: PolicyId;310 policyId: PolicyId;
741}) =>311}) =>
742 Effect.flatMap(ControlPlaneStore, (store) =>312 Effect.gen(function* () {
743 Effect.flatMap(313 const scope = yield* loadWorkspacePolicyContext(policyOps.get, input.workspaceId);
744 loadWorkspacePolicyContext(store, policyOps.get, input.workspaceId),314 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
745 (scope) => getScopedPolicy({ scope, policyId: input.policyId }),315 Effect.mapError((cause) =>
746 ));316 policyOps.get.unknownStorage(
747 317 cause,
748export const updateOrganizationPolicy = (input: {318 "Failed loading local workspace policies",
749 organizationId: OrganizationId;319 ),
750 policyId: PolicyId;320 ),
751 payload: UpdatePolicyPayload;321 );
752}) =>322 const policy = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
753 Effect.flatMap(ControlPlaneStore, (store) =>323 if (policy === null || !policyMatchesScope(policy, scope)) {
754 Effect.flatMap(324 return yield* Effect.fail(
755 loadOrganizationPolicyContext(store, policyOps.update, input.organizationId),325 policyOps.get.notFound(
756 (scope) => updateScopedPolicy({326 "Policy not found",
757 scope,327 `scopeType=${scope.scopeType} organizationId=${scope.organizationId} workspaceId=${scope.workspaceId} policyId=${input.policyId}`,
758 policyId: input.policyId,328 ),
759 payload: input.payload,329 );
760 }),330 }
761 ));331 return policy;
332 });
762 333
763export const updatePolicy = (input: {334export const updatePolicy = (input: {
764 workspaceId: WorkspaceId;335 workspaceId: WorkspaceId;
765 policyId: PolicyId;336 policyId: PolicyId;
766 payload: UpdatePolicyPayload;337 payload: UpdatePolicyPayload;
767}) =>338}) =>
768 Effect.flatMap(ControlPlaneStore, (store) =>339 Effect.gen(function* () {
769 Effect.flatMap(340 const scope = yield* loadWorkspacePolicyContext(policyOps.update, input.workspaceId);
770 loadWorkspacePolicyContext(store, policyOps.update, input.workspaceId),341 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
771 (scope) => updateScopedPolicy({342 Effect.mapError((cause) =>
772 scope,343 policyOps.update.unknownStorage(
773 policyId: input.policyId,344 cause,
774 payload: input.payload,345 "Failed loading local workspace policies",
775 }),346 ),
776 ));347 ),
348 );
349 yield* ensureLocalWorkspacePolicyCompatible(policyOps.update, input.payload);
350 const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
351 if (existing === null || existing.configKey === null || !policyMatchesScope(existing, scope)) {
352 return yield* Effect.fail(
353 policyOps.update.notFound(
354 "Policy not found",
355 `scopeType=${scope.scopeType} organizationId=${scope.organizationId} workspaceId=${scope.workspaceId} policyId=${input.policyId}`,
356 ),
357 );
358 }
777 359
778export const removeOrganizationPolicy = (input: {360 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
779 organizationId: OrganizationId;361 const policies = {
780 policyId: PolicyId;362 ...(projectConfig.policies ?? {}),
781}) =>363 };
782 Effect.flatMap(ControlPlaneStore, (store) =>364 const existingConfig = policies[existing.configKey] ?? {
783 Effect.flatMap(365 match: existing.resourcePattern,
784 loadOrganizationPolicyContext(store, policyOps.remove, input.organizationId),366 action: existing.effect,
785 (scope) => removeScopedPolicy({ scope, policyId: input.policyId }),367 approval: existing.approvalMode === "required" ? "manual" : "auto",
786 ));368 };
369 policies[existing.configKey] = {
370 ...existingConfig,
371 ...(input.payload.resourcePattern !== undefined ? { match: input.payload.resourcePattern } : {}),
372 ...(input.payload.effect !== undefined ? { action: input.payload.effect } : {}),
373 ...(input.payload.approvalMode !== undefined
374 ? { approval: input.payload.approvalMode === "required" ? "manual" : "auto" }
375 : {}),
376 ...(input.payload.enabled !== undefined ? { enabled: input.payload.enabled } : {}),
377 ...(input.payload.priority !== undefined ? { priority: input.payload.priority } : {}),
378 };
379
380 const updatedAt = Date.now();
381 const existingState = localWorkspace.workspaceState.policies[existing.configKey];
382 const workspaceState = {
383 ...localWorkspace.workspaceState,
384 policies: {
385 ...localWorkspace.workspaceState.policies,
386 [existing.configKey]: {
387 id: existing.id,
388 createdAt: existingState?.createdAt ?? existing.createdAt,
389 updatedAt,
390 },
391 },
392 };
393 yield* writeLocalPolicyFiles({
394 operation: policyOps.update,
395 context: localWorkspace.runtimeLocalWorkspace.context,
396 projectConfig: {
397 ...projectConfig,
398 policies,
399 },
400 workspaceState,
401 });
402
403 return {
404 ...existing,
405 resourcePattern: policies[existing.configKey]!.match,
406 effect: policies[existing.configKey]!.action,
407 approvalMode: policies[existing.configKey]!.approval === "manual" ? "required" : "auto",
408 priority: policies[existing.configKey]!.priority ?? 0,
409 enabled: policies[existing.configKey]!.enabled ?? true,
410 updatedAt,
411 } satisfies Policy;
412 });
787 413
788export const removePolicy = (input: {414export const removePolicy = (input: {
789 workspaceId: WorkspaceId;415 workspaceId: WorkspaceId;
790 policyId: PolicyId;416 policyId: PolicyId;
791}) =>417}) =>
792 Effect.flatMap(ControlPlaneStore, (store) =>418 Effect.gen(function* () {
793 Effect.flatMap(419 const scope = yield* loadWorkspacePolicyContext(policyOps.remove, input.workspaceId);
794 loadWorkspacePolicyContext(store, policyOps.remove, input.workspaceId),420 const localWorkspace = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
795 (scope) => removeScopedPolicy({ scope, policyId: input.policyId }),421 Effect.mapError((cause) =>
796 ));422 policyOps.remove.unknownStorage(
423 cause,
424 "Failed loading local workspace policies",
425 ),
426 ),
427 );
428 const existing = localWorkspace.policies.find((candidate) => candidate.id === input.policyId) ?? null;
429 if (existing === null || existing.configKey === null || !policyMatchesScope(existing, scope)) {
430 return { removed: false };
431 }
432
433 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
434 const policies = {
435 ...(projectConfig.policies ?? {}),
436 };
437 delete policies[existing.configKey];
438 const {
439 [existing.configKey]: _removedPolicy,
440 ...remainingPolicies
441 } = localWorkspace.workspaceState.policies;
442 yield* writeLocalPolicyFiles({
443 operation: policyOps.remove,
444 context: localWorkspace.runtimeLocalWorkspace.context,
445 projectConfig: {
446 ...projectConfig,
447 policies,
448 },
449 workspaceState: {
450 ...localWorkspace.workspaceState,
451 policies: remainingPolicies,
452 },
453 });
454 return { removed: true };
455 });
packages/control-plane/src/runtime/source-auth-service.test.ts+76−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,38 @@ 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* Effect.tryPromise({
190 try: () => resolveLocalWorkspaceContext({ workspaceRoot }),
191 catch: (cause) =>
192 cause instanceof Error ? cause : new Error(String(cause)),
193 });
194 const loadedConfig = yield* Effect.tryPromise({
195 try: () => loadLocalExecutorConfig(context),
196 catch: (cause) =>
197 cause instanceof Error ? cause : new Error(String(cause)),
198 });
199
200 return {
201 context,
202 installation: {
203 workspaceId,
204 accountId,
205 organizationId: "org_local_personal" as never,
206 },
207 loadedConfig,
208 } satisfies RuntimeLocalWorkspaceState;
209 }),
210 );
211
166describe("source-auth-service", () => {212describe("source-auth-service", () => {
167 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);213 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
168 214
@@ -225,36 +271,20 @@ describe("source-auth-service", () => { · 56 unmodified lines
225 271
226 const persistence = await makePersistence();272 const persistence = await makePersistence();
227 try {273 try {
228 const now = Date.now();
229 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth");274 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth");
230 const accountId = AccountIdSchema.make("acc_google_oauth");275 const accountId = AccountIdSchema.make("acc_google_oauth");
231 276 const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState(
232 await Effect.runPromise(277 workspaceId,
233 persistence.rows.organizations.insert({278 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 );279 );
253 280
254 const service = createRuntimeSourceAuthService({281 const service = createRuntimeSourceAuthService({
255 rows: persistence.rows,282 rows: persistence.rows,
256 liveExecutionManager: createLiveExecutionManager(),283 liveExecutionManager: createLiveExecutionManager(),
257 getLocalServerBaseUrl: () => "http://localhost:8788",284 getLocalServerBaseUrl: () => "http://localhost:8788",
285 localConfig: runtimeLocalWorkspace.loadedConfig.config,
286 workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
287 localWorkspaceState: runtimeLocalWorkspace,
258 });288 });
259 289
260 await withGoogleAuthTestServer({290 await withGoogleAuthTestServer({
@@ -290,7 +320,12 @@ describe("source-auth-service", () => { · 29 unmodified lines
290 clientId: "google-test-client",320 clientId: "google-test-client",
291 clientSecret: "google-test-secret",321 clientSecret: "google-test-secret",
292 },322 },
293 }),323 }).pipe(
324 Effect.provideService(
325 RuntimeLocalWorkspaceService,
326 runtimeLocalWorkspace,
327 ),
328 ),
294 );329 );
295 330
296 expect(addResult.kind).toBe("oauth_required");331 expect(addResult.kind).toBe("oauth_required");
@@ -461,22 +496,14 @@ describe("source-auth-service", () => { · 164 unmodified lines
461 expect(Option.isNone(oldSecret)).toBe(true);496 expect(Option.isNone(oldSecret)).toBe(true);
462 }497 }
463 498
464 const recipeOperations = await Effect.runPromise(499 const localArtifact = await readLocalSourceArtifact({
465 persistence.rows.sources.getByWorkspaceAndId(500 context: runtimeLocalWorkspace.context,
466 workspaceId,501 sourceId: connectedSource.id,
467 connectedSource.id,502 });
468 ),503 expect(localArtifact).not.toBeNull();
469 );504 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(505 expect(
479 materializedOperations.some(506 localArtifact?.operations.some(
480 (operation) => operation.toolId === "spreadsheets.values.get",507 (operation) => operation.toolId === "spreadsheets.values.get",
481 ),508 ),
482 ).toBe(true);509 ).toBe(true);
@@ -495,36 +522,20 @@ describe("source-auth-service", () => { · 12 unmodified lines
495 522
496 const persistence = await makePersistence();523 const persistence = await makePersistence();
497 try {524 try {
498 const now = Date.now();
499 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web");525 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web");
500 const accountId = AccountIdSchema.make("acc_google_oauth_web");526 const accountId = AccountIdSchema.make("acc_google_oauth_web");
501 527 const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState(
502 await Effect.runPromise(528 workspaceId,
503 persistence.rows.organizations.insert({529 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 );530 );
523 531
524 const service = createRuntimeSourceAuthService({532 const service = createRuntimeSourceAuthService({
525 rows: persistence.rows,533 rows: persistence.rows,
526 liveExecutionManager: createLiveExecutionManager(),534 liveExecutionManager: createLiveExecutionManager(),
527 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",535 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
536 localConfig: runtimeLocalWorkspace.loadedConfig.config,
537 workspaceRoot: runtimeLocalWorkspace.context.workspaceRoot,
538 localWorkspaceState: runtimeLocalWorkspace,
528 });539 });
529 540
530 await withGoogleAuthTestServer({541 await withGoogleAuthTestServer({
@@ -565,6 +576,11 @@ describe("source-auth-service", () => { · 34 unmodified lines
565 {576 {
566 baseUrl: "https://app.executor.dev",577 baseUrl: "https://app.executor.dev",
567 },578 },
579 ).pipe(
580 Effect.provideService(
581 RuntimeLocalWorkspaceService,
582 runtimeLocalWorkspace,
583 ),
568 ),584 ),
569 );585 );
570 586
packages/control-plane/src/runtime/source-inspection.test.ts+162−121
@@ -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,37 @@ 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* Effect.tryPromise({
44 status: "active",63 try: () => resolveLocalWorkspaceContext({ workspaceRoot }),
45 createdByAccountId: input.accountId,64 catch: (cause) =>
46 createdAt: 1000,65 cause instanceof Error ? cause : new Error(String(cause)),
47 updatedAt: 1000,66 });
48 }));67 const loadedConfig = yield* Effect.tryPromise({
49 await Effect.runPromise(input.persistence.rows.workspaces.insert({68 try: () => loadLocalExecutorConfig(context),
50 id: input.workspaceId,69 catch: (cause) =>
51 organizationId: input.organizationId,70 cause instanceof Error ? cause : new Error(String(cause)),
52 name: `Workspace ${input.workspaceId}`,71 });
53 createdByAccountId: input.accountId,72
54 createdAt: 1000,73 return {
55 updatedAt: 1000,74 context,
56 }));75 installation: {
57};76 workspaceId,
77 accountId,
78 organizationId: "org_local_personal" as never,
79 },
80 loadedConfig,
81 } satisfies RuntimeLocalWorkspaceState;
82 }),
83 );
58 84
59const makeSource = (input: {85const makeSource = (input: {
60 workspaceId: Source["workspaceId"];86 workspaceId: Source["workspaceId"];
@@ -133,16 +159,39 @@ const makeOperation = ( · 72 unmodified lines
133 ...overrides,159 ...overrides,
134});160});
135 161
162const configSourceFromSource = (source: Source): LocalConfigSource => ({
163 kind: "openapi",
164 ...(source.name !== source.id ? { name: source.name } : {}),
165 ...(source.namespace && source.namespace !== source.id
166 ? { namespace: source.namespace }
167 : {}),
168 connection: {
169 endpoint: source.endpoint,
170 },
171 binding: {
172 specUrl:
173 typeof source.binding.specUrl === "string"
174 ? source.binding.specUrl
175 : "https://example.com/openapi.json",
176 defaultHeaders:
177 (source.binding.defaultHeaders as Record<string, string> | null | undefined)
178 ?? null,
179 },
180});
181
136describe("source-inspection", () => {182describe("source-inspection", () => {
137 it("returns a lightweight inspection bundle and loads rich detail on demand", async () => {183 it("returns a lightweight inspection bundle and loads rich detail on demand", async () => {
138 const persistence = await makePersistence();184 const persistence = await makePersistence();
139 try {185 try {
140 const workspaceId = WorkspaceIdSchema.make("ws_source_inspection");186 const workspaceId = WorkspaceIdSchema.make("ws_source_inspection");
141 const organizationId = OrganizationIdSchema.make("org_source_inspection");
142 const accountId = AccountIdSchema.make("acc_source_inspection");187 const accountId = AccountIdSchema.make("acc_source_inspection");
143 const sourceId = SourceIdSchema.make("src_source_inspection");188 const sourceId = SourceIdSchema.make("src_source_inspection");
144 const recipeId = SourceRecipeIdSchema.make("src_recipe_inspection");189 const recipeRevisionId =
145 const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_inspection");190 SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization");
191 const runtimeLocalWorkspace = await makeRuntimeLocalWorkspaceState(
192 workspaceId,
193 accountId,
194 );
146 const hugeDocument = JSON.stringify({195 const hugeDocument = JSON.stringify({
147 openapi: "3.0.3",196 openapi: "3.0.3",
148 info: {197 info: {
@@ -162,103 +211,83 @@ describe("source-inspection", () => { · 13 unmodified lines
162 ),211 ),
163 });212 });
164 213
165 await seedWorkspace({214 const source = makeSource({
166 persistence,
167 workspaceId,215 workspaceId,
168 organizationId,216 sourceId,
169 accountId,
170 });217 });
171 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({218 await writeProjectLocalExecutorConfig({
172 id: recipeId,219 context: runtimeLocalWorkspace.context,
173 kind: "http_api",220 config: {
174 adapterKey: "openapi",221 sources: {
175 providerKey: "generic_http",222 [sourceId]: configSourceFromSource(source),
176 name: "Cloudflare API",223 },
177 summary: null,224 },
178 visibility: "workspace",225 });
179 latestRevisionId: recipeRevisionId,226 await writeLocalSourceArtifact({
180 createdAt: 1000,227 context: runtimeLocalWorkspace.context,
181 updatedAt: 1000,228 sourceId,
182 }));229 artifact: buildLocalSourceArtifact({
183 await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({230 source: {
184 id: recipeRevisionId,231 ...source,
185 recipeId,232 sourceHash: "manifest_hash",
186 revisionNumber: 1,233 },
187 sourceConfigJson: JSON.stringify({234 materialization: {
188 kind: "openapi",235 manifestJson: JSON.stringify({
189 endpoint: "https://api.cloudflare.com/client/v4",236 sourceHash: "manifest_hash",
190 specUrl: "https://example.com/openapi.json",237 }),
191 }),238 manifestHash: "manifest_hash",
192 manifestJson: JSON.stringify({239 sourceHash: "manifest_hash",
193 sourceHash: "manifest_hash",240 documents: [{
241 id: "src_recipe_doc_inspection",
242 recipeRevisionId,
243 documentKind: "openapi",
244 documentKey: "https://example.com/openapi.json",
245 contentText: hugeDocument,
246 contentHash: "doc_hash",
247 fetchedAt: 1000,
248 createdAt: 1000,
249 updatedAt: 1000,
250 }],
251 schemaBundles: [{
252 id: SourceRecipeSchemaBundleIdSchema.make(
253 "src_recipe_bundle_inspection",
254 ),
255 recipeRevisionId,
256 bundleKind: "json_schema_ref_map",
257 refsJson: JSON.stringify({
258 "#/components/schemas/Pagination": {
259 type: "object",
260 properties: {
261 page: { type: "number" },
262 },
263 },
264 }),
265 contentHash: "bundle_hash",
266 createdAt: 1000,
267 updatedAt: 1000,
268 }],
269 operations: [makeOperation({
270 recipeRevisionId,
271 })],
272 },
194 }),273 }),
195 manifestHash: "manifest_hash",274 });
196 materializationHash: "materialization_hash",275 await writeLocalWorkspaceState({
197 createdAt: 1000,276 context: runtimeLocalWorkspace.context,
198 updatedAt: 1000,277 state: {
199 }));
200 await Effect.runPromise(persistence.rows.sourceRecipeDocuments.replaceForRevision({
201 recipeRevisionId,
202 documents: [{
203 id: "src_recipe_doc_inspection",
204 recipeRevisionId,
205 documentKind: "openapi",
206 documentKey: "https://example.com/openapi.json",
207 contentText: hugeDocument,
208 contentHash: "doc_hash",
209 fetchedAt: 1000,
210 createdAt: 1000,
211 updatedAt: 1000,
212 }],
213 }));
214 await Effect.runPromise(persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
215 recipeRevisionId,
216 bundles: [{
217 id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_inspection"),
218 recipeRevisionId,
219 bundleKind: "json_schema_ref_map",
220 refsJson: JSON.stringify({
221 "#/components/schemas/Pagination": {
222 type: "object",
223 properties: {
224 page: { type: "number" },
225 },
226 },
227 }),
228 contentHash: "bundle_hash",
229 createdAt: 1000,
230 updatedAt: 1000,
231 }],
232 }));
233 await Effect.runPromise(persistence.rows.sourceRecipeOperations.replaceForRevision({
234 recipeRevisionId,
235 operations: [makeOperation()],
236 }));
237 await Effect.runPromise(persistence.rows.sources.insert({
238 id: sourceId,
239 workspaceId,
240 recipeId,
241 recipeRevisionId,
242 name: "Cloudflare API",
243 kind: "openapi",
244 endpoint: "https://api.cloudflare.com/client/v4",
245 status: "connected",
246 enabled: true,
247 namespace: "cloudflare.api",
248 importAuthPolicy: "reuse_runtime",
249 bindingConfigJson: JSON.stringify({
250 adapterKey: "openapi",
251 version: 1,278 version: 1,
252 payload: {279 sources: {
253 specUrl: "https://example.com/openapi.json",280 [sourceId]: {
254 defaultHeaders: null,281 status: "connected",
282 lastError: null,
283 sourceHash: "manifest_hash",
284 createdAt: 1000,
285 updatedAt: 1000,
286 },
255 },287 },
256 }),288 policies: {},
257 sourceHash: null,289 },
258 lastError: null,290 });
259 createdAt: 1000,
260 updatedAt: 1000,
261 }));
262 291
263 const inspection = await Effect.runPromise(292 const inspection = await Effect.runPromise(
264 getSourceInspection({293 getSourceInspection({
@@ -266,6 +295,10 @@ describe("source-inspection", () => { · 1 unmodified line
266 sourceId,295 sourceId,
267 }).pipe(296 }).pipe(
268 Effect.provideService(ControlPlaneStore, persistence.rows),297 Effect.provideService(ControlPlaneStore, persistence.rows),
298 Effect.provideService(
299 RuntimeLocalWorkspaceService,
300 runtimeLocalWorkspace,
301 ),
269 ),302 ),
270 );303 );
271 304
@@ -282,6 +315,10 @@ describe("source-inspection", () => { · 10 unmodified lines
282 toolPath: "cloudflare.api.zones.listZones",315 toolPath: "cloudflare.api.zones.listZones",
283 }).pipe(316 }).pipe(
284 Effect.provideService(ControlPlaneStore, persistence.rows),317 Effect.provideService(ControlPlaneStore, persistence.rows),
318 Effect.provideService(
319 RuntimeLocalWorkspaceService,
320 runtimeLocalWorkspace,
321 ),
285 ),322 ),
286 );323 );
287 324
@@ -300,6 +337,10 @@ describe("source-inspection", () => { · 12 unmodified lines
300 schemaBundleId: "src_recipe_bundle_inspection",337 schemaBundleId: "src_recipe_bundle_inspection",
301 }).pipe(338 }).pipe(
302 Effect.provideService(ControlPlaneStore, persistence.rows),339 Effect.provideService(ControlPlaneStore, persistence.rows),
340 Effect.provideService(
341 RuntimeLocalWorkspaceService,
342 runtimeLocalWorkspace,
343 ),
303 ),344 ),
304 );345 );
305 346
packages/control-plane/src/runtime/source-materialization.ts+67−105
@@ -11,7 +11,7 @@ import { · 10 unmodified lines
11 writeLocalSourceArtifact,11 writeLocalSourceArtifact,
12} from "./local-source-artifacts";12} from "./local-source-artifacts";
13import {13import {
14 getRuntimeLocalWorkspaceOption,14 requireRuntimeLocalWorkspace,
15} from "./local-runtime-context";15} from "./local-runtime-context";
16import {16import {
17 loadLocalWorkspaceState,17 loadLocalWorkspaceState,
@@ -29,7 +29,6 @@ import { · 11 unmodified lines
29import type {29import type {
30 ResolveSecretMaterial as ResolveSourceSecretMaterial,30 ResolveSecretMaterial as ResolveSourceSecretMaterial,
31} from "./secret-material-providers";31} from "./secret-material-providers";
32import { persistRecipeMaterialization } from "./source-recipe-support";
33 32
34const shouldIndexSource = (source: Source): boolean =>33const shouldIndexSource = (source: Source): boolean =>
35 source.enabled34 source.enabled
@@ -43,71 +42,9 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines
43 resolveSecretMaterial: ResolveSourceSecretMaterial;42 resolveSecretMaterial: ResolveSourceSecretMaterial;
44}): Effect.Effect<void, Error, never> =>43}): Effect.Effect<void, Error, never> =>
45 Effect.gen(function* () {44 Effect.gen(function* () {
46 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();45 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId);
47 if (
48 runtimeLocalWorkspace !== null
49 && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId
50 ) {
51 if (!shouldIndexSource(input.source)) {
52 const state = yield* Effect.tryPromise({
53 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
54 catch: (cause) =>
55 cause instanceof Error ? cause : new Error(String(cause)),
56 });
57 const existingSourceState = state.sources[input.source.id];
58 const nextState: LocalWorkspaceState = {
59 ...state,
60 sources: {
61 ...state.sources,
62 [input.source.id]: {
63 status: (input.source.enabled ? input.source.status : "draft") as SourceStatus,
64 lastError: null,
65 sourceHash: input.source.sourceHash,
66 createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
67 updatedAt: Date.now(),
68 },
69 },
70 };
71 yield* Effect.tryPromise({
72 try: () =>
73 writeLocalWorkspaceState({
74 context: runtimeLocalWorkspace.context,
75 state: nextState,
76 }),
77 catch: (cause) =>
78 cause instanceof Error ? cause : new Error(String(cause)),
79 });
80 return;
81 }
82
83 const adapter = getSourceAdapterForSource(input.source);
84 const materialization = yield* adapter.materializeSource({
85 source: input.source,
86 resolveSecretMaterial: input.resolveSecretMaterial,
87 resolveAuthMaterialForSlot: (slot) =>
88 resolveSourceAuthMaterial({
89 rows: input.rows,
90 source: input.source,
91 slot,
92 actorAccountId: input.actorAccountId,
93 resolveSecretMaterial: input.resolveSecretMaterial,
94 }),
95 });
96
97 yield* Effect.tryPromise({
98 try: () =>
99 writeLocalSourceArtifact({
100 context: runtimeLocalWorkspace.context,
101 sourceId: input.source.id,
102 artifact: buildLocalSourceArtifact({
103 source: input.source,
104 materialization,
105 }),
106 }),
107 catch: (cause) =>
108 cause instanceof Error ? cause : new Error(String(cause)),
109 });
110 46
47 if (!shouldIndexSource(input.source)) {
111 const state = yield* Effect.tryPromise({48 const state = yield* Effect.tryPromise({
112 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),49 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
113 catch: (cause) =>50 catch: (cause) =>
@@ -119,9 +56,9 @@ export const syncSourceMaterialization = (input: { · 5 unmodified lines
119 sources: {56 sources: {
120 ...state.sources,57 ...state.sources,
121 [input.source.id]: {58 [input.source.id]: {
122 status: "connected",59 status: (input.source.enabled ? input.source.status : "draft") as SourceStatus,
123 lastError: null,60 lastError: null,
124 sourceHash: materialization.sourceHash,61 sourceHash: input.source.sourceHash,
125 createdAt: existingSourceState?.createdAt ?? input.source.createdAt,62 createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
126 updatedAt: Date.now(),63 updatedAt: Date.now(),
127 },64 },
@@ -130,19 +67,15 @@ export const syncSourceMaterialization = (input: { · 2 unmodified lines
130 yield* Effect.tryPromise({67 yield* Effect.tryPromise({
131 try: () =>68 try: () =>
132 writeLocalWorkspaceState({69 writeLocalWorkspaceState({
133 context: runtimeLocalWorkspace.context,70 context: runtimeLocalWorkspace.context,
134 state: nextState,71 state: nextState,
135 }),72 }),
136 catch: (cause) =>73 catch: (cause) =>
137 cause instanceof Error ? cause : new Error(String(cause)),74 cause instanceof Error ? cause : new Error(String(cause)),
138 });75 });
139 return;76 return;
140 }77 }
141 78
142 if (!shouldIndexSource(input.source)) {
143 return;
144 }
145
146 const adapter = getSourceAdapterForSource(input.source);79 const adapter = getSourceAdapterForSource(input.source);
147 const materialization = yield* adapter.materializeSource({80 const materialization = yield* adapter.materializeSource({
148 source: input.source,81 source: input.source,
@@ -156,10 +89,47 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines
156 resolveSecretMaterial: input.resolveSecretMaterial,89 resolveSecretMaterial: input.resolveSecretMaterial,
157 }),90 }),
158 });91 });
159 yield* persistRecipeMaterialization({92 yield* Effect.tryPromise({
160 rows: input.rows,93 try: () =>
161 source: input.source,94 writeLocalSourceArtifact({
162 materialization,95 context: runtimeLocalWorkspace.context,
96 sourceId: input.source.id,
97 artifact: buildLocalSourceArtifact({
98 source: input.source,
99 materialization,
100 }),
101 }),
102 catch: (cause) =>
103 cause instanceof Error ? cause : new Error(String(cause)),
104 });
105
106 const state = yield* Effect.tryPromise({
107 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
108 catch: (cause) =>
109 cause instanceof Error ? cause : new Error(String(cause)),
110 });
111 const existingSourceState = state.sources[input.source.id];
112 const nextState: LocalWorkspaceState = {
113 ...state,
114 sources: {
115 ...state.sources,
116 [input.source.id]: {
117 status: "connected",
118 lastError: null,
119 sourceHash: materialization.sourceHash,
120 createdAt: existingSourceState?.createdAt ?? input.source.createdAt,
121 updatedAt: Date.now(),
122 },
123 },
124 };
125 yield* Effect.tryPromise({
126 try: () =>
127 writeLocalWorkspaceState({
128 context: runtimeLocalWorkspace.context,
129 state: nextState,
130 }),
131 catch: (cause) =>
132 cause instanceof Error ? cause : new Error(String(cause)),
163 });133 });
164 });134 });
165 135
@@ -171,32 +141,24 @@ export const persistMcpRecipeMaterializationFromManifest = (input: { · 5 unmodified lines
171 >[0]["manifestEntries"];141 >[0]["manifestEntries"];
172}): Effect.Effect<void, Error, never> =>142}): Effect.Effect<void, Error, never> =>
173 Effect.gen(function* () {143 Effect.gen(function* () {
174 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();144 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.source.workspaceId);
175 if (145 const materialization = materializationFromMcpManifestEntries({
176 runtimeLocalWorkspace !== null146 recipeRevisionId: "src_recipe_rev_materialization" as never,
177 && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId147 endpoint: input.source.endpoint,
178 ) {148 manifestEntries: input.manifestEntries,
179 const materialization = materializationFromMcpManifestEntries({149 });
180 recipeRevisionId: "src_recipe_rev_materialization" as never,
181 endpoint: input.source.endpoint,
182 manifestEntries: input.manifestEntries,
183 });
184 150
185 yield* Effect.tryPromise({151 yield* Effect.tryPromise({
186 try: () =>152 try: () =>
187 writeLocalSourceArtifact({153 writeLocalSourceArtifact({
188 context: runtimeLocalWorkspace.context,154 context: runtimeLocalWorkspace.context,
189 sourceId: input.source.id,155 sourceId: input.source.id,
190 artifact: buildLocalSourceArtifact({156 artifact: buildLocalSourceArtifact({
191 source: input.source,157 source: input.source,
192 materialization,158 materialization,
193 }),
194 }),159 }),
195 catch: (cause) =>160 }),
196 cause instanceof Error ? cause : new Error(String(cause)),161 catch: (cause) =>
197 });162 cause instanceof Error ? cause : new Error(String(cause)),
198 return;163 });
199 }
200
201 return yield* persistMcpRecipeRevisionFromManifestEntries(input);
202 });164 });
packages/control-plane/src/runtime/source-recipes-runtime.test.ts+371−245
@@ -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,45 @@ 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* Effect.tryPromise({
70 try: () => resolveLocalWorkspaceContext({ workspaceRoot }),
71 catch: (cause) =>
72 cause instanceof Error ? cause : new Error(String(cause)),
73 });
74 const loadedConfig = yield* Effect.tryPromise({
75 try: () => loadLocalExecutorConfig(context),
76 catch: (cause) =>
77 cause instanceof Error ? cause : new Error(String(cause)),
78 });
79
80 return {
81 context,
82 installation: {
83 workspaceId,
84 accountId: AccountIdSchema.make("acc_local_source_recipes"),
85 organizationId: "org_local_personal" as never,
86 },
87 loadedConfig,
88 } satisfies RuntimeLocalWorkspaceState;
89 }),
90 );
91
92const withRuntimeLocalWorkspace = <A, E>(
93 effect: Effect.Effect<A, E, never>,
94 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
95) =>
96 effect.pipe(
97 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
98 );
99
41const openApiBindingConfigJson = (specUrl: string): string =>100const openApiBindingConfigJson = (specUrl: string): string =>
42 JSON.stringify({101 JSON.stringify({
43 adapterKey: "openapi",102 adapterKey: "openapi",
@@ -221,30 +280,128 @@ const makeMcpOperation = ( · 177 unmodified lines
221 ...overrides,280 ...overrides,
222});281});
223 282
224const seedWorkspace = async (input: {283const configSourceFromSource = (source: Source): LocalConfigSource => {
225 persistence: SqlControlPlanePersistence;284 switch (source.kind) {
226 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;285 case "openapi":
227 organizationId: ReturnType<typeof OrganizationIdSchema.make>;286 return {
228 accountId: ReturnType<typeof AccountIdSchema.make>;287 kind: "openapi" as const,
288 ...(source.name !== source.id ? { name: source.name } : {}),
289 ...(source.namespace && source.namespace !== source.id
290 ? { namespace: source.namespace }
291 : {}),
292 ...(source.enabled ? {} : { enabled: false }),
293 connection: {
294 endpoint: source.endpoint,
295 },
296 binding: {
297 specUrl:
298 typeof source.binding.specUrl === "string"
299 ? source.binding.specUrl
300 : "https://example.com/openapi.json",
301 defaultHeaders:
302 (source.binding.defaultHeaders as Record<string, string> | null | undefined)
303 ?? null,
304 },
305 };
306 case "graphql":
307 return {
308 kind: "graphql" as const,
309 ...(source.name !== source.id ? { name: source.name } : {}),
310 ...(source.namespace && source.namespace !== source.id
311 ? { namespace: source.namespace }
312 : {}),
313 ...(source.enabled ? {} : { enabled: false }),
314 connection: {
315 endpoint: source.endpoint,
316 },
317 binding: {
318 defaultHeaders:
319 (source.binding.defaultHeaders as Record<string, string> | null | undefined)
320 ?? null,
321 },
322 };
323 case "mcp":
324 return {
325 kind: "mcp" as const,
326 ...(source.name !== source.id ? { name: source.name } : {}),
327 ...(source.namespace && source.namespace !== source.id
328 ? { namespace: source.namespace }
329 : {}),
330 ...(source.enabled ? {} : { enabled: false }),
331 connection: {
332 endpoint: source.endpoint,
333 },
334 binding: {
335 transport:
336 (source.binding.transport as "auto" | "streamable-http" | "sse" | null | undefined)
337 ?? null,
338 queryParams:
339 (source.binding.queryParams as Record<string, string> | null | undefined)
340 ?? null,
341 headers:
342 (source.binding.headers as Record<string, string> | null | undefined)
343 ?? null,
344 },
345 };
346 default:
347 throw new Error(`Unsupported source kind in test fixture: ${source.kind}`);
348 }
349};
350
351const seedLocalWorkspaceSources = async (input: {
352 runtimeLocalWorkspace: RuntimeLocalWorkspaceState;
353 fixtures: ReadonlyArray<{
354 source: Source;
355 materialization: {
356 manifestJson: string | null;
357 manifestHash: string | null;
358 sourceHash: string | null;
359 documents: ReadonlyArray<any>;
360 schemaBundles: ReadonlyArray<any>;
361 operations: ReadonlyArray<StoredSourceRecipeOperationRecord>;
362 };
363 }>;
229}) => {364}) => {
230 const now = 1000;365 await writeProjectLocalExecutorConfig({
231 await Effect.runPromise(input.persistence.rows.organizations.insert({366 context: input.runtimeLocalWorkspace.context,
232 id: input.organizationId,367 config: {
233 slug: `org-${input.organizationId}`,368 sources: Object.fromEntries(
234 name: `Org ${input.organizationId}`,369 input.fixtures.map(({ source }) => [source.id, configSourceFromSource(source)]),
235 status: "active",370 ),
236 createdByAccountId: input.accountId,371 },
237 createdAt: now,372 });
238 updatedAt: now,373
239 }));374 await Promise.all(
240 await Effect.runPromise(input.persistence.rows.workspaces.insert({375 input.fixtures.map(({ source, materialization }) =>
241 id: input.workspaceId,376 writeLocalSourceArtifact({
242 organizationId: input.organizationId,377 context: input.runtimeLocalWorkspace.context,
243 name: `Workspace ${input.workspaceId}`,378 sourceId: source.id,
244 createdByAccountId: input.accountId,379 artifact: buildLocalSourceArtifact({
245 createdAt: now,380 source,
246 updatedAt: now,381 materialization,
247 }));382 }),
383 })),
384 );
385
386 await writeLocalWorkspaceState({
387 context: input.runtimeLocalWorkspace.context,
388 state: {
389 version: 1,
390 sources: Object.fromEntries(
391 input.fixtures.map(({ source }) => [
392 source.id,
393 {
394 status: source.status,
395 lastError: source.lastError,
396 sourceHash: source.sourceHash,
397 createdAt: source.createdAt,
398 updatedAt: source.updatedAt,
399 },
400 ]),
401 ),
402 policies: {},
403 },
404 });
248};405};
249 406
250describe("source-recipes-runtime", () => {407describe("source-recipes-runtime", () => {
@@ -432,10 +589,8 @@ describe("source-recipes-runtime", () => { · 181 unmodified lines
432 const persistence = await makePersistence();589 const persistence = await makePersistence();
433 try {590 try {
434 const workspaceId = WorkspaceIdSchema.make("ws_shared_revision");591 const workspaceId = WorkspaceIdSchema.make("ws_shared_revision");
435 const organizationId = OrganizationIdSchema.make("org_shared_revision");592 const runtimeLocalWorkspace =
436 const accountId = AccountIdSchema.make("acc_shared_revision");593 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({594 const openApiDocument = JSON.stringify({
440 openapi: "3.0.3",595 openapi: "3.0.3",
441 info: {596 info: {
@@ -475,101 +630,68 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines
475 definition,630 definition,
476 });631 });
477 632
478 await seedWorkspace({633 await seedLocalWorkspaceSources({
479 persistence,634 runtimeLocalWorkspace,
480 workspaceId,635 fixtures: ["GitHub One", "GitHub Two"].map((name, index) => ({
481 organizationId,636 source: makeSource({
482 accountId,637 id: SourceIdSchema.make(`src_shared_revision_${index}`),
638 workspaceId,
639 name,
640 sourceHash: manifest.sourceHash,
641 createdAt: 1000 + index,
642 updatedAt: 1000 + index,
643 }),
644 materialization: {
645 manifestJson: JSON.stringify(manifest),
646 manifestHash: manifest.sourceHash,
647 sourceHash: manifest.sourceHash,
648 documents: [{
649 id: `src_recipe_doc_shared_revision_${index}`,
650 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"),
651 documentKind: "openapi",
652 documentKey: "https://api.github.com/openapi.json",
653 contentText: openApiDocument,
654 contentHash: manifest.sourceHash,
655 fetchedAt: 1000,
656 createdAt: 1000,
657 updatedAt: 1000,
658 }],
659 schemaBundles: [],
660 operations: [makeOperation({
661 id: `src_recipe_op_shared_revision_${index}`,
662 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_materialization"),
663 operationKey: definition.toolId,
664 toolId: definition.toolId,
665 title: definition.name,
666 description: definition.description,
667 searchText: `${definition.toolId} ${definition.name}`.toLowerCase(),
668 inputSchemaJson: presentation.inputSchemaJson ?? null,
669 outputSchemaJson: presentation.outputSchemaJson ?? null,
670 providerDataJson: presentation.providerDataJson,
671 })],
672 },
673 })),
483 });674 });
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
542 for (const [index, name] of ["GitHub One", "GitHub Two"].entries()) {
543 await Effect.runPromise(persistence.rows.sources.insert({
544 id: SourceIdSchema.make(`src_shared_revision_${index}`),
545 workspaceId,
546 recipeId,
547 recipeRevisionId,
548 name,
549 kind: "openapi",
550 endpoint: "https://api.github.com",
551 status: "connected",
552 enabled: true,
553 namespace: "github",
554 importAuthPolicy: "reuse_runtime",
555 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
556 sourceHash: manifest.sourceHash,
557 lastError: null,
558 createdAt: 1000 + index,
559 updatedAt: 1000 + index,
560 }));
561 }
562 675
563 const recipes = await Effect.runPromise(loadWorkspaceSourceRecipes({676 const recipes = await Effect.runPromise(
564 rows: persistence.rows,677 withRuntimeLocalWorkspace(
565 workspaceId,678 loadWorkspaceSourceRecipes({
566 }));679 rows: persistence.rows,
680 workspaceId,
681 }),
682 runtimeLocalWorkspace,
683 ),
684 );
567 685
568 expect(recipes).toHaveLength(2);686 expect(recipes).toHaveLength(2);
569 expect(recipes[0]?.documents).toHaveLength(1);687 expect(recipes[0]?.documents).toHaveLength(1);
570 expect(recipes[0]?.operations).toHaveLength(1);688 expect(recipes[0]?.operations).toHaveLength(1);
571 expect(recipes[0]?.documents).toBe(recipes[1]?.documents);689 expect(recipes[0]?.documents[0]?.contentText).toBe(
572 expect(recipes[0]?.operations).toBe(recipes[1]?.operations);690 recipes[1]?.documents[0]?.contentText,
691 );
692 expect(recipes[0]?.operations[0]?.providerDataJson).toBe(
693 recipes[1]?.operations[0]?.providerDataJson,
694 );
573 } finally {695 } finally {
574 await persistence.close();696 await persistence.close();
575 }697 }
@@ -579,68 +701,44 @@ describe("source-recipes-runtime", () => { · 3 unmodified lines
579 const persistence = await makePersistence();701 const persistence = await makePersistence();
580 try {702 try {
581 const workspaceId = WorkspaceIdSchema.make("ws_empty_recipe_rows");703 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");704 const sourceId = SourceIdSchema.make("src_empty_recipe_rows");
585 const recipeId = SourceRecipeIdSchema.make("src_recipe_empty_recipe_rows");705 const runtimeLocalWorkspace =
586 const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_empty_recipe_rows");706 await makeRuntimeLocalWorkspaceState(workspaceId);
587 707
588 await seedWorkspace({708 await seedLocalWorkspaceSources({
589 persistence,709 runtimeLocalWorkspace,
590 workspaceId,710 fixtures: [{
591 organizationId,711 source: makeSource({
592 accountId,712 id: sourceId,
713 workspaceId,
714 name: "GraphQL Demo",
715 kind: "graphql",
716 endpoint: "https://example.com/graphql",
717 namespace: "graphql",
718 binding: {
719 defaultHeaders: null,
720 },
721 }),
722 materialization: {
723 manifestJson: null,
724 manifestHash: null,
725 sourceHash: null,
726 documents: [],
727 schemaBundles: [],
728 operations: [],
729 },
730 }],
593 });731 });
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 732
640 const recipes = await Effect.runPromise(loadWorkspaceSourceRecipes({733 const recipes = await Effect.runPromise(
641 rows: persistence.rows,734 withRuntimeLocalWorkspace(
642 workspaceId,735 loadWorkspaceSourceRecipes({
643 }));736 rows: persistence.rows,
737 workspaceId,
738 }),
739 runtimeLocalWorkspace,
740 ),
741 );
644 742
645 expect(recipes).toHaveLength(1);743 expect(recipes).toHaveLength(1);
646 expect(recipes[0]?.documents).toEqual([]);744 expect(recipes[0]?.documents).toEqual([]);
@@ -654,84 +752,112 @@ describe("source-recipes-runtime", () => { · 7 unmodified lines
654 it("fails clearly when loading a missing source, missing revision, or invalid manifest", async () => {752 it("fails clearly when loading a missing source, missing revision, or invalid manifest", async () => {
655 const persistence = await makePersistence();753 const persistence = await makePersistence();
656 try {754 try {
657 await expect(Effect.runPromise(loadSourceWithRecipe({755 const missingWorkspaceId = WorkspaceIdSchema.make("ws_missing_source");
658 rows: persistence.rows,756 const missingRuntimeLocalWorkspace =
659 workspaceId: WorkspaceIdSchema.make("ws_missing_source"),757 await makeRuntimeLocalWorkspaceState(missingWorkspaceId);
660 sourceId: SourceIdSchema.make("src_missing_source"),758
661 }))).rejects.toThrow("Source not found");759 await expect(
760 Effect.runPromise(
761 withRuntimeLocalWorkspace(
762 loadSourceWithRecipe({
763 rows: persistence.rows,
764 workspaceId: missingWorkspaceId,
765 sourceId: SourceIdSchema.make("src_missing_source"),
766 }),
767 missingRuntimeLocalWorkspace,
768 ),
769 ),
770 ).rejects.toThrow("Source not found");
662 771
663 const workspaceId = WorkspaceIdSchema.make("ws_bad_recipe_runtime");772 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");773 const sourceId = SourceIdSchema.make("src_bad_recipe_runtime");
667 const recipeId = SourceRecipeIdSchema.make("src_recipe_bad_recipe_runtime");774 const runtimeLocalWorkspace =
668 const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bad_recipe_runtime");775 await makeRuntimeLocalWorkspaceState(workspaceId);
669 776
670 await seedWorkspace({777 await writeProjectLocalExecutorConfig({
671 persistence,778 context: runtimeLocalWorkspace.context,
672 workspaceId,779 config: {
673 organizationId,780 sources: {
674 accountId,781 [sourceId]: configSourceFromSource(
782 makeSource({
783 id: sourceId,
784 workspaceId,
785 name: "Broken GitHub",
786 }),
787 ),
788 },
789 },
675 });790 });
676 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({791 await writeLocalWorkspaceState({
677 id: recipeId,792 context: runtimeLocalWorkspace.context,
678 kind: "http_api",793 state: {
679 adapterKey: "openapi",794 version: 1,
680 providerKey: "generic_http",795 sources: {
681 name: "Broken GitHub",796 [sourceId]: {
682 summary: null,797 status: "connected",
683 visibility: "workspace",798 lastError: null,
684 latestRevisionId: recipeRevisionId,799 sourceHash: null,
685 createdAt: 1000,800 createdAt: 1000,
686 updatedAt: 1000,801 updatedAt: 1000,
687 }));802 },
688 await Effect.runPromise(persistence.rows.sources.insert({803 },
804 policies: {},
805 },
806 });
807
808 await expect(
809 Effect.runPromise(
810 withRuntimeLocalWorkspace(
811 loadSourceWithRecipe({
812 rows: persistence.rows,
813 workspaceId,
814 sourceId,
815 }),
816 runtimeLocalWorkspace,
817 ),
818 ),
819 ).rejects.toThrow("Recipe artifact missing");
820
821 const invalidManifestSource = makeSource({
689 id: sourceId,822 id: sourceId,
690 workspaceId,823 workspaceId,
691 recipeId,
692 recipeRevisionId,
693 name: "Broken GitHub",824 name: "Broken GitHub",
694 kind: "openapi",825 });
695 endpoint: "https://api.github.com",826 const invalidArtifact = buildLocalSourceArtifact({
696 status: "connected",827 source: invalidManifestSource,
697 enabled: true,828 materialization: {
698 namespace: "github",829 manifestJson: "{}",
699 importAuthPolicy: "reuse_runtime",830 manifestHash: "manifest_hash_invalid",
700 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),831 sourceHash: "manifest_hash_invalid",
701 sourceHash: null,832 documents: [],
702 lastError: null,833 schemaBundles: [],
703 createdAt: 1000,834 operations: [],
704 updatedAt: 1000,835 },
705 }));836 });
706 837 await writeLocalSourceArtifact({
707 await expect(Effect.runPromise(loadSourceWithRecipe({838 context: runtimeLocalWorkspace.context,
708 rows: persistence.rows,
709 workspaceId,
710 sourceId,839 sourceId,
711 }))).rejects.toThrow("Recipe revision missing");840 artifact: {
712 841 ...invalidArtifact,
713 await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({842 revision: {
714 id: recipeRevisionId,843 ...invalidArtifact.revision,
715 recipeId,844 manifestJson: "{bad-json",
716 revisionNumber: 1,845 },
717 sourceConfigJson: JSON.stringify({846 },
718 kind: "openapi",847 });
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 }));
729 848
730 await expect(Effect.runPromise(loadSourceWithRecipe({849 await expect(
731 rows: persistence.rows,850 Effect.runPromise(
732 workspaceId,851 withRuntimeLocalWorkspace(
733 sourceId,852 loadSourceWithRecipe({
734 }))).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`);853 rows: persistence.rows,
854 workspaceId,
855 sourceId,
856 }),
857 runtimeLocalWorkspace,
858 ),
859 ),
860 ).rejects.toThrow(`Invalid OpenAPI manifest for ${sourceId}`);
735 } finally {861 } finally {
736 await persistence.close();862 await persistence.close();
737 }863 }
packages/control-plane/src/runtime/source-recipes-runtime.ts+78−198
@@ -13,13 +13,12 @@ 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
18import {17import {
19 readLocalSourceArtifact,18 readLocalSourceArtifact,
20} from "./local-source-artifacts";19} from "./local-source-artifacts";
21import {20import {
22 getRuntimeLocalWorkspaceOption,21 requireRuntimeLocalWorkspace,
23} from "./local-runtime-context";22} from "./local-runtime-context";
24import { namespaceFromSourceName } from "./source-names";23import { namespaceFromSourceName } from "./source-names";
25import {24import {
@@ -162,133 +161,61 @@ export const loadWorkspaceSourceRecipes = (input: { · 136 unmodified lines
162 actorAccountId?: AccountId | null;161 actorAccountId?: AccountId | null;
163}): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> =>162}): Effect.Effect<readonly LoadedSourceRecipe[], Error, never> =>
164 Effect.gen(function* () {163 Effect.gen(function* () {
165 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();164 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId);
166 if (
167 runtimeLocalWorkspace !== null
168 && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId
169 ) {
170 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
171 actorAccountId: input.actorAccountId,
172 });
173
174 const localRecipes = yield* Effect.forEach(sources, (source) =>
175 Effect.gen(function* () {
176 const artifact = yield* Effect.tryPromise({
177 try: () =>
178 readLocalSourceArtifact({
179 context: runtimeLocalWorkspace.context,
180 sourceId: source.id,
181 }),
182 catch: (cause) =>
183 cause instanceof Error ? cause : new Error(String(cause)),
184 });
185 if (artifact === null) {
186 return null;
187 }
188
189 const sourceRecord: StoredSourceRecord = {
190 id: source.id,
191 workspaceId: source.workspaceId,
192 recipeId: artifact.recipeId,
193 recipeRevisionId: artifact.revision.id,
194 name: source.name,
195 kind: source.kind,
196 endpoint: source.endpoint,
197 status: source.status,
198 enabled: source.enabled,
199 namespace: source.namespace,
200 importAuthPolicy: source.importAuthPolicy,
201 bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
202 sourceHash: source.sourceHash,
203 lastError: source.lastError,
204 createdAt: source.createdAt,
205 updatedAt: source.updatedAt,
206 };
207 const manifest = yield* parseManifestForRecipe({
208 source,
209 revision: artifact.revision,
210 });
211
212 return {
213 source,
214 sourceRecord,
215 revision: artifact.revision,
216 documents: artifact.documents,
217 schemaBundles: artifact.schemaBundles,
218 operations: artifact.operations,
219 manifest,
220 } satisfies LoadedSourceRecipe;
221 }),
222 );
223
224 return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null);
225 }
226
227 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
228 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {165 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
229 actorAccountId: input.actorAccountId,166 actorAccountId: input.actorAccountId,
230 });167 });
231 168 const localRecipes = yield* Effect.forEach(sources, (source) =>
232 const sourceById = new Map(sources.map((source) => [source.id, source]));
233 const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
234 const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
235
236 const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds);
237 const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
238 const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);
239 const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds);
240 const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
241 for (const document of documents) {
242 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
243 existing.push(document);
244 documentsByRevisionId.set(document.recipeRevisionId, existing);
245 }
246 const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
247 for (const schemaBundle of schemaBundles) {
248 const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
249 existing.push(schemaBundle);
250 schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
251 }
252 const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds);
253 const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
254 for (const operation of operations) {
255 const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];
256 existing.push(operation);
257 operationsByRevisionId.set(operation.recipeRevisionId, existing);
258 }
259
260 return yield* Effect.forEach(relevantSourceRecords, (sourceRecord) =>
261 Effect.gen(function* () {169 Effect.gen(function* () {
262 const source = sourceById.get(sourceRecord.id);170 const artifact = yield* Effect.tryPromise({
263 if (!source) {171 try: () =>
264 return yield* Effect.fail(172 readLocalSourceArtifact({
265 new Error(`Projected source missing for ${sourceRecord.id}`),173 context: runtimeLocalWorkspace.context,
266 );174 sourceId: source.id,
175 }),
176 catch: (cause) =>
177 cause instanceof Error ? cause : new Error(String(cause)),
178 });
179 if (artifact === null) {
180 return null;
267 }181 }
268 182
269 const revision = revisionById.get(sourceRecord.recipeRevisionId);183 const sourceRecord: StoredSourceRecord = {
270 if (!revision) {184 id: source.id,
271 return yield* Effect.fail(185 workspaceId: source.workspaceId,
272 new Error(`Recipe revision missing for source ${sourceRecord.id}`),186 recipeId: artifact.recipeId,
273 );187 recipeRevisionId: artifact.revision.id,
274 }188 name: source.name,
189 kind: source.kind,
190 endpoint: source.endpoint,
191 status: source.status,
192 enabled: source.enabled,
193 namespace: source.namespace,
194 importAuthPolicy: source.importAuthPolicy,
195 bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
196 sourceHash: source.sourceHash,
197 lastError: source.lastError,
198 createdAt: source.createdAt,
199 updatedAt: source.updatedAt,
200 };
275 201
276 const manifest = yield* parseManifestForRecipe({202 const manifest = yield* parseManifestForRecipe({
277 source,203 source,
278 revision,204 revision: artifact.revision,
279 });205 });
280 206
281 return {207 return {
282 source,208 source,
283 sourceRecord,209 sourceRecord,
284 revision,210 revision: artifact.revision,
285 documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],211 documents: artifact.documents,
286 schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],212 schemaBundles: artifact.schemaBundles,
287 operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],213 operations: artifact.operations,
288 manifest,214 manifest,
289 } satisfies LoadedSourceRecipe;215 } satisfies LoadedSourceRecipe;
290 }),216 }),
291 );217 );
218 return localRecipes.filter((recipe): recipe is LoadedSourceRecipe => recipe !== null);
292 });219 });
293 220
294export const loadSourceWithRecipe = (input: {221export const loadSourceWithRecipe = (input: {
@@ -298,104 +225,57 @@ export const loadSourceWithRecipe = (input: { · 3 unmodified lines
298 actorAccountId?: AccountId | null;225 actorAccountId?: AccountId | null;
299}): Effect.Effect<LoadedSourceRecipe, Error, never> =>226}): Effect.Effect<LoadedSourceRecipe, Error, never> =>
300 Effect.gen(function* () {227 Effect.gen(function* () {
301 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();228 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(input.workspaceId);
302 if (
303 runtimeLocalWorkspace !== null
304 && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId
305 ) {
306 const source = yield* loadSourceById(input.rows, {
307 workspaceId: input.workspaceId,
308 sourceId: input.sourceId,
309 actorAccountId: input.actorAccountId,
310 });
311
312 const artifact = yield* Effect.tryPromise({
313 try: () =>
314 readLocalSourceArtifact({
315 context: runtimeLocalWorkspace.context,
316 sourceId: source.id,
317 }),
318 catch: (cause) =>
319 cause instanceof Error ? cause : new Error(String(cause)),
320 });
321 if (artifact === null) {
322 return yield* Effect.fail(
323 new Error(`Recipe artifact missing for source ${input.sourceId}`),
324 );
325 }
326
327 const sourceRecord: StoredSourceRecord = {
328 id: source.id,
329 workspaceId: source.workspaceId,
330 recipeId: artifact.recipeId,
331 recipeRevisionId: artifact.revision.id,
332 name: source.name,
333 kind: source.kind,
334 endpoint: source.endpoint,
335 status: source.status,
336 enabled: source.enabled,
337 namespace: source.namespace,
338 importAuthPolicy: source.importAuthPolicy,
339 bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
340 sourceHash: source.sourceHash,
341 lastError: source.lastError,
342 createdAt: source.createdAt,
343 updatedAt: source.updatedAt,
344 };
345 const manifest = yield* parseManifestForRecipe({
346 source,
347 revision: artifact.revision,
348 });
349
350 return {
351 source,
352 sourceRecord,
353 revision: artifact.revision,
354 documents: artifact.documents,
355 schemaBundles: artifact.schemaBundles,
356 operations: artifact.operations,
357 manifest,
358 } satisfies LoadedSourceRecipe;
359 }
360
361 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
362 input.workspaceId,
363 input.sourceId,
364 );
365 if (Option.isNone(sourceRecord)) {
366 return yield* Effect.fail(
367 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
368 );
369 }
370
371 const source = yield* loadSourceById(input.rows, {229 const source = yield* loadSourceById(input.rows, {
372 workspaceId: input.workspaceId,230 workspaceId: input.workspaceId,
373 sourceId: input.sourceId,231 sourceId: input.sourceId,
374 actorAccountId: input.actorAccountId,232 actorAccountId: input.actorAccountId,
375 });233 });
376 const revision = yield* input.rows.sourceRecipeRevisions.getById(sourceRecord.value.recipeRevisionId);234 const artifact = yield* Effect.tryPromise({
377 if (Option.isNone(revision)) {235 try: () =>
236 readLocalSourceArtifact({
237 context: runtimeLocalWorkspace.context,
238 sourceId: source.id,
239 }),
240 catch: (cause) =>
241 cause instanceof Error ? cause : new Error(String(cause)),
242 });
243 if (artifact === null) {
378 return yield* Effect.fail(244 return yield* Effect.fail(
379 new Error(`Recipe revision missing for source ${input.sourceId}`),245 new Error(`Recipe artifact missing for source ${input.sourceId}`),
380 );246 );
381 }247 }
382 const [documents, schemaBundles, operations, manifest] = yield* Effect.all([248
383 input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId),249 const sourceRecord: StoredSourceRecord = {
384 input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),250 id: source.id,
385 input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),251 workspaceId: source.workspaceId,
386 parseManifestForRecipe({252 recipeId: artifact.recipeId,
387 source,253 recipeRevisionId: artifact.revision.id,
388 revision: revision.value,254 name: source.name,
389 }),255 kind: source.kind,
390 ]);256 endpoint: source.endpoint,
257 status: source.status,
258 enabled: source.enabled,
259 namespace: source.namespace,
260 importAuthPolicy: source.importAuthPolicy,
261 bindingConfigJson: getSourceAdapterForSource(source).serializeBindingConfig(source),
262 sourceHash: source.sourceHash,
263 lastError: source.lastError,
264 createdAt: source.createdAt,
265 updatedAt: source.updatedAt,
266 };
267 const manifest = yield* parseManifestForRecipe({
268 source,
269 revision: artifact.revision,
270 });
391 271
392 return {272 return {
393 source,273 source,
394 sourceRecord: sourceRecord.value,274 sourceRecord,
395 revision: revision.value,275 revision: artifact.revision,
396 documents,276 documents: artifact.documents,
397 schemaBundles,277 schemaBundles: artifact.schemaBundles,
398 operations,278 operations: artifact.operations,
399 manifest,279 manifest,
400 } satisfies LoadedSourceRecipe;280 } satisfies LoadedSourceRecipe;
401 });281 });
packages/control-plane/src/runtime/source-store.test.ts+130−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,42 @@ 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* Effect.tryPromise({
60 try: () => resolveLocalWorkspaceContext({ workspaceRoot }),
61 catch: (cause) =>
62 cause instanceof Error ? cause : new Error(String(cause)),
63 });
64 const loadedConfig = yield* Effect.tryPromise({
65 try: () => loadLocalExecutorConfig(context),
66 catch: (cause) =>
67 cause instanceof Error ? cause : new Error(String(cause)),
68 });
69
70 return {
71 context,
72 installation: {
73 workspaceId: input.workspaceId,
74 accountId: input.accountId,
75 organizationId: "org_local_personal" as never,
76 },
77 loadedConfig,
78 } satisfies RuntimeLocalWorkspaceState;
79 });
80
81const withRuntimeLocalWorkspace = <A, E>(
82 effect: Effect.Effect<A, E, never>,
83 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
84) =>
85 effect.pipe(
86 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
87 );
88
43const makeOpenApiSource = (input: {89const makeOpenApiSource = (input: {
44 workspaceId: Source["workspaceId"];90 workspaceId: Source["workspaceId"];
45 sourceId: Source["id"];91 sourceId: Source["id"];
@@ -78,28 +124,13 @@ describe("source-store", () => { · 32 unmodified lines
78 const persistence = yield* makePersistence;124 const persistence = yield* makePersistence;
79 const now = Date.now();125 const now = Date.now();
80 const accountId = AccountIdSchema.make("acc_source_store");126 const accountId = AccountIdSchema.make("acc_source_store");
81 const organizationId = OrganizationIdSchema.make("org_source_store");
82 const workspaceId = WorkspaceIdSchema.make("ws_source_store");127 const workspaceId = WorkspaceIdSchema.make("ws_source_store");
83 const sourceId = SourceIdSchema.make("src_source_store");128 const sourceId = SourceIdSchema.make("github");
84 const firstTokenId = SecretMaterialIdSchema.make("sec_source_store_first");129 const firstTokenId = SecretMaterialIdSchema.make("sec_source_store_first");
85 const secondTokenId = SecretMaterialIdSchema.make("sec_source_store_second");130 const secondTokenId = SecretMaterialIdSchema.make("sec_source_store_second");
86 131 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({
87 yield* persistence.rows.organizations.insert({132 workspaceId,
88 id: organizationId,133 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 });134 });
104 135
105 yield* persistence.rows.secretMaterials.upsert({136 yield* persistence.rows.secretMaterials.upsert({
@@ -119,22 +150,25 @@ describe("source-store", () => { · 13 unmodified lines
119 updatedAt: now,150 updatedAt: now,
120 });151 });
121 152
122 yield* persistSource(153 yield* withRuntimeLocalWorkspace(
123 persistence.rows,154 persistSource(
124 makeOpenApiSource({155 persistence.rows,
125 workspaceId,156 makeOpenApiSource({
126 sourceId,157 workspaceId,
127 now,158 sourceId,
128 auth: {159 now,
129 kind: "bearer",160 auth: {
130 headerName: "Authorization",161 kind: "bearer",
131 prefix: "Bearer ",162 headerName: "Authorization",
132 token: {163 prefix: "Bearer ",
133 providerId: "postgres",164 token: {
134 handle: firstTokenId,165 providerId: "postgres",
166 handle: firstTokenId,
167 },
135 },168 },
136 },169 }),
137 }),170 ),
171 runtimeLocalWorkspace,
138 );172 );
139 173
140 yield* persistence.rows.sourceAuthSessions.upsert({174 yield* persistence.rows.sourceAuthSessions.upsert({
@@ -167,23 +201,26 @@ describe("source-store", () => { · 26 unmodified lines
167 updatedAt: now,201 updatedAt: now,
168 });202 });
169 203
170 yield* persistSource(204 yield* withRuntimeLocalWorkspace(
171 persistence.rows,205 persistSource(
172 makeOpenApiSource({206 persistence.rows,
173 workspaceId,207 makeOpenApiSource({
174 sourceId,208 workspaceId,
175 now,209 sourceId,
176 updatedAt: now + 1,210 now,
177 auth: {211 updatedAt: now + 1,
178 kind: "bearer",212 auth: {
179 headerName: "Authorization",213 kind: "bearer",
180 prefix: "Bearer ",214 headerName: "Authorization",
181 token: {215 prefix: "Bearer ",
182 providerId: "postgres",216 token: {
183 handle: secondTokenId,217 providerId: "postgres",
218 handle: secondTokenId,
219 },
184 },220 },
185 },221 }),
186 }),222 ),
223 runtimeLocalWorkspace,
187 );224 );
188 225
189 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true);226 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true);
@@ -196,16 +233,30 @@ describe("source-store", () => { · 6 unmodified lines
196 : null,233 : null,
197 ).toBe(secondTokenId);234 ).toBe(secondTokenId);
198 235
199 const removed = yield* removeSourceById(persistence.rows, {236 const removed = yield* withRuntimeLocalWorkspace(
200 workspaceId,237 removeSourceById(persistence.rows, {
201 sourceId,238 workspaceId,
202 });239 sourceId,
240 }),
241 runtimeLocalWorkspace,
242 );
203 expect(removed).toBe(true);243 expect(removed).toBe(true);
204 244
205 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);245 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);246 expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
208 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);247 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
248 const config = yield* Effect.tryPromise({
249 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),
250 catch: (cause) =>
251 cause instanceof Error ? cause : new Error(String(cause)),
252 });
253 expect(config.config?.sources?.[sourceId]).toBeUndefined();
254 const workspaceState = yield* Effect.tryPromise({
255 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
256 catch: (cause) =>
257 cause instanceof Error ? cause : new Error(String(cause)),
258 });
259 expect(workspaceState.sources[sourceId]).toBeUndefined();
209 }),260 }),
210 );261 );
211 262
@@ -214,27 +265,12 @@ describe("source-store", () => { · 2 unmodified lines
214 const persistence = yield* makePersistence;265 const persistence = yield* makePersistence;
215 const now = Date.now();266 const now = Date.now();
216 const accountId = AccountIdSchema.make("acc_actor_scoped");267 const accountId = AccountIdSchema.make("acc_actor_scoped");
217 const organizationId = OrganizationIdSchema.make("org_actor_scoped");
218 const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped");268 const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped");
219 const sourceId = SourceIdSchema.make("src_actor_scoped");269 const sourceId = SourceIdSchema.make("github");
220 const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared");270 const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared");
221 271 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspace({
222 yield* persistence.rows.organizations.insert({272 workspaceId,
223 id: organizationId,273 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 });274 });
239 yield* persistence.rows.secretMaterials.upsert({275 yield* persistence.rows.secretMaterials.upsert({
240 id: sharedTokenId,276 id: sharedTokenId,
@@ -260,16 +296,22 @@ describe("source-store", () => { · 19 unmodified lines
260 },296 },
261 });297 });
262 298
263 yield* persistSource(persistence.rows, source);299 yield* withRuntimeLocalWorkspace(
264 yield* persistSource(300 persistSource(persistence.rows, source),
265 persistence.rows,301 runtimeLocalWorkspace,
266 {302 );
267 ...source,303 yield* withRuntimeLocalWorkspace(
268 updatedAt: now + 1,304 persistSource(
269 },305 persistence.rows,
270 {306 {
271 actorAccountId: accountId,307 ...source,
272 },308 updatedAt: now + 1,
309 },
310 {
311 actorAccountId: accountId,
312 },
313 ),
314 runtimeLocalWorkspace,
273 );315 );
274 316
275 const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({317 const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
@@ -282,264 +324,4 @@ describe("source-store", () => { · 6 unmodified lines
282 expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2);324 expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2);
283 }),325 }),
284 );326 );
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});327});
packages/control-plane/src/runtime/source-store.ts+178−428
@@ -6,26 +6,23 @@ import type { · 5 unmodified lines
6 LocalConfigSource,6 LocalConfigSource,
7 Source,7 Source,
8 SourceId,8 SourceId,
9 SourceRecipeId,
10 SourceRecipeRevisionId,
11 WorkspaceId,9 WorkspaceId,
12} from "#schema";10} from "#schema";
13import { SourceIdSchema } from "#schema";11import { SourceIdSchema } from "#schema";
14import { type SqlControlPlaneRows } from "#persistence";12import { type SqlControlPlaneRows } from "#persistence";
15import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";
17 14
18import {15import {
19 createSourceRecipeRecord,
20 createSourceRecipeRevisionRecord,
21 projectSourceFromStorage,
22 projectSourcesFromStorage,
23 stableSourceRecipeId,16 stableSourceRecipeId,
24 stableSourceRecipeRevisionId,17 stableSourceRecipeRevisionId,
25 splitSourceForStorage,18 splitSourceForStorage,
26} from "./source-definitions";19} from "./source-definitions";
20import {
21 sourceAuthFromAuthArtifact,
22} from "./auth-artifacts";
27import {23import {
28 loadLocalExecutorConfig,24 loadLocalExecutorConfig,
25 type LoadedLocalExecutorConfig,
29 type ResolvedLocalWorkspaceContext,26 type ResolvedLocalWorkspaceContext,
30 writeProjectLocalExecutorConfig,27 writeProjectLocalExecutorConfig,
31} from "./local-config";28} from "./local-config";
@@ -34,7 +31,7 @@ import { · 2 unmodified lines
34 removeLocalSourceArtifact,31 removeLocalSourceArtifact,
35} from "./local-source-artifacts";32} from "./local-source-artifacts";
36import {33import {
37 getRuntimeLocalWorkspaceOption,34 requireRuntimeLocalWorkspace,
38} from "./local-runtime-context";35} from "./local-runtime-context";
39import {36import {
40 loadLocalWorkspaceState,37 loadLocalWorkspaceState,
@@ -136,7 +133,7 @@ const deriveLocalSourceId = ( · 95 unmodified lines
136 return SourceIdSchema.make(candidate);133 return SourceIdSchema.make(candidate);
137};134};
138 135
139const resolveLocalConfigSecretProviderAlias = (config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"]): string | null => {136const resolveLocalConfigSecretProviderAlias = (config: LoadedLocalExecutorConfig["config"]): string | null => {
140 const defaultAlias = trimOrNull(config?.secrets?.defaults?.env);137 const defaultAlias = trimOrNull(config?.secrets?.defaults?.env);
141 if (defaultAlias !== null && config?.secrets?.providers?.[defaultAlias]) {138 if (defaultAlias !== null && config?.secrets?.providers?.[defaultAlias]) {
142 return defaultAlias;139 return defaultAlias;
@@ -147,7 +144,7 @@ const resolveLocalConfigSecretProviderAlias = (config: Awaited<ReturnType<typeof · 4 unmodified lines
147 144
148const sourceAuthFromConfigInput = (input: {145const sourceAuthFromConfigInput = (input: {
149 auth: unknown;146 auth: unknown;
150 config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"];147 config: LoadedLocalExecutorConfig["config"];
151 existing: Source["auth"] | null;148 existing: Source["auth"] | null;
152}): Source["auth"] => {149}): Source["auth"] => {
153 if (input.auth === undefined) {150 if (input.auth === undefined) {
@@ -175,9 +172,11 @@ const sourceAuthFromConfigInput = (input: { · 21 unmodified lines
175 };172 };
176 const providerAlias = trimOrNull(explicit.provider);173 const providerAlias = trimOrNull(explicit.provider);
177 const providerId = providerAlias174 const providerId = providerAlias
178 ? toConfigSecretProviderId(providerAlias)175 ? (providerAlias === "params" ? "params" : toConfigSecretProviderId(providerAlias))
179 : explicit.source === "env"176 : explicit.source === "env"
180 ? "env"177 ? "env"
178 : explicit.source === "params"
179 ? "params"
181 : null;180 : null;
182 const handle = trimOrNull(explicit.id);181 const handle = trimOrNull(explicit.id);
183 if (providerId && handle) {182 if (providerId && handle) {
@@ -199,7 +198,7 @@ const sourceAuthFromConfigInput = (input: { · 15 unmodified lines
199const configAuthFromSource = (input: {198const configAuthFromSource = (input: {
200 source: Source;199 source: Source;
201 existingConfigAuth: LocalConfigSecretInput | undefined;200 existingConfigAuth: LocalConfigSecretInput | undefined;
202 config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"];201 config: LoadedLocalExecutorConfig["config"];
203}): LocalConfigSecretInput | undefined => {202}): LocalConfigSecretInput | undefined => {
204 if (input.source.auth.kind !== "bearer") {203 if (input.source.auth.kind !== "bearer") {
205 return input.existingConfigAuth;204 return input.existingConfigAuth;
@@ -209,6 +208,14 @@ const configAuthFromSource = (input: { · 3 unmodified lines
209 return input.source.auth.token.handle;208 return input.source.auth.token.handle;
210 }209 }
211 210
211 if (input.source.auth.token.providerId === "params") {
212 return {
213 source: "params",
214 provider: "params",
215 id: input.source.auth.token.handle,
216 };
217 }
218
212 const provider = fromConfigSecretProviderId(input.source.auth.token.providerId);219 const provider = fromConfigSecretProviderId(input.source.auth.token.providerId);
213 if (provider !== null) {220 if (provider !== null) {
214 const configuredProvider = input.config?.secrets?.providers?.[provider];221 const configuredProvider = input.config?.secrets?.providers?.[provider];
@@ -226,11 +233,7 @@ const configAuthFromSource = (input: { · 11 unmodified lines
226 233
227const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId) =>234const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId) =>
228 Effect.gen(function* () {235 Effect.gen(function* () {
229 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();236 const runtimeLocalWorkspace = yield* requireRuntimeLocalWorkspace(workspaceId);
230 if (runtimeLocalWorkspace === null || runtimeLocalWorkspace.installation.workspaceId !== workspaceId) {
231 return null;
232 }
233
234 const loadedConfig = yield* Effect.tryPromise({237 const loadedConfig = yield* Effect.tryPromise({
235 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),238 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),
236 catch: (cause) =>239 catch: (cause) =>
@@ -253,7 +256,7 @@ const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId) => · 16 unmodified lines
253const buildLocalSourceRecord = (input: {256const buildLocalSourceRecord = (input: {
254 context: ResolvedLocalWorkspaceContext;257 context: ResolvedLocalWorkspaceContext;
255 workspaceId: WorkspaceId;258 workspaceId: WorkspaceId;
256 loadedConfig: Awaited<ReturnType<typeof loadLocalExecutorConfig>>;259 loadedConfig: LoadedLocalExecutorConfig;
257 workspaceState: LocalWorkspaceState;260 workspaceState: LocalWorkspaceState;
258 sourceId: SourceId;261 sourceId: SourceId;
259 actorAccountId?: AccountId | null;262 actorAccountId?: AccountId | null;
@@ -335,11 +338,23 @@ const buildLocalSourceRecord = (input: { · 75 unmodified lines
335 updatedAt: baseSource.updatedAt,338 updatedAt: baseSource.updatedAt,
336 };339 };
337 340
338 const source = yield* projectSourceFromStorage({341 const source: Source = {
339 sourceRecord,342 ...baseSource,
340 runtimeAuthArtifact,343 auth:
341 importAuthArtifact,344 runtimeAuthArtifact === null
342 });345 ? baseSource.auth
346 : sourceAuthFromAuthArtifact(runtimeAuthArtifact),
347 importAuth:
348 baseSource.importAuthPolicy === "separate"
349 ? importAuthArtifact === null
350 ? baseSource.importAuth
351 : sourceAuthFromAuthArtifact(importAuthArtifact)
352 : { kind: "none" },
353 sourceHash: sourceRecord.sourceHash,
354 lastError: sourceRecord.lastError,
355 createdAt: sourceRecord.createdAt,
356 updatedAt: sourceRecord.updatedAt,
357 };
343 358
344 return {359 return {
345 source,360 source,
@@ -356,49 +371,23 @@ export const loadSourcesInWorkspace = ( · 10 unmodified lines
356) =>371) =>
357 Effect.gen(function* () {372 Effect.gen(function* () {
358 const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId);373 const localWorkspace = yield* resolveRuntimeLocalWorkspace(workspaceId);
359 if (localWorkspace !== null) {
360 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
361 return yield* Effect.forEach(
362 Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}),
363 (sourceId) =>
364 Effect.map(
365 buildLocalSourceRecord({
366 context: localWorkspace.context,
367 workspaceId,
368 loadedConfig: localWorkspace.loadedConfig,
369 workspaceState: localWorkspace.workspaceState,
370 sourceId: SourceIdSchema.make(sourceId),
371 actorAccountId: options.actorAccountId,
372 authArtifacts,
373 }),
374 ({ source }) => source,
375 ),
376 );
377 }
378
379 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
380 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);374 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
381 const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => {375 return yield* Effect.forEach(
382 const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id);376 Object.keys(localWorkspace.loadedConfig.config?.sources ?? {}),
383 const preferred = selectPreferredAuthArtifact({377 (sourceId) =>
384 authArtifacts: matches,378 Effect.map(
385 actorAccountId: options.actorAccountId,379 buildLocalSourceRecord({
386 slot: "runtime",380 context: localWorkspace.context,
387 });381 workspaceId,
388 const preferredImport = selectPreferredAuthArtifact({382 loadedConfig: localWorkspace.loadedConfig,
389 authArtifacts: matches,383 workspaceState: localWorkspace.workspaceState,
390 actorAccountId: options.actorAccountId,384 sourceId: SourceIdSchema.make(sourceId),
391 slot: "import",385 actorAccountId: options.actorAccountId,
392 });386 authArtifacts,
393 return [preferred, preferredImport].filter(387 }),
394 (artifact): artifact is AuthArtifact => artifact !== null,388 ({ source }) => source,
395 );389 ),
396 });390 );
397
398 return yield* projectSourcesFromStorage({
399 sourceRecords,
400 authArtifacts: filteredAuthArtifacts,
401 });
402 });391 });
403 392
404export const loadSourceById = (rows: SqlControlPlaneRows, input: {393export const loadSourceById = (rows: SqlControlPlaneRows, input: {
@@ -408,64 +397,30 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 3 unmodified lines
408}) =>397}) =>
409 Effect.gen(function* () {398 Effect.gen(function* () {
410 const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);399 const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);
411 if (localWorkspace !== null) {400 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId);
412 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(input.workspaceId);401 if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) {
413 if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) {
414 return yield* Effect.fail(
415 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
416 );
417 }
418
419 const localSource = yield* buildLocalSourceRecord({
420 context: localWorkspace.context,
421 workspaceId: input.workspaceId,
422 loadedConfig: localWorkspace.loadedConfig,
423 workspaceState: localWorkspace.workspaceState,
424 sourceId: input.sourceId,
425 actorAccountId: input.actorAccountId,
426 authArtifacts,
427 });
428
429 return localSource.source;
430 }
431
432 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
433 input.workspaceId,
434 input.sourceId,
435 );
436
437 if (Option.isNone(sourceRecord)) {
438 return yield* Effect.fail(402 return yield* Effect.fail(
439 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),403 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
440 );404 );
441 }405 }
442 406
443 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({407 const localSource = yield* buildLocalSourceRecord({
408 context: localWorkspace.context,
444 workspaceId: input.workspaceId,409 workspaceId: input.workspaceId,
410 loadedConfig: localWorkspace.loadedConfig,
411 workspaceState: localWorkspace.workspaceState,
445 sourceId: input.sourceId,412 sourceId: input.sourceId,
446 });
447 const authArtifact = selectPreferredAuthArtifact({
448 authArtifacts,
449 actorAccountId: input.actorAccountId,413 actorAccountId: input.actorAccountId,
450 slot: "runtime",
451 });
452 const importAuthArtifact = selectPreferredAuthArtifact({
453 authArtifacts,414 authArtifacts,
454 actorAccountId: input.actorAccountId,
455 slot: "import",
456 });415 });
457 416
458 return yield* projectSourceFromStorage({417 return localSource.source;
459 sourceRecord: sourceRecord.value,
460 runtimeAuthArtifact: authArtifact,
461 importAuthArtifact,
462 });
463 });418 });
464 419
465const configSourceFromLocalSource = (input: {420const configSourceFromLocalSource = (input: {
466 source: Source;421 source: Source;
467 existingConfigAuth: LocalConfigSecretInput | undefined;422 existingConfigAuth: LocalConfigSecretInput | undefined;
468 config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"];423 config: LoadedLocalExecutorConfig["config"];
469}): LocalConfigSource => {424}): LocalConfigSource => {
470 const auth = configAuthFromSource({425 const auth = configAuthFromSource({
471 source: input.source,426 source: input.source,
@@ -554,113 +509,61 @@ const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { · 82 unmodified lines
554 return existingAuthArtifacts.length;509 return existingAuthArtifacts.length;
555 });510 });
556 511
557const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: {
558 recipeId: SourceRecipeId;
559 recipeRevisionId: SourceRecipeRevisionId;
560}) =>
561 Effect.gen(function* () {
562 const revisionReferenceCount = yield* rows.sources.countByRecipeRevisionId(
563 input.recipeRevisionId,
564 );
565 if (revisionReferenceCount === 0) {
566 yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);
567 yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId);
568 yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);
569 }
570
571 const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId);
572 if (recipeReferenceCount > 0) {
573 return;
574 }
575
576 const recipeRevisions = yield* rows.sourceRecipeRevisions.listByRecipeId(input.recipeId);
577 yield* Effect.forEach(
578 recipeRevisions,
579 (recipeRevision) =>
580 Effect.all([
581 rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id),
582 rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id),
583 rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id),
584 ]),
585 { discard: true },
586 );
587 yield* rows.sourceRecipeRevisions.removeByRecipeId(input.recipeId);
588 yield* rows.sourceRecipes.removeById(input.recipeId);
589 });
590
591export const removeSourceById = (rows: SqlControlPlaneRows, input: {512export const removeSourceById = (rows: SqlControlPlaneRows, input: {
592 workspaceId: WorkspaceId;513 workspaceId: WorkspaceId;
593 sourceId: Source["id"];514 sourceId: Source["id"];
594}) =>515}) =>
595 Effect.gen(function* () {516 Effect.gen(function* () {
596 const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);517 const localWorkspace = yield* resolveRuntimeLocalWorkspace(input.workspaceId);
597 if (localWorkspace !== null) {518 if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) {
598 if (!localWorkspace.loadedConfig.config?.sources?.[input.sourceId]) {
599 return false;
600 }
601
602 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
603 const sources = {
604 ...(projectConfig.sources ?? {}),
605 };
606 delete sources[input.sourceId];
607 yield* Effect.tryPromise({
608 try: () =>
609 writeProjectLocalExecutorConfig({
610 context: localWorkspace.context,
611 config: {
612 ...projectConfig,
613 sources,
614 },
615 }),
616 catch: (cause) =>
617 cause instanceof Error ? cause : new Error(String(cause)),
618 });
619
620 const {
621 [input.sourceId]: _removedSource,
622 ...remainingSources
623 } = localWorkspace.workspaceState.sources;
624 const workspaceState: LocalWorkspaceState = {
625 ...localWorkspace.workspaceState,
626 sources: remainingSources,
627 };
628 yield* Effect.tryPromise({
629 try: () =>
630 writeLocalWorkspaceState({
631 context: localWorkspace.context,
632 state: workspaceState,
633 }),
634 catch: (cause) =>
635 cause instanceof Error ? cause : new Error(String(cause)),
636 });
637 yield* Effect.tryPromise({
638 try: () =>
639 removeLocalSourceArtifact({
640 context: localWorkspace.context,
641 sourceId: input.sourceId,
642 }),
643 catch: (cause) =>
644 cause instanceof Error ? cause : new Error(String(cause)),
645 });
646
647 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
648 input.workspaceId,
649 input.sourceId,
650 );
651 yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({
652 workspaceId: input.workspaceId,
653 sourceId: input.sourceId,
654 });
655 yield* removeAuthArtifactsForSource(rows, input);
656 return true;
657 }
658
659 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId);
660 if (Option.isNone(sourceRecord)) {
661 return false;519 return false;
662 }520 }
663 521
522 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
523 const sources = {
524 ...(projectConfig.sources ?? {}),
525 };
526 delete sources[input.sourceId];
527 yield* Effect.tryPromise({
528 try: () =>
529 writeProjectLocalExecutorConfig({
530 context: localWorkspace.context,
531 config: {
532 ...projectConfig,
533 sources,
534 },
535 }),
536 catch: (cause) =>
537 cause instanceof Error ? cause : new Error(String(cause)),
538 });
539
540 const {
541 [input.sourceId]: _removedSource,
542 ...remainingSources
543 } = localWorkspace.workspaceState.sources;
544 const workspaceState: LocalWorkspaceState = {
545 ...localWorkspace.workspaceState,
546 sources: remainingSources,
547 };
548 yield* Effect.tryPromise({
549 try: () =>
550 writeLocalWorkspaceState({
551 context: localWorkspace.context,
552 state: workspaceState,
553 }),
554 catch: (cause) =>
555 cause instanceof Error ? cause : new Error(String(cause)),
556 });
557 yield* Effect.tryPromise({
558 try: () =>
559 removeLocalSourceArtifact({
560 context: localWorkspace.context,
561 sourceId: input.sourceId,
562 }),
563 catch: (cause) =>
564 cause instanceof Error ? cause : new Error(String(cause)),
565 });
566
664 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(567 yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
665 input.workspaceId,568 input.workspaceId,
666 input.sourceId,569 input.sourceId,
@@ -670,15 +573,6 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 3 unmodified lines
670 sourceId: input.sourceId,573 sourceId: input.sourceId,
671 });574 });
672 yield* removeAuthArtifactsForSource(rows, input);575 yield* removeAuthArtifactsForSource(rows, input);
673 const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);
674 if (!removed) {
675 return false;
676 }
677
678 yield* cleanupOrphanedRecipeData(rows, {
679 recipeId: sourceRecord.value.recipeId,
680 recipeRevisionId: sourceRecord.value.recipeRevisionId,
681 });
682 576
683 return true;577 return true;
684 });578 });
@@ -692,157 +586,20 @@ export const persistSource = ( · 7 unmodified lines
692) =>586) =>
693 Effect.gen(function* () {587 Effect.gen(function* () {
694 const localWorkspace = yield* resolveRuntimeLocalWorkspace(source.workspaceId);588 const localWorkspace = yield* resolveRuntimeLocalWorkspace(source.workspaceId);
695 if (localWorkspace !== null) {589 const nextSource = {
696 const nextSource = {590 ...source,
697 ...source,591 id:
698 id:592 localWorkspace.loadedConfig.config?.sources?.[source.id]
699 localWorkspace.loadedConfig.config?.sources?.[source.id]593 || localWorkspace.workspaceState.sources[source.id]
700 || localWorkspace.workspaceState.sources[source.id]594 ? source.id
701 ? source.id595 : deriveLocalSourceId(
702 : deriveLocalSourceId(596 source,
703 source,597 new Set(Object.keys(localWorkspace.loadedConfig.config?.sources ?? {})),
704 new Set(Object.keys(localWorkspace.loadedConfig.config?.sources ?? {})),598 ),
705 ),599 } satisfies Source;
706 } satisfies Source;
707 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
708 workspaceId: nextSource.workspaceId,
709 sourceId: nextSource.id,
710 });
711 const existingRuntimeAuthArtifact = selectExactAuthArtifact({
712 authArtifacts: existingAuthArtifacts,
713 actorAccountId: options.actorAccountId,
714 slot: "runtime",
715 });
716 const existingImportAuthArtifact = selectExactAuthArtifact({
717 authArtifacts: existingAuthArtifacts,
718 actorAccountId: options.actorAccountId,
719 slot: "import",
720 });
721 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
722 const sources = {
723 ...(projectConfig.sources ?? {}),
724 };
725 const existingConfigSource = sources[nextSource.id];
726 sources[nextSource.id] = configSourceFromLocalSource({
727 source: nextSource,
728 existingConfigAuth: existingConfigSource?.connection.auth,
729 config: localWorkspace.loadedConfig.config,
730 });
731 yield* Effect.tryPromise({
732 try: () =>
733 writeProjectLocalExecutorConfig({
734 context: localWorkspace.context,
735 config: {
736 ...projectConfig,
737 sources,
738 },
739 }),
740 catch: (cause) =>
741 cause instanceof Error ? cause : new Error(String(cause)),
742 });
743
744 const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
745 source: nextSource,
746 recipeId: stableSourceRecipeId(nextSource),
747 recipeRevisionId: stableSourceRecipeRevisionId(nextSource),
748 actorAccountId: options.actorAccountId,
749 existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,
750 existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,
751 });
752
753 if (runtimeAuthArtifact === null) {
754 if (existingRuntimeAuthArtifact !== null) {
755 yield* removeAuthLeaseAndSecrets(rows, {
756 authArtifactId: existingRuntimeAuthArtifact.id,
757 });
758 }
759 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
760 workspaceId: nextSource.workspaceId,
761 sourceId: nextSource.id,
762 actorAccountId: options.actorAccountId ?? null,
763 slot: "runtime",
764 });
765 } else {
766 yield* rows.authArtifacts.upsert(runtimeAuthArtifact);
767 if (
768 existingRuntimeAuthArtifact !== null
769 && existingRuntimeAuthArtifact.id !== runtimeAuthArtifact.id
770 ) {
771 yield* removeAuthLeaseAndSecrets(rows, {
772 authArtifactId: existingRuntimeAuthArtifact.id,
773 });
774 }
775 }
776
777 yield* cleanupAuthArtifactSecretRefs(rows, {
778 previous: existingRuntimeAuthArtifact ?? null,
779 next: runtimeAuthArtifact,
780 });
781
782 if (importAuthArtifact === null) {
783 if (existingImportAuthArtifact !== null) {
784 yield* removeAuthLeaseAndSecrets(rows, {
785 authArtifactId: existingImportAuthArtifact.id,
786 });
787 }
788 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
789 workspaceId: nextSource.workspaceId,
790 sourceId: nextSource.id,
791 actorAccountId: options.actorAccountId ?? null,
792 slot: "import",
793 });
794 } else {
795 yield* rows.authArtifacts.upsert(importAuthArtifact);
796 if (
797 existingImportAuthArtifact !== null
798 && existingImportAuthArtifact.id !== importAuthArtifact.id
799 ) {
800 yield* removeAuthLeaseAndSecrets(rows, {
801 authArtifactId: existingImportAuthArtifact.id,
802 });
803 }
804 }
805
806 yield* cleanupAuthArtifactSecretRefs(rows, {
807 previous: existingImportAuthArtifact ?? null,
808 next: importAuthArtifact,
809 });
810
811 const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id];
812 const workspaceState: LocalWorkspaceState = {
813 ...localWorkspace.workspaceState,
814 sources: {
815 ...localWorkspace.workspaceState.sources,
816 [nextSource.id]: {
817 status: nextSource.status,
818 lastError: nextSource.lastError,
819 sourceHash: nextSource.sourceHash,
820 createdAt: existingSourceState?.createdAt ?? nextSource.createdAt,
821 updatedAt: nextSource.updatedAt,
822 },
823 },
824 };
825 yield* Effect.tryPromise({
826 try: () =>
827 writeLocalWorkspaceState({
828 context: localWorkspace.context,
829 state: workspaceState,
830 }),
831 catch: (cause) =>
832 cause instanceof Error ? cause : new Error(String(cause)),
833 });
834
835 return yield* loadSourceById(rows, {
836 workspaceId: source.workspaceId,
837 sourceId: nextSource.id,
838 actorAccountId: options.actorAccountId,
839 });
840 }
841
842 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
843 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({600 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
844 workspaceId: source.workspaceId,601 workspaceId: nextSource.workspaceId,
845 sourceId: source.id,602 sourceId: nextSource.id,
846 });603 });
847 const existingRuntimeAuthArtifact = selectExactAuthArtifact({604 const existingRuntimeAuthArtifact = selectExactAuthArtifact({
848 authArtifacts: existingAuthArtifacts,605 authArtifacts: existingAuthArtifacts,
@@ -854,73 +611,38 @@ export const persistSource = ( · 5 unmodified lines
854 actorAccountId: options.actorAccountId,611 actorAccountId: options.actorAccountId,
855 slot: "import",612 slot: "import",
856 });613 });
857 614 const projectConfig = cloneJson(localWorkspace.loadedConfig.projectConfig ?? {});
858 const nextRecipeId = stableSourceRecipeId(source);615 const sources = {
859 const nextRecipeRevisionId = Option.isSome(existing) && existing.value.recipeId === nextRecipeId616 ...(projectConfig.sources ?? {}),
860 ? existing.value.recipeRevisionId617 };
861 : stableSourceRecipeRevisionId(source);618 const existingConfigSource = sources[nextSource.id];
862 const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);619 sources[nextSource.id] = configSourceFromLocalSource({
863 const nextRevision = createSourceRecipeRevisionRecord({620 source: nextSource,
864 source,621 existingConfigAuth: existingConfigSource?.connection.auth,
865 recipeId: nextRecipeId,622 config: localWorkspace.loadedConfig.config,
866 recipeRevisionId: nextRecipeRevisionId,
867 revisionNumber: Option.isSome(existingTargetRevision)
868 ? existingTargetRevision.value.revisionNumber
869 : 1,
870 manifestJson: Option.isSome(existingTargetRevision)
871 ? existingTargetRevision.value.manifestJson
872 : null,
873 manifestHash: Option.isSome(existingTargetRevision)
874 ? existingTargetRevision.value.manifestHash
875 : null,
876 materializationHash: Option.isSome(existingTargetRevision)
877 ? existingTargetRevision.value.materializationHash
878 : null,
879 });623 });
880 624 yield* Effect.tryPromise({
881 const nextRecipe = createSourceRecipeRecord({625 try: () =>
882 source,626 writeProjectLocalExecutorConfig({
883 recipeId: nextRecipeId,627 context: localWorkspace.context,
884 latestRevisionId: nextRevision.id,628 config: {
629 ...projectConfig,
630 sources,
631 },
632 }),
633 catch: (cause) =>
634 cause instanceof Error ? cause : new Error(String(cause)),
885 });635 });
886 636
887 const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({637 const { runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
888 source,638 source: nextSource,
889 recipeId: nextRecipe.id,639 recipeId: stableSourceRecipeId(nextSource),
890 recipeRevisionId: nextRevision.id,640 recipeRevisionId: stableSourceRecipeRevisionId(nextSource),
891 actorAccountId: options.actorAccountId,641 actorAccountId: options.actorAccountId,
892 existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,642 existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,
893 existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,643 existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,
894 });644 });
895 645
896 if (Option.isNone(existing)) {
897 yield* rows.sources.insert(sourceRecord);
898 } else {
899 const {
900 id: _id,
901 workspaceId: _workspaceId,
902 createdAt: _createdAt,
903 ...patch
904 } = sourceRecord;
905 yield* rows.sources.update(source.workspaceId, source.id, patch);
906 }
907
908 yield* rows.sourceRecipes.upsert(nextRecipe);
909 yield* rows.sourceRecipeRevisions.upsert(nextRevision);
910
911 if (
912 Option.isSome(existing)
913 && (
914 existing.value.recipeId !== nextRecipeId
915 || existing.value.recipeRevisionId !== nextRecipeRevisionId
916 )
917 ) {
918 yield* cleanupOrphanedRecipeData(rows, {
919 recipeId: existing.value.recipeId,
920 recipeRevisionId: existing.value.recipeRevisionId,
921 });
922 }
923
924 if (runtimeAuthArtifact === null) {646 if (runtimeAuthArtifact === null) {
925 if (existingRuntimeAuthArtifact !== null) {647 if (existingRuntimeAuthArtifact !== null) {
926 yield* removeAuthLeaseAndSecrets(rows, {648 yield* removeAuthLeaseAndSecrets(rows, {
@@ -928,8 +650,8 @@ export const persistSource = ( · 1 unmodified line
928 });650 });
929 }651 }
930 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({652 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
931 workspaceId: source.workspaceId,653 workspaceId: nextSource.workspaceId,
932 sourceId: source.id,654 sourceId: nextSource.id,
933 actorAccountId: options.actorAccountId ?? null,655 actorAccountId: options.actorAccountId ?? null,
934 slot: "runtime",656 slot: "runtime",
935 });657 });
@@ -957,8 +679,8 @@ export const persistSource = ( · 21 unmodified lines
957 });679 });
958 }680 }
959 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({681 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
960 workspaceId: source.workspaceId,682 workspaceId: nextSource.workspaceId,
961 sourceId: source.id,683 sourceId: nextSource.id,
962 actorAccountId: options.actorAccountId ?? null,684 actorAccountId: options.actorAccountId ?? null,
963 slot: "import",685 slot: "import",
964 });686 });
@@ -979,5 +701,33 @@ export const persistSource = ( · 14 unmodified lines
979 next: importAuthArtifact,701 next: importAuthArtifact,
980 });702 });
981 703
982 return source;704 const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id];
705 const workspaceState: LocalWorkspaceState = {
706 ...localWorkspace.workspaceState,
707 sources: {
708 ...localWorkspace.workspaceState.sources,
709 [nextSource.id]: {
710 status: nextSource.status,
711 lastError: nextSource.lastError,
712 sourceHash: nextSource.sourceHash,
713 createdAt: existingSourceState?.createdAt ?? nextSource.createdAt,
714 updatedAt: nextSource.updatedAt,
715 },
716 },
717 };
718 yield* Effect.tryPromise({
719 try: () =>
720 writeLocalWorkspaceState({
721 context: localWorkspace.context,
722 state: workspaceState,
723 }),
724 catch: (cause) =>
725 cause instanceof Error ? cause : new Error(String(cause)),
726 });
727
728 return yield* loadSourceById(rows, {
729 workspaceId: nextSource.workspaceId,
730 sourceId: nextSource.id,
731 actorAccountId: options.actorAccountId,
732 });
983 });733 });
packages/control-plane/src/runtime/test-http-client.ts+3−3
@@ -15,10 +15,10 @@ import { · 14 unmodified lines
15 15
16import {16import {
17 ControlPlaneAuthHeaders,17 ControlPlaneAuthHeaders,
18 type SqlControlPlaneRuntime,18 type ControlPlaneRuntime,
19} from "./index";19} from "./index";
20 20
21const createClientLayer = (runtime: SqlControlPlaneRuntime) => {21const createClientLayer = (runtime: ControlPlaneRuntime) => {
22 const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer);22 const apiLayer = createControlPlaneApiLayer(runtime.runtimeLayer);
23 23
24 return HttpApiBuilder.serve().pipe(24 return HttpApiBuilder.serve().pipe(
@@ -48,7 +48,7 @@ type ControlPlaneClient = Effect.Effect.Success< · 23 unmodified lines
48 48
49export const withControlPlaneClient = <A, E>(49export const withControlPlaneClient = <A, E>(
50 input: {50 input: {
51 runtime: SqlControlPlaneRuntime;51 runtime: ControlPlaneRuntime;
52 accountId?: string;52 accountId?: string;
53 },53 },
54 f: (client: ControlPlaneClient) => Effect.Effect<A, E, never>,54 f: (client: ControlPlaneClient) => Effect.Effect<A, E, never>,
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+377−196
@@ -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,85 @@ 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* Effect.tryPromise({
614 yield* persistMcpRecipeMaterializationFromManifest({635 try: () =>
615 rows: input.persistence.rows,636 writeProjectLocalExecutorConfig({
616 source,637 context: input.runtimeLocalWorkspace.context,
617 manifestEntries: [638 config: {
618 {639 sources: {
619 toolId: "echo",640 [input.sourceId]: {
620 toolName: "echo",641 kind: "mcp",
621 description: "Echo the provided string",642 name: source.name,
622 inputSchemaJson: JSON.stringify({643 ...(source.namespace ? { namespace: source.namespace } : {}),
623 type: "object",644 connection: {
624 properties: {645 endpoint: source.endpoint,
625 value: {646 },
626 type: "string",647 binding: {
648 transport: "streamable-http",
649 },
627 },650 },
628 },651 },
629 required: ["value"],652 },
630 additionalProperties: false,653 }),
654 catch: (cause) =>
655 cause instanceof Error ? cause : new Error(String(cause)),
656 });
657 yield* Effect.tryPromise({
658 try: () =>
659 writeLocalSourceArtifact({
660 context: input.runtimeLocalWorkspace.context,
661 sourceId: source.id,
662 artifact: buildLocalSourceArtifact({
663 source,
664 materialization: materializationFromMcpManifestEntries({
665 recipeRevisionId: "src_recipe_rev_materialization" as never,
666 endpoint: source.endpoint,
667 manifestEntries: [
668 {
669 toolId: "echo",
670 toolName: "echo",
671 description: "Echo the provided string",
672 inputSchemaJson: JSON.stringify({
673 type: "object",
674 properties: {
675 value: { type: "string" },
676 },
677 required: ["value"],
678 additionalProperties: false,
679 }),
680 },
681 ],
682 }),
631 }),683 }),
632 },684 }),
633 ],685 catch: (cause) =>
686 cause instanceof Error ? cause : new Error(String(cause)),
634 });687 });
635 });688 yield* Effect.tryPromise({
689 try: () =>
690 writeLocalWorkspaceState({
691 context: input.runtimeLocalWorkspace.context,
692 state: {
693 version: 1,
694 sources: {
695 [input.sourceId]: {
696 status: "connected",
697 lastError: null,
698 sourceHash: null,
699 createdAt: now,
700 updatedAt: now,
701 },
702 },
703 policies: {},
704 },
705 }),
706 catch: (cause) =>
707 cause instanceof Error ? cause : new Error(String(cause)),
708 });
709 }) as Effect.Effect<void, Error, never>;
636 710
637const persistConnectedGithubOpenApiSource = (input: {711const persistConnectedGithubOpenApiSource = (input: {
638 persistence: SqlControlPlanePersistence;712 runtimeLocalWorkspace: RuntimeLocalWorkspaceState;
639 workspaceId: WorkspaceId;713 workspaceId: WorkspaceId;
640 sourceId: SourceId;714 sourceId: SourceId;
641 endpoint?: string;715 endpoint?: string;
@@ -831,7 +905,22 @@ const persistConnectedGithubOpenApiSource = (input: { · 189 unmodified lines
831 updatedAt: now,905 updatedAt: now,
832 };906 };
833 907
834 yield* persistSource(input.persistence.rows, source);908 const configAuth =
909 source.auth.kind === "bearer"
910 ? source.auth.token.providerId === "params"
911 ? {
912 source: "params" as const,
913 provider: "params",
914 id: source.auth.token.handle,
915 }
916 : source.auth.token.providerId === "env"
917 ? source.auth.token.handle
918 : {
919 source: "env" as const,
920 provider: "env",
921 id: source.auth.token.handle,
922 }
923 : undefined;
835 924
836 const manifest = yield* extractOpenApiManifest(925 const manifest = yield* extractOpenApiManifest(
837 source.name,926 source.name,
@@ -842,131 +931,189 @@ const persistConnectedGithubOpenApiSource = (input: { · 4 unmodified lines
842 ),931 ),
843 );932 );
844 const definitions = compileOpenApiToolDefinitions(manifest);933 const definitions = compileOpenApiToolDefinitions(manifest);
845 const sourceRecord =934 yield* Effect.tryPromise({
846 yield* input.persistence.rows.sources.getByWorkspaceAndId(935 try: () =>
847 input.workspaceId,936 writeProjectLocalExecutorConfig({
848 input.sourceId,937 context: input.runtimeLocalWorkspace.context,
849 );938 config: {
850 if (Option.isNone(sourceRecord)) {939 sources: {
851 return yield* Effect.fail(940 [input.sourceId]: {
852 new Error(`Missing stored source ${input.sourceId}`),941 kind: "openapi",
853 );942 name: source.name,
854 }943 ...(source.namespace ? { namespace: source.namespace } : {}),
855 944 connection: {
856 yield* input.persistence.rows.sourceRecipeRevisions.update(945 endpoint: source.endpoint,
857 sourceRecord.value.recipeRevisionId,946 ...(configAuth !== undefined ? { auth: configAuth } : {}),
858 {947 },
859 manifestJson: JSON.stringify(manifest),948 binding: {
860 manifestHash: manifest.sourceHash,949 specUrl: String(source.binding.specUrl),
861 updatedAt: now,950 defaultHeaders: null,
862 },951 },
863 );952 },
864 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({953 },
865 recipeRevisionId: sourceRecord.value.recipeRevisionId,954 },
866 documents: [955 }),
867 {956 catch: (cause) =>
868 id: `src_recipe_doc_${randomUUID()}`,957 cause instanceof Error ? cause : new Error(String(cause)),
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 },
881 ],
882 });958 });
883 const refEntries = Object.entries(manifest.refHintTable ?? {});959 const refEntries = Object.entries(manifest.refHintTable ?? {});
884 yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({960 yield* Effect.tryPromise({
885 recipeRevisionId: sourceRecord.value.recipeRevisionId,961 try: () =>
886 bundles:962 writeLocalSourceArtifact({
887 refEntries.length > 0963 context: input.runtimeLocalWorkspace.context,
888 ? [964 sourceId: source.id,
889 {965 artifact: buildLocalSourceArtifact({
890 id: SourceRecipeSchemaBundleIdSchema.make(966 source: {
891 `src_recipe_bundle_${randomUUID()}`,967 ...source,
892 ),968 sourceHash: manifest.sourceHash,
893 recipeRevisionId: sourceRecord.value.recipeRevisionId,969 },
894 bundleKind: "json_schema_ref_map",970 materialization: {
895 refsJson: JSON.stringify(971 manifestJson: JSON.stringify(manifest),
896 Object.fromEntries(972 manifestHash: manifest.sourceHash,
897 refEntries.map(([ref, rawValue]) => {973 sourceHash: manifest.sourceHash,
898 try {974 documents: [
899 return [ref, JSON.parse(rawValue) as unknown];975 {
900 } catch {976 id: `src_recipe_doc_${randomUUID()}`,
901 return [ref, rawValue];977 recipeRevisionId: "src_recipe_rev_materialization" as never,
902 }978 documentKind: "openapi",
903 }),979 documentKey:
980 typeof source.binding.specUrl === "string"
981 ? source.binding.specUrl
982 : source.endpoint,
983 contentText: openApiDocumentText,
984 contentHash: manifest.sourceHash,
985 fetchedAt: now,
986 createdAt: now,
987 updatedAt: now,
988 },
989 ],
990 schemaBundles:
991 refEntries.length > 0
992 ? [
993 {
994 id: SourceRecipeSchemaBundleIdSchema.make(
995 `src_recipe_bundle_${randomUUID()}`,
996 ),
997 recipeRevisionId: "src_recipe_rev_materialization" as never,
998 bundleKind: "json_schema_ref_map",
999 refsJson: JSON.stringify(
1000 Object.fromEntries(
1001 refEntries.map(([ref, rawValue]) => {
1002 try {
1003 return [ref, JSON.parse(rawValue) as unknown];
1004 } catch {
1005 return [ref, rawValue];
1006 }
1007 }),
1008 ),
1009 ),
1010 contentHash: manifest.sourceHash,
1011 createdAt: now,
1012 updatedAt: now,
1013 },
1014 ]
1015 : [],
1016 operations: definitions.map((definition) => {
1017 const presentation = buildOpenApiToolPresentation({
1018 definition,
1019 });
1020 const method = definition.method.toUpperCase();
1021
1022 return {
1023 id: `src_recipe_op_${randomUUID()}`,
1024 recipeRevisionId: "src_recipe_rev_materialization" as never,
1025 operationKey: definition.toolId,
1026 transportKind: "http",
1027 toolId: definition.toolId,
1028 title: definition.name,
1029 description: definition.description,
1030 operationKind:
1031 method === "GET" || method === "HEAD"
1032 ? "read"
1033 : method === "DELETE"
1034 ? "delete"
1035 : "write",
1036 searchText: normalizeSearchText(
1037 definition.toolId,
1038 definition.name,
1039 definition.description,
1040 definition.rawToolId,
1041 definition.operationId ?? undefined,
1042 definition.method,
1043 definition.path,
1044 definition.group,
1045 definition.leaf,
1046 definition.tags.join(" "),
904 ),1047 ),
905 ),1048 inputSchemaJson: presentation.inputSchemaJson ?? null,
906 contentHash: manifest.sourceHash,1049 outputSchemaJson: presentation.outputSchemaJson ?? null,
1050 providerKind: "openapi",
1051 providerDataJson: presentation.providerDataJson,
1052 createdAt: now,
1053 updatedAt: now,
1054 };
1055 }),
1056 },
1057 }),
1058 }),
1059 catch: (cause) =>
1060 cause instanceof Error ? cause : new Error(String(cause)),
1061 });
1062 yield* Effect.tryPromise({
1063 try: () =>
1064 writeLocalWorkspaceState({
1065 context: input.runtimeLocalWorkspace.context,
1066 state: {
1067 version: 1,
1068 sources: {
1069 [input.sourceId]: {
1070 status: "connected",
1071 lastError: null,
1072 sourceHash: manifest.sourceHash,
907 createdAt: now,1073 createdAt: now,
908 updatedAt: now,1074 updatedAt: now,
909 },1075 },
910 ]1076 },
911 : [],1077 policies: {},
912 });1078 },
913 yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({1079 }),
914 recipeRevisionId: sourceRecord.value.recipeRevisionId,1080 catch: (cause) =>
915 operations: definitions.map((definition) => {1081 cause instanceof Error ? cause : new Error(String(cause)),
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 });1082 });
956 yield* input.persistence.rows.sources.update(
957 input.workspaceId,
958 input.sourceId,
959 {
960 sourceHash: manifest.sourceHash,
961 updatedAt: now,
962 },
963 );
964 1083
965 return;1084 return;
1085 }) as Effect.Effect<void, Error, never>;
1086
1087const makeRuntimeLocalWorkspaceState = (workspaceId: WorkspaceId): Effect.Effect<RuntimeLocalWorkspaceState, Error, never> =>
1088 Effect.gen(function* () {
1089 const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-workspace-env-"));
1090 const context = yield* Effect.tryPromise({
1091 try: () => resolveLocalWorkspaceContext({ workspaceRoot }),
1092 catch: (cause) =>
1093 cause instanceof Error ? cause : new Error(String(cause)),
1094 });
1095 const loadedConfig = yield* Effect.tryPromise({
1096 try: () => loadLocalExecutorConfig(context),
1097 catch: (cause) =>
1098 cause instanceof Error ? cause : new Error(String(cause)),
1099 });
1100 return {
1101 context,
1102 installation: {
1103 workspaceId,
1104 accountId: AccountIdSchema.make("acc_local_test"),
1105 organizationId: "org_local_personal" as never,
1106 },
1107 loadedConfig,
1108 } satisfies RuntimeLocalWorkspaceState;
966 });1109 });
967 1110
968const makeResolver = (persistence: SqlControlPlanePersistence) =>1111const makeResolver = (
969 createWorkspaceExecutionEnvironmentResolver({1112 persistence: SqlControlPlanePersistence,
1113 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
1114) =>
1115 ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>
1116 createWorkspaceExecutionEnvironmentResolver({
970 rows: persistence.rows,1117 rows: persistence.rows,
971 sourceAuthService: {1118 sourceAuthService: {
972 getLocalServerBaseUrl: () => null,1119 getLocalServerBaseUrl: () => null,
@@ -983,19 +1130,25 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 10 unmodified lines
983 completeSourceCredentialSetup: () =>1130 completeSourceCredentialSetup: () =>
984 Effect.fail(new Error("not implemented in test")),1131 Effect.fail(new Error("not implemented in test")),
985 } as RuntimeSourceAuthService,1132 } as RuntimeSourceAuthService,
986 });1133 })(input).pipe(
1134 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
1135 ));
987 1136
988const makeResolverWithLiveSourceAuth = (1137const makeResolverWithLiveSourceAuth = (
989 persistence: SqlControlPlanePersistence,1138 persistence: SqlControlPlanePersistence,
1139 runtimeLocalWorkspace: RuntimeLocalWorkspaceState,
990) =>1140) =>
991 createWorkspaceExecutionEnvironmentResolver({1141 ((input: Parameters<ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>>[0]) =>
1142 createWorkspaceExecutionEnvironmentResolver({
992 rows: persistence.rows,1143 rows: persistence.rows,
993 sourceAuthService: createRuntimeSourceAuthService({1144 sourceAuthService: createRuntimeSourceAuthService({
994 rows: persistence.rows,1145 rows: persistence.rows,
995 liveExecutionManager: createLiveExecutionManager(),1146 liveExecutionManager: createLiveExecutionManager(),
996 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",1147 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
997 }),1148 }),
998 });1149 })(input).pipe(
1150 Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace),
1151 ));
999 1152
1000describe("workspace-execution-environment", () => {1153describe("workspace-execution-environment", () => {
1001 it.scoped("discovers persisted tools without live MCP listing", () =>1154 it.scoped("discovers persisted tools without live MCP listing", () =>
@@ -1004,8 +1157,9 @@ describe("workspace-execution-environment", () => { · 2 unmodified lines
1004 const persistence = yield* makePersistence;1157 const persistence = yield* makePersistence;
1005 const workspaceId = WorkspaceIdSchema.make("ws_catalog");1158 const workspaceId = WorkspaceIdSchema.make("ws_catalog");
1006 const sourceId = SourceIdSchema.make("src_catalog");1159 const sourceId = SourceIdSchema.make("src_catalog");
1160 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1007 yield* persistConnectedEchoTool({1161 yield* persistConnectedEchoTool({
1008 persistence,1162 runtimeLocalWorkspace,
1009 endpoint: server.endpoint,1163 endpoint: server.endpoint,
1010 workspaceId,1164 workspaceId,
1011 sourceId,1165 sourceId,
@@ -1017,7 +1171,7 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines
1017 cause instanceof Error ? cause : new Error(String(cause)),1171 cause instanceof Error ? cause : new Error(String(cause)),
1018 }).pipe(Effect.orDie);1172 }).pipe(Effect.orDie);
1019 1173
1020 const resolveEnvironment = makeResolver(persistence);1174 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1021 const environment = yield* resolveEnvironment({1175 const environment = yield* resolveEnvironment({
1022 workspaceId,1176 workspaceId,
1023 accountId: AccountIdSchema.make("acc_catalog"),1177 accountId: AccountIdSchema.make("acc_catalog"),
@@ -1050,13 +1204,14 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines
1050 const persistence = yield* makePersistence;1204 const persistence = yield* makePersistence;
1051 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");1205 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
1052 const sourceId = SourceIdSchema.make("src_github_discovery");1206 const sourceId = SourceIdSchema.make("src_github_discovery");
1207 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1053 yield* persistConnectedGithubOpenApiSource({1208 yield* persistConnectedGithubOpenApiSource({
1054 persistence,1209 runtimeLocalWorkspace,
1055 workspaceId,1210 workspaceId,
1056 sourceId,1211 sourceId,
1057 });1212 });
1058 1213
1059 const resolveEnvironment = makeResolver(persistence);1214 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1060 const environment = yield* resolveEnvironment({1215 const environment = yield* resolveEnvironment({
1061 workspaceId,1216 workspaceId,
1062 accountId: AccountIdSchema.make("acc_github_discovery"),1217 accountId: AccountIdSchema.make("acc_github_discovery"),
@@ -1103,14 +1258,15 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
1103 const persistence = yield* makePersistence;1258 const persistence = yield* makePersistence;
1104 const workspaceId = WorkspaceIdSchema.make("ws_invoke");1259 const workspaceId = WorkspaceIdSchema.make("ws_invoke");
1105 const sourceId = SourceIdSchema.make("src_invoke");1260 const sourceId = SourceIdSchema.make("src_invoke");
1261 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1106 yield* persistConnectedEchoTool({1262 yield* persistConnectedEchoTool({
1107 persistence,1263 runtimeLocalWorkspace,
1108 endpoint: server.endpoint,1264 endpoint: server.endpoint,
1109 workspaceId,1265 workspaceId,
1110 sourceId,1266 sourceId,
1111 });1267 });
1112 1268
1113 const resolveEnvironment = makeResolver(persistence);1269 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1114 const environment = yield* resolveEnvironment({1270 const environment = yield* resolveEnvironment({
1115 workspaceId,1271 workspaceId,
1116 accountId: AccountIdSchema.make("acc_invoke"),1272 accountId: AccountIdSchema.make("acc_invoke"),
@@ -1139,7 +1295,8 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines
1139 const specServer = yield* makeOpenApiSpecServer;1295 const specServer = yield* makeOpenApiSpecServer;
1140 const persistence = yield* makePersistence;1296 const persistence = yield* makePersistence;
1141 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");1297 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1142 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);1298 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1299 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
1143 const now = Date.now();1300 const now = Date.now();
1144 const tokenSecretMaterialId = SecretMaterialIdSchema.make(1301 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1145 "sec_test_openapi_bearer",1302 "sec_test_openapi_bearer",
@@ -1220,21 +1377,35 @@ describe("workspace-execution-environment", () => { · 74 unmodified lines
1220 encodeURIComponent("exec_add_openapi:executor.sources.add:"),1377 encodeURIComponent("exec_add_openapi:executor.sources.add:"),
1221 );1378 );
1222 1379
1223 const storedSource =1380 const loadedConfig = yield* Effect.tryPromise({
1224 yield* persistence.rows.sources.getByWorkspaceAndId(1381 try: () => loadLocalExecutorConfig(runtimeLocalWorkspace.context),
1225 workspaceId,1382 catch: (cause) =>
1226 added.id,1383 cause instanceof Error ? cause : new Error(String(cause)),
1227 );1384 });
1228 expect(Option.isSome(storedSource)).toBe(true);1385 expect(loadedConfig.config?.sources?.[added.id]).toMatchObject({
1229 if (Option.isSome(storedSource)) {1386 kind: "openapi",
1230 const documents =1387 connection: {
1231 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(1388 endpoint: `${specServer.baseUrl}/`,
1232 storedSource.value.recipeRevisionId,1389 },
1233 );1390 });
1234 expect(documents[0]?.contentText).toContain(1391 const artifact = yield* Effect.tryPromise({
1235 '"operationId":"repos.getRepo"',1392 try: () =>
1236 );1393 readLocalSourceArtifact({
1237 }1394 context: runtimeLocalWorkspace.context,
1395 sourceId: added.id,
1396 }),
1397 catch: (cause) =>
1398 cause instanceof Error ? cause : new Error(String(cause)),
1399 });
1400 expect(artifact?.documents[0]?.contentText).toContain(
1401 '"operationId":"repos.getRepo"',
1402 );
1403 const workspaceState = yield* Effect.tryPromise({
1404 try: () => loadLocalWorkspaceState(runtimeLocalWorkspace.context),
1405 catch: (cause) =>
1406 cause instanceof Error ? cause : new Error(String(cause)),
1407 });
1408 expect(workspaceState.sources[added.id]?.status).toBe("connected");
1238 1409
1239 const freshEnvironment = yield* resolveEnvironment({1410 const freshEnvironment = yield* resolveEnvironment({
1240 workspaceId,1411 workspaceId,
@@ -1280,7 +1451,8 @@ describe("workspace-execution-environment", () => { · 39 unmodified lines
1280 const graphqlServer = yield* makeGraphqlServer;1451 const graphqlServer = yield* makeGraphqlServer;
1281 const persistence = yield* makePersistence;1452 const persistence = yield* makePersistence;
1282 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");1453 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1283 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);1454 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1455 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
1284 const now = Date.now();1456 const now = Date.now();
1285 const tokenSecretMaterialId = SecretMaterialIdSchema.make(1457 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1286 "sec_test_graphql_bearer",1458 "sec_test_graphql_bearer",
@@ -1339,33 +1511,38 @@ describe("workspace-execution-environment", () => { · 52 unmodified lines
1339 expect(added.auth.kind).toBe("bearer");1511 expect(added.auth.kind).toBe("bearer");
1340 expect(added.auth.token?.providerId).toBe("postgres");1512 expect(added.auth.token?.providerId).toBe("postgres");
1341 1513
1342 const storedSource =1514 const artifact = yield* Effect.tryPromise({
1343 yield* persistence.rows.sources.getByWorkspaceAndId(1515 try: () =>
1344 workspaceId,1516 readLocalSourceArtifact({
1345 added.id,1517 context: runtimeLocalWorkspace.context,
1346 );1518 sourceId: added.id,
1347 expect(Option.isSome(storedSource)).toBe(true);1519 }),
1348 if (Option.isSome(storedSource)) {1520 catch: (cause) =>
1349 const documents =1521 cause instanceof Error ? cause : new Error(String(cause)),
1350 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(1522 });
1351 storedSource.value.recipeRevisionId,1523 expect(artifact?.documents[0]?.contentText).toContain('"__schema"');
1352 );1524 expect(artifact?.schemaBundles.length).toBe(1);
1353 expect(documents[0]?.contentText).toContain('"__schema"');1525 if (artifact === null) {
1354 expect(1526 throw new Error(`Missing local source artifact for ${added.id}`);
1355 (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(
1356 storedSource.value.recipeRevisionId,
1357 )).length,
1358 ).toBe(1);
1359 }1527 }
1360 1528
1361 const connectedSourceRecord = Option.getOrThrow(storedSource);1529 yield* Effect.tryPromise({
1362 yield* persistence.rows.sourceRecipeRevisions.update(1530 try: () =>
1363 connectedSourceRecord.recipeRevisionId,1531 writeLocalSourceArtifact({
1364 {1532 context: runtimeLocalWorkspace.context,
1365 manifestJson: null,1533 sourceId: added.id,
1366 updatedAt: Date.now(),1534 artifact: {
1367 },1535 ...artifact,
1368 );1536 revision: {
1537 ...artifact.revision,
1538 manifestJson: null,
1539 updatedAt: Date.now(),
1540 },
1541 },
1542 }),
1543 catch: (cause) =>
1544 cause instanceof Error ? cause : new Error(String(cause)),
1545 });
1369 1546
1370 const freshEnvironment = yield* resolveEnvironment({1547 const freshEnvironment = yield* resolveEnvironment({
1371 workspaceId,1548 workspaceId,
@@ -1515,9 +1692,10 @@ describe("workspace-execution-environment", () => { · 143 unmodified lines
1515 const persistence = yield* makePersistence;1692 const persistence = yield* makePersistence;
1516 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");1693 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");
1517 const sourceId = SourceIdSchema.make("src_params_auth");1694 const sourceId = SourceIdSchema.make("src_params_auth");
1695 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1518 1696
1519 yield* persistConnectedGithubOpenApiSource({1697 yield* persistConnectedGithubOpenApiSource({
1520 persistence,1698 runtimeLocalWorkspace,
1521 workspaceId,1699 workspaceId,
1522 sourceId,1700 sourceId,
1523 endpoint: specServer.baseUrl,1701 endpoint: specServer.baseUrl,
@@ -1527,7 +1705,7 @@ describe("workspace-execution-environment", () => { · 3 unmodified lines
1527 },1705 },
1528 });1706 });
1529 1707
1530 const resolveEnvironment = makeResolver(persistence);1708 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1531 const environment = yield* resolveEnvironment({1709 const environment = yield* resolveEnvironment({
1532 workspaceId,1710 workspaceId,
1533 accountId: AccountIdSchema.make("acc_params_auth"),1711 accountId: AccountIdSchema.make("acc_params_auth"),
@@ -1567,14 +1745,15 @@ describe("workspace-execution-environment", () => { · 33 unmodified lines
1567 "ws_openapi_describe_bundle",1745 "ws_openapi_describe_bundle",
1568 );1746 );
1569 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");1747 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");
1748 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1570 1749
1571 yield* persistConnectedGithubOpenApiSource({1750 yield* persistConnectedGithubOpenApiSource({
1572 persistence,1751 runtimeLocalWorkspace,
1573 workspaceId,1752 workspaceId,
1574 sourceId,1753 sourceId,
1575 });1754 });
1576 1755
1577 const resolveEnvironment = makeResolver(persistence);1756 const resolveEnvironment = makeResolver(persistence, runtimeLocalWorkspace);
1578 const environment = yield* resolveEnvironment({1757 const environment = yield* resolveEnvironment({
1579 workspaceId,1758 workspaceId,
1580 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),1759 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),
@@ -1621,10 +1800,12 @@ describe("workspace-execution-environment", () => { · 40 unmodified lines
1621 () =>1800 () =>
1622 Effect.gen(function* () {1801 Effect.gen(function* () {
1623 const persistence = yield* makePersistence;1802 const persistence = yield* makePersistence;
1624 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);1803 const workspaceId = WorkspaceIdSchema.make("ws_describe_add_source");
1804 const runtimeLocalWorkspace = yield* makeRuntimeLocalWorkspaceState(workspaceId);
1805 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence, runtimeLocalWorkspace);
1625 1806
1626 const environment = yield* resolveEnvironment({1807 const environment = yield* resolveEnvironment({
1627 workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"),1808 workspaceId,
1628 accountId: AccountIdSchema.make("acc_describe_add_source"),1809 accountId: AccountIdSchema.make("acc_describe_add_source"),
1629 executionId: ExecutionIdSchema.make("exec_describe_add_source"),1810 executionId: ExecutionIdSchema.make("exec_describe_add_source"),
1630 });1811 });
packages/control-plane/src/runtime/workspace-execution-environment.ts+8−33
@@ -395,49 +395,24 @@ const authorizePersistedToolInvocation = (input: { · 394 unmodified lines
395 >[0]["onElicitation"];395 >[0]["onElicitation"];
396}): Effect.Effect<void, Error, never> =>396}): Effect.Effect<void, Error, never> =>
397 Effect.gen(function* () {397 Effect.gen(function* () {
398 const workspace = yield* input.rows.workspaces398 const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
399 .getById(input.workspaceId)399 const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies(input.workspaceId).pipe(
400 .pipe(
401 Effect.mapError((cause) =>
402 cause instanceof Error ? cause : new Error(String(cause)),
403 ),
404 );
405 const storedPolicies = Option.isSome(workspace)
406 ? yield* input.rows.policies
407 .listForWorkspaceContext({
408 organizationId: workspace.value.organizationId,
409 workspaceId: input.workspaceId,
410 })
411 .pipe(
412 Effect.mapError((cause) =>
413 cause instanceof Error ? cause : new Error(String(cause)),
414 ),
415 )
416 : [];
417 const localWorkspacePolicies = yield* loadRuntimeLocalWorkspacePolicies({
418 store: input.rows,
419 workspaceId: input.workspaceId,
420 }).pipe(
421 Effect.mapError((cause) =>400 Effect.mapError((cause) =>
422 cause instanceof Error ? cause : new Error(String(cause)),401 cause instanceof Error ? cause : new Error(String(cause)),
423 ),402 ),
424 );403 );
425 const policies = localWorkspacePolicies === null
426 ? storedPolicies
427 : [
428 ...storedPolicies.filter((policy) => policy.scopeType !== "workspace"),
429 ...localWorkspacePolicies.policies,
430 ];
431 404
432 const decision = evaluateInvocationPolicy({405 const decision = evaluateInvocationPolicy({
433 descriptor: input.descriptor,406 descriptor: input.descriptor,
434 args: input.args,407 args: input.args,
435 policies,408 policies: localWorkspacePolicies.policies,
436 context: {409 context: {
437 workspaceId: input.workspaceId,410 workspaceId: input.workspaceId,
438 organizationId: Option.isSome(workspace)411 organizationId:
439 ? workspace.value.organizationId412 runtimeLocalWorkspace !== null
440 : ("org_unknown" as never),413 && runtimeLocalWorkspace.installation.workspaceId === input.workspaceId
414 ? runtimeLocalWorkspace.installation.organizationId
415 : ("org_unknown" as never),
441 accountId: input.accountId,416 accountId: input.accountId,
442 clientId:417 clientId:
443 typeof input.context?.clientId === "string" &&418 typeof input.context?.clientId === "string" &&
packages/control-plane/src/schema/models/local-config.ts+1−0
@@ -6,6 +6,7 @@ export const LocalConfigSecretProviderSourceSchema = Schema.Literal( · 5 unmodified lines
6 "env",6 "env",
7 "file",7 "file",
8 "exec",8 "exec",
9 "params",
9);10);
10 11
11export const LocalConfigEnvSecretProviderSchema = Schema.Struct({12export const LocalConfigEnvSecretProviderSchema = Schema.Struct({
packages/control-plane/src/schema/models/local-installation.ts+5−22
@@ -1,10 +1,5 @@
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";3import { TimestampMsSchema } from "../common";
9import {4import {
10 AccountIdSchema,5 AccountIdSchema,
@@ -13,28 +8,16 @@ import { · 2 unmodified lines
13 WorkspaceIdSchema,8 WorkspaceIdSchema,
14} from "../ids";9} from "../ids";
15 10
16const localInstallationSchemaOverrides = {11export const LocalInstallationSchema = Schema.Struct({
17 id: InstallationIdSchema,12 id: InstallationIdSchema,
18 accountId: AccountIdSchema,13 accountId: AccountIdSchema,
19 organizationId: OrganizationIdSchema,14 organizationId: OrganizationIdSchema,
20 workspaceId: WorkspaceIdSchema,15 workspaceId: WorkspaceIdSchema,
21 createdAt: TimestampMsSchema,16 createdAt: TimestampMsSchema,
22 updatedAt: TimestampMsSchema,17 updatedAt: TimestampMsSchema,
23} as const;18});
24
25export const LocalInstallationSchema = createSelectSchema(
26 localInstallationsTable,
27 localInstallationSchemaOverrides,
28);
29
30export const LocalInstallationInsertSchema = createInsertSchema(
31 localInstallationsTable,
32 localInstallationSchemaOverrides,
33);
34 19
35export const LocalInstallationUpdateSchema = createUpdateSchema(20export const LocalInstallationInsertSchema = LocalInstallationSchema;
36 localInstallationsTable,21export const LocalInstallationUpdateSchema = Schema.partial(LocalInstallationSchema);
37 localInstallationSchemaOverrides,
38);
39 22
40export type LocalInstallation = typeof LocalInstallationSchema.Type;23export type LocalInstallation = typeof LocalInstallationSchema.Type;
packages/react/src/index.test.tsx+4−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,7 @@ 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({98 createControlPlaneRuntime({
99 localDataDir: ":memory:",99 localDataDir: ":memory:",
100 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")),100 workspaceRoot: mkdtempSync(join(tmpdir(), "executor-react-test-")),
101 }),101 }),
packages/server/src/index.ts+7−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;
@@ -77,7 +77,7 @@ export type StartLocalExecutorServerOptions = { · 12 unmodified lines
77};77};
78 78
79export type LocalExecutorRequestHandler = {79export type LocalExecutorRequestHandler = {
80 readonly runtime: SqlControlPlaneRuntime;80 readonly runtime: ControlPlaneRuntime;
81 readonly handleApiRequest: (request: Request) => Promise<Response>;81 readonly handleApiRequest: (request: Request) => Promise<Response>;
82 readonly getBaseUrl: () => string | undefined;82 readonly getBaseUrl: () => string | undefined;
83 readonly setBaseUrl: (baseUrl: string) => void;83 readonly setBaseUrl: (baseUrl: string) => void;
@@ -86,7 +86,7 @@ export type LocalExecutorRequestHandler = { · 2 unmodified lines
86type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>;86type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>;
87type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>;87type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>;
88 88
89const disposeRuntime = (runtime: SqlControlPlaneRuntime) =>89const disposeRuntime = (runtime: ControlPlaneRuntime) =>
90 Effect.tryPromise({90 Effect.tryPromise({
91 try: () => runtime.close(),91 try: () => runtime.close(),
92 catch: (cause) =>92 catch: (cause) =>
@@ -98,7 +98,7 @@ const createRuntime = ( · 5 unmodified lines
98 getLocalServerBaseUrl: () => string | undefined,98 getLocalServerBaseUrl: () => string | undefined,
99 options: StartLocalExecutorServerOptions,99 options: StartLocalExecutorServerOptions,
100) =>100) =>
101 createSqlControlPlaneRuntime({101 createControlPlaneRuntime({
102 localDataDir,102 localDataDir,
103 migrationsFolder: options.migrationsFolder,103 migrationsFolder: options.migrationsFolder,
104 workspaceRoot: options.workspaceRoot,104 workspaceRoot: options.workspaceRoot,
@@ -209,7 +209,7 @@ const createRuntimeWithLegacyMigration = ( · 104 unmodified lines
209 );209 );
210 });210 });
211 211
212const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) =>212const createControlPlaneWebHandler = (runtime: ControlPlaneRuntime) =>
213 Effect.acquireRelease(213 Effect.acquireRelease(
214 Effect.sync(() =>214 Effect.sync(() =>
215 HttpApiBuilder.toWebHandler(215 HttpApiBuilder.toWebHandler(