Unified · Split
| @@ -15,11 +15,11 @@ import { · 14 unmodified lines | |||
| 15 | ExecutionIdSchema, | 15 | ExecutionIdSchema, |
| 16 | RuntimeExecutionResolverService, | 16 | RuntimeExecutionResolverService, |
| 17 | createControlPlaneClient, | 17 | createControlPlaneClient, |
| 18 | createSqlControlPlaneRuntime, | 18 | createControlPlaneRuntime, |
| 19 | type ControlPlaneClient, | 19 | type ControlPlaneClient, |
| 20 | type ExecutionEnvelope, | 20 | type ExecutionEnvelope, |
| 21 | type ExecutionInteraction, | 21 | type ExecutionInteraction, |
| 22 | type SqlControlPlaneRuntime, | 22 | type ControlPlaneRuntime, |
| 23 | } from "@executor/control-plane"; | 23 | } from "@executor/control-plane"; |
| 24 | import type { ToolCatalog } from "@executor/codemode-core"; | 24 | import type { ToolCatalog } from "@executor/codemode-core"; |
| 25 | 25 | ||
| @@ -247,7 +247,7 @@ const formatCatalogUnavailableMessage = (cause: Cause.Cause<unknown>): string => · 221 unmodified lines | |||
| 247 | : `Current workspace catalog unavailable: ${message}`; | 247 | : `Current workspace catalog unavailable: ${message}`; |
| 248 | }; | 248 | }; |
| 249 | 249 | ||
| 250 | const closeSqlRuntime = (runtime: SqlControlPlaneRuntime) => | 250 | const closeRuntime = (runtime: ControlPlaneRuntime) => |
| 251 | Effect.tryPromise({ | 251 | Effect.tryPromise({ |
| 252 | try: () => runtime.close(), | 252 | try: () => runtime.close(), |
| 253 | catch: toError, | 253 | catch: toError, |
| @@ -274,7 +274,7 @@ const buildRunWorkflowText = ( · 20 unmodified lines | |||
| 274 | 274 | ||
| 275 | const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => | 275 | const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => |
| 276 | Effect.acquireUseRelease( | 276 | Effect.acquireUseRelease( |
| 277 | createSqlControlPlaneRuntime({ | 277 | createControlPlaneRuntime({ |
| 278 | localDataDir: DEFAULT_LOCAL_DATA_DIR, | 278 | localDataDir: DEFAULT_LOCAL_DATA_DIR, |
| 279 | }).pipe(Effect.mapError(toError)), | 279 | }).pipe(Effect.mapError(toError)), |
| 280 | (runtime) => | 280 | (runtime) => |
| @@ -293,7 +293,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 12 unmodified lines | |||
| 293 | 293 | ||
| 294 | return yield* buildRunWorkflowText(environment.catalog); | 294 | return yield* buildRunWorkflowText(environment.catalog); |
| 295 | }), | 295 | }), |
| 296 | closeSqlRuntime, | 296 | closeRuntime, |
| 297 | ).pipe( | 297 | ).pipe( |
| 298 | Effect.catchAllCause((cause) => | 298 | Effect.catchAllCause((cause) => |
| 299 | Effect.succeed( | 299 | Effect.succeed( |
| @@ -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: { |
| @@ -2,26 +2,20 @@ import { HttpApi, OpenApi } from "@effect/platform"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { ExecutionsApi } from "./executions/api"; | 3 | import { ExecutionsApi } from "./executions/api"; |
| 4 | import { LocalApi } from "./local/api"; | 4 | import { LocalApi } from "./local/api"; |
| 5 | import { MembershipsApi } from "./memberships/api"; | ||
| 6 | import { OAuthApi } from "./oauth/api"; | 5 | import { OAuthApi } from "./oauth/api"; |
| 7 | import { OrganizationsApi } from "./organizations/api"; | ||
| 8 | import { PoliciesApi } from "./policies/api"; | 6 | import { PoliciesApi } from "./policies/api"; |
| 9 | import { SourcesApi } from "./sources/api"; | 7 | import { SourcesApi } from "./sources/api"; |
| 10 | import { WorkspacesApi } from "./workspaces/api"; | ||
| 11 | 8 | ||
| 12 | export class ControlPlaneApi extends HttpApi.make("controlPlane") | 9 | export class ControlPlaneApi extends HttpApi.make("controlPlane") |
| 13 | .add(LocalApi) | 10 | .add(LocalApi) |
| 14 | .add(OAuthApi) | 11 | .add(OAuthApi) |
| 15 | .add(OrganizationsApi) | ||
| 16 | .add(MembershipsApi) | ||
| 17 | .add(WorkspacesApi) | ||
| 18 | .add(SourcesApi) | 12 | .add(SourcesApi) |
| 19 | .add(PoliciesApi) | 13 | .add(PoliciesApi) |
| 20 | .add(ExecutionsApi) | 14 | .add(ExecutionsApi) |
| 21 | .annotateContext( | 15 | .annotateContext( |
| 22 | OpenApi.annotations({ | 16 | OpenApi.annotations({ |
| 23 | title: "Executor Control Plane API", | 17 | title: "Executor Control Plane API", |
| 24 | description: "CRUD control plane for organizations, workspaces, sources, and policies", | 18 | description: "Local-first control plane for workspace sources, policies, auth, and execution", |
| 25 | }), | 19 | }), |
| 26 | ) {} | 20 | ) {} |
| 27 | 21 | ||
| @@ -8,19 +8,13 @@ import { · 7 unmodified lines | |||
| 8 | } from "./auth/actor-resolver"; | 8 | } from "./auth/actor-resolver"; |
| 9 | import { ControlPlaneExecutionsLive } from "./executions/http"; | 9 | import { ControlPlaneExecutionsLive } from "./executions/http"; |
| 10 | import { ControlPlaneLocalLive } from "./local/http"; | 10 | import { ControlPlaneLocalLive } from "./local/http"; |
| 11 | import { ControlPlaneMembershipsLive } from "./memberships/http"; | ||
| 12 | import { ControlPlaneOAuthLive } from "./oauth/http"; | 11 | import { ControlPlaneOAuthLive } from "./oauth/http"; |
| 13 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; | ||
| 14 | import { ControlPlanePoliciesLive } from "./policies/http"; | 12 | import { ControlPlanePoliciesLive } from "./policies/http"; |
| 15 | import { ControlPlaneSourcesLive } from "./sources/http"; | 13 | import { ControlPlaneSourcesLive } from "./sources/http"; |
| 16 | import { ControlPlaneWorkspacesLive } from "./workspaces/http"; | ||
| 17 | 14 | ||
| 18 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( | 15 | export 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), |
| @@ -60,27 +60,6 @@ export { · 59 unmodified lines | |||
| 60 | type CompleteSourceOAuthResult, | 60 | type CompleteSourceOAuthResult, |
| 61 | } from "./oauth/api"; | 61 | } from "./oauth/api"; |
| 62 | 62 | ||
| 63 | export { | ||
| 64 | CreateOrganizationPayloadSchema, | ||
| 65 | UpdateOrganizationPayloadSchema, | ||
| 66 | type CreateOrganizationPayload, | ||
| 67 | type UpdateOrganizationPayload, | ||
| 68 | } from "./organizations/api"; | ||
| 69 | |||
| 70 | export { | ||
| 71 | CreateMembershipPayloadSchema, | ||
| 72 | UpdateMembershipPayloadSchema, | ||
| 73 | type CreateMembershipPayload, | ||
| 74 | type UpdateMembershipPayload, | ||
| 75 | } from "./memberships/api"; | ||
| 76 | |||
| 77 | export { | ||
| 78 | CreateWorkspacePayloadSchema, | ||
| 79 | UpdateWorkspacePayloadSchema, | ||
| 80 | type CreateWorkspacePayload, | ||
| 81 | type UpdateWorkspacePayload, | ||
| 82 | } from "./workspaces/api"; | ||
| 83 | |||
| 84 | export { | 63 | export { |
| 85 | ConnectSourcePayloadSchema, | 64 | ConnectSourcePayloadSchema, |
| 86 | ConnectSourceResultSchema, | 65 | ConnectSourceResultSchema, |
| @@ -1,84 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | AccountIdSchema, | ||
| 4 | OrganizationIdSchema, | ||
| 5 | OrganizationMembershipInsertSchema, | ||
| 6 | OrganizationMembershipSchema, | ||
| 7 | OrganizationMembershipUpdateSchema, | ||
| 8 | } from "#schema"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | ControlPlaneBadRequestError, | ||
| 13 | ControlPlaneForbiddenError, | ||
| 14 | ControlPlaneNotFoundError, | ||
| 15 | ControlPlaneStorageError, | ||
| 16 | ControlPlaneUnauthorizedError, | ||
| 17 | } from "../errors"; | ||
| 18 | |||
| 19 | const createMembershipPayloadRequiredSchema = OrganizationMembershipInsertSchema.pipe( | ||
| 20 | Schema.pick("accountId", "role"), | ||
| 21 | ); | ||
| 22 | |||
| 23 | const createMembershipPayloadOptionalSchema = OrganizationMembershipInsertSchema.pipe( | ||
| 24 | Schema.pick("status", "billable", "invitedByAccountId"), | ||
| 25 | Schema.partialWith({ exact: true }), | ||
| 26 | ); | ||
| 27 | |||
| 28 | export const CreateMembershipPayloadSchema = Schema.extend( | ||
| 29 | createMembershipPayloadRequiredSchema, | ||
| 30 | createMembershipPayloadOptionalSchema, | ||
| 31 | ); | ||
| 32 | |||
| 33 | export type CreateMembershipPayload = typeof CreateMembershipPayloadSchema.Type; | ||
| 34 | |||
| 35 | export const UpdateMembershipPayloadSchema = OrganizationMembershipUpdateSchema.pipe( | ||
| 36 | Schema.pick("role", "status", "billable"), | ||
| 37 | Schema.partialWith({ exact: true }), | ||
| 38 | ); | ||
| 39 | |||
| 40 | export type UpdateMembershipPayload = typeof UpdateMembershipPayloadSchema.Type; | ||
| 41 | |||
| 42 | const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | ||
| 43 | const accountIdParam = HttpApiSchema.param("accountId", AccountIdSchema); | ||
| 44 | |||
| 45 | export class MembershipsApi extends HttpApiGroup.make("memberships") | ||
| 46 | .add( | ||
| 47 | HttpApiEndpoint.get("list")`/organizations/${organizationIdParam}/memberships` | ||
| 48 | .addSuccess(Schema.Array(OrganizationMembershipSchema)) | ||
| 49 | .addError(ControlPlaneBadRequestError) | ||
| 50 | .addError(ControlPlaneUnauthorizedError) | ||
| 51 | .addError(ControlPlaneForbiddenError) | ||
| 52 | .addError(ControlPlaneNotFoundError) | ||
| 53 | .addError(ControlPlaneStorageError), | ||
| 54 | ) | ||
| 55 | .add( | ||
| 56 | HttpApiEndpoint.post("create")`/organizations/${organizationIdParam}/memberships` | ||
| 57 | .setPayload(CreateMembershipPayloadSchema) | ||
| 58 | .addSuccess(OrganizationMembershipSchema) | ||
| 59 | .addError(ControlPlaneBadRequestError) | ||
| 60 | .addError(ControlPlaneUnauthorizedError) | ||
| 61 | .addError(ControlPlaneForbiddenError) | ||
| 62 | .addError(ControlPlaneNotFoundError) | ||
| 63 | .addError(ControlPlaneStorageError), | ||
| 64 | ) | ||
| 65 | .add( | ||
| 66 | HttpApiEndpoint.patch("update")`/organizations/${organizationIdParam}/memberships/${accountIdParam}` | ||
| 67 | .setPayload(UpdateMembershipPayloadSchema) | ||
| 68 | .addSuccess(OrganizationMembershipSchema) | ||
| 69 | .addError(ControlPlaneBadRequestError) | ||
| 70 | .addError(ControlPlaneUnauthorizedError) | ||
| 71 | .addError(ControlPlaneForbiddenError) | ||
| 72 | .addError(ControlPlaneNotFoundError) | ||
| 73 | .addError(ControlPlaneStorageError), | ||
| 74 | ) | ||
| 75 | .add( | ||
| 76 | HttpApiEndpoint.del("remove")`/organizations/${organizationIdParam}/memberships/${accountIdParam}` | ||
| 77 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | ||
| 78 | .addError(ControlPlaneBadRequestError) | ||
| 79 | .addError(ControlPlaneUnauthorizedError) | ||
| 80 | .addError(ControlPlaneForbiddenError) | ||
| 81 | .addError(ControlPlaneNotFoundError) | ||
| 82 | .addError(ControlPlaneStorageError), | ||
| 83 | ) | ||
| 84 | .prefix("/v1") {} | ||
| @@ -1,67 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | ||
| 2 | import type { OrganizationId } from "#schema"; | ||
| 3 | |||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | ||
| 6 | createMembership, | ||
| 7 | listMemberships, | ||
| 8 | removeMembership, | ||
| 9 | updateMembership, | ||
| 10 | } from "../../runtime/organizations-operations"; | ||
| 11 | |||
| 12 | import { ControlPlaneApi } from "../api"; | ||
| 13 | import { withRequestActor } from "../http-auth"; | ||
| 14 | |||
| 15 | const requireReadMemberships = (organizationId: OrganizationId) => | ||
| 16 | requirePermission({ | ||
| 17 | permission: "memberships:read", | ||
| 18 | organizationId, | ||
| 19 | }); | ||
| 20 | |||
| 21 | const requireWriteMemberships = (organizationId: OrganizationId) => | ||
| 22 | requirePermission({ | ||
| 23 | permission: "memberships:write", | ||
| 24 | organizationId, | ||
| 25 | }); | ||
| 26 | |||
| 27 | export const ControlPlaneMembershipsLive = HttpApiBuilder.group( | ||
| 28 | ControlPlaneApi, | ||
| 29 | "memberships", | ||
| 30 | (handlers) => | ||
| 31 | handlers | ||
| 32 | .handle("list", ({ path }) => | ||
| 33 | withRequestActor("memberships.list", () => | ||
| 34 | withPolicy(requireReadMemberships(path.organizationId))( | ||
| 35 | listMemberships(path.organizationId), | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | ) | ||
| 39 | .handle("create", ({ path, payload }) => | ||
| 40 | withRequestActor("memberships.create", () => | ||
| 41 | withPolicy(requireWriteMemberships(path.organizationId))( | ||
| 42 | createMembership({ organizationId: path.organizationId, payload }), | ||
| 43 | ), | ||
| 44 | ), | ||
| 45 | ) | ||
| 46 | .handle("update", ({ path, payload }) => | ||
| 47 | withRequestActor("memberships.update", () => | ||
| 48 | withPolicy(requireWriteMemberships(path.organizationId))( | ||
| 49 | updateMembership({ | ||
| 50 | organizationId: path.organizationId, | ||
| 51 | accountId: path.accountId, | ||
| 52 | payload, | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ), | ||
| 56 | ) | ||
| 57 | .handle("remove", ({ path }) => | ||
| 58 | withRequestActor("memberships.remove", () => | ||
| 59 | withPolicy(requireWriteMemberships(path.organizationId))( | ||
| 60 | removeMembership({ | ||
| 61 | organizationId: path.organizationId, | ||
| 62 | accountId: path.accountId, | ||
| 63 | }), | ||
| 64 | ), | ||
| 65 | ), | ||
| 66 | ), | ||
| 67 | ); | ||
| @@ -1,82 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | OrganizationIdSchema, | ||
| 4 | OrganizationStatusSchema, | ||
| 5 | OrganizationSchema, | ||
| 6 | } from "#schema"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | ControlPlaneBadRequestError, | ||
| 11 | ControlPlaneForbiddenError, | ||
| 12 | ControlPlaneNotFoundError, | ||
| 13 | ControlPlaneStorageError, | ||
| 14 | ControlPlaneUnauthorizedError, | ||
| 15 | } from "../errors"; | ||
| 16 | import { | ||
| 17 | OptionalTrimmedNonEmptyStringSchema, | ||
| 18 | TrimmedNonEmptyStringSchema, | ||
| 19 | } from "../string-schemas"; | ||
| 20 | |||
| 21 | export const CreateOrganizationPayloadSchema = Schema.Struct({ | ||
| 22 | name: TrimmedNonEmptyStringSchema, | ||
| 23 | slug: OptionalTrimmedNonEmptyStringSchema, | ||
| 24 | }); | ||
| 25 | |||
| 26 | export type CreateOrganizationPayload = typeof CreateOrganizationPayloadSchema.Type; | ||
| 27 | |||
| 28 | export const UpdateOrganizationPayloadSchema = Schema.Struct({ | ||
| 29 | name: OptionalTrimmedNonEmptyStringSchema, | ||
| 30 | status: Schema.optional(OrganizationStatusSchema), | ||
| 31 | }); | ||
| 32 | |||
| 33 | export type UpdateOrganizationPayload = typeof UpdateOrganizationPayloadSchema.Type; | ||
| 34 | |||
| 35 | const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | ||
| 36 | |||
| 37 | export class OrganizationsApi extends HttpApiGroup.make("organizations") | ||
| 38 | .add( | ||
| 39 | HttpApiEndpoint.get("list")`/organizations` | ||
| 40 | .addSuccess(Schema.Array(OrganizationSchema)) | ||
| 41 | .addError(ControlPlaneBadRequestError) | ||
| 42 | .addError(ControlPlaneUnauthorizedError) | ||
| 43 | .addError(ControlPlaneForbiddenError) | ||
| 44 | .addError(ControlPlaneStorageError), | ||
| 45 | ) | ||
| 46 | .add( | ||
| 47 | HttpApiEndpoint.post("create")`/organizations` | ||
| 48 | .setPayload(CreateOrganizationPayloadSchema) | ||
| 49 | .addSuccess(OrganizationSchema) | ||
| 50 | .addError(ControlPlaneBadRequestError) | ||
| 51 | .addError(ControlPlaneUnauthorizedError) | ||
| 52 | .addError(ControlPlaneForbiddenError) | ||
| 53 | .addError(ControlPlaneStorageError), | ||
| 54 | ) | ||
| 55 | .add( | ||
| 56 | HttpApiEndpoint.get("get")`/organizations/${organizationIdParam}` | ||
| 57 | .addSuccess(OrganizationSchema) | ||
| 58 | .addError(ControlPlaneBadRequestError) | ||
| 59 | .addError(ControlPlaneUnauthorizedError) | ||
| 60 | .addError(ControlPlaneForbiddenError) | ||
| 61 | .addError(ControlPlaneNotFoundError) | ||
| 62 | .addError(ControlPlaneStorageError), | ||
| 63 | ) | ||
| 64 | .add( | ||
| 65 | HttpApiEndpoint.patch("update")`/organizations/${organizationIdParam}` | ||
| 66 | .setPayload(UpdateOrganizationPayloadSchema) | ||
| 67 | .addSuccess(OrganizationSchema) | ||
| 68 | .addError(ControlPlaneBadRequestError) | ||
| 69 | .addError(ControlPlaneUnauthorizedError) | ||
| 70 | .addError(ControlPlaneForbiddenError) | ||
| 71 | .addError(ControlPlaneNotFoundError) | ||
| 72 | .addError(ControlPlaneStorageError), | ||
| 73 | ) | ||
| 74 | .add( | ||
| 75 | HttpApiEndpoint.del("remove")`/organizations/${organizationIdParam}` | ||
| 76 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | ||
| 77 | .addError(ControlPlaneBadRequestError) | ||
| 78 | .addError(ControlPlaneUnauthorizedError) | ||
| 79 | .addError(ControlPlaneForbiddenError) | ||
| 80 | .addError(ControlPlaneStorageError), | ||
| 81 | ) | ||
| 82 | .prefix("/v1") {} | ||
| @@ -1,75 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | ||
| 2 | import type { OrganizationId } from "#schema"; | ||
| 3 | |||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | ||
| 6 | createOrganization, | ||
| 7 | getOrganization, | ||
| 8 | listOrganizations, | ||
| 9 | removeOrganization, | ||
| 10 | updateOrganization, | ||
| 11 | } from "../../runtime/organizations-operations"; | ||
| 12 | |||
| 13 | import { ControlPlaneApi } from "../api"; | ||
| 14 | import { withRequestActor } from "../http-auth"; | ||
| 15 | |||
| 16 | const requireReadOrganizations = requirePermission({ | ||
| 17 | permission: "organizations:read", | ||
| 18 | }); | ||
| 19 | |||
| 20 | const requireManageOrganizations = (organizationId: OrganizationId) => | ||
| 21 | requirePermission({ | ||
| 22 | permission: "organizations:manage", | ||
| 23 | organizationId, | ||
| 24 | }); | ||
| 25 | |||
| 26 | export const ControlPlaneOrganizationsLive = HttpApiBuilder.group( | ||
| 27 | ControlPlaneApi, | ||
| 28 | "organizations", | ||
| 29 | (handlers) => | ||
| 30 | handlers | ||
| 31 | .handle("list", () => | ||
| 32 | withRequestActor("organizations.list", (actor) => | ||
| 33 | withPolicy(requireReadOrganizations)( | ||
| 34 | listOrganizations({ | ||
| 35 | accountId: actor.principal.accountId, | ||
| 36 | }), | ||
| 37 | ), | ||
| 38 | ), | ||
| 39 | ) | ||
| 40 | .handle("create", ({ payload }) => | ||
| 41 | withRequestActor("organizations.create", (actor) => | ||
| 42 | createOrganization({ | ||
| 43 | payload, | ||
| 44 | createdByAccountId: actor.principal.accountId, | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | ) | ||
| 48 | .handle("get", ({ path }) => | ||
| 49 | withRequestActor("organizations.get", (actor) => | ||
| 50 | withPolicy(requireReadOrganizations)( | ||
| 51 | getOrganization({ | ||
| 52 | organizationId: path.organizationId, | ||
| 53 | accountId: actor.principal.accountId, | ||
| 54 | }), | ||
| 55 | ), | ||
| 56 | ), | ||
| 57 | ) | ||
| 58 | .handle("update", ({ path, payload }) => | ||
| 59 | withRequestActor("organizations.update", () => | ||
| 60 | withPolicy(requireManageOrganizations(path.organizationId))( | ||
| 61 | updateOrganization({ | ||
| 62 | organizationId: path.organizationId, | ||
| 63 | payload, | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ), | ||
| 67 | ) | ||
| 68 | .handle("remove", ({ path }) => | ||
| 69 | withRequestActor("organizations.remove", () => | ||
| 70 | withPolicy(requireManageOrganizations(path.organizationId))( | ||
| 71 | removeOrganization({ organizationId: path.organizationId }), | ||
| 72 | ), | ||
| 73 | ), | ||
| 74 | ), | ||
| 75 | ); | ||
| @@ -5,38 +5,15 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | CreateExecutionPayloadSchema, | 6 | CreateExecutionPayloadSchema, |
| 7 | } from "./executions/api"; | 7 | } from "./executions/api"; |
| 8 | import { | ||
| 9 | CreateOrganizationPayloadSchema, | ||
| 10 | UpdateOrganizationPayloadSchema, | ||
| 11 | } from "./organizations/api"; | ||
| 12 | import { CreatePolicyPayloadSchema } from "./policies/api"; | 8 | import { CreatePolicyPayloadSchema } from "./policies/api"; |
| 13 | import { | 9 | import { |
| 14 | ConnectSourcePayloadSchema, | 10 | ConnectSourcePayloadSchema, |
| 15 | CreateSourcePayloadSchema, | 11 | CreateSourcePayloadSchema, |
| 16 | UpdateSourcePayloadSchema, | 12 | UpdateSourcePayloadSchema, |
| 17 | } from "./sources/api"; | 13 | } from "./sources/api"; |
| 18 | import { CreateWorkspacePayloadSchema } from "./workspaces/api"; | ||
| 19 | 14 | ||
| 20 | describe("control-plane payload schemas", () => { | 15 | describe("control-plane payload schemas", () => { |
| 21 | it("normalizes trimmed strings at decode time", () => { | 16 | it("normalizes trimmed strings at decode time", () => { |
| 22 | expect( | ||
| 23 | Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({ | ||
| 24 | name: " Acme ", | ||
| 25 | slug: " acme ", | ||
| 26 | }), | ||
| 27 | ).toEqual({ | ||
| 28 | name: "Acme", | ||
| 29 | slug: "acme", | ||
| 30 | }); | ||
| 31 | |||
| 32 | expect( | ||
| 33 | Schema.decodeUnknownSync(CreateWorkspacePayloadSchema)({ | ||
| 34 | name: " Primary ", | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | name: "Primary", | ||
| 38 | }); | ||
| 39 | |||
| 40 | expect( | 17 | expect( |
| 41 | Schema.decodeUnknownSync(CreateSourcePayloadSchema)({ | 18 | Schema.decodeUnknownSync(CreateSourcePayloadSchema)({ |
| 42 | name: " Github ", | 19 | name: " Github ", |
| @@ -101,18 +78,6 @@ describe("control-plane payload schemas", () => { · 58 unmodified lines | |||
| 101 | }); | 78 | }); |
| 102 | 79 | ||
| 103 | it("rejects blank strings for normalized string fields", () => { | 80 | it("rejects blank strings for normalized string fields", () => { |
| 104 | throws(() => | ||
| 105 | Schema.decodeUnknownSync(CreateOrganizationPayloadSchema)({ | ||
| 106 | name: " ", | ||
| 107 | }) | ||
| 108 | ); | ||
| 109 | |||
| 110 | throws(() => | ||
| 111 | Schema.decodeUnknownSync(UpdateOrganizationPayloadSchema)({ | ||
| 112 | name: " ", | ||
| 113 | }) | ||
| 114 | ); | ||
| 115 | |||
| 116 | throws(() => | 81 | throws(() => |
| 117 | Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({ | 82 | Schema.decodeUnknownSync(UpdateSourcePayloadSchema)({ |
| 118 | endpoint: " ", | 83 | endpoint: " ", |
| @@ -1,7 +1,6 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 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 | ||
| 52 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; | 51 | export type UpdatePolicyPayload = typeof UpdatePolicyPayloadSchema.Type; |
| 53 | 52 | ||
| 54 | const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | ||
| 55 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | 53 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 56 | const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema); | 54 | const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema); |
| 57 | 55 | ||
| 58 | export class PoliciesApi extends HttpApiGroup.make("policies") | 56 | export class PoliciesApi extends HttpApiGroup.make("policies") |
| 59 | .add( | ||
| 60 | HttpApiEndpoint.get("listOrganization")`/organizations/${organizationIdParam}/policies` | ||
| 61 | .addSuccess(Schema.Array(PolicySchema)) | ||
| 62 | .addError(ControlPlaneBadRequestError) | ||
| 63 | .addError(ControlPlaneUnauthorizedError) | ||
| 64 | .addError(ControlPlaneForbiddenError) | ||
| 65 | .addError(ControlPlaneNotFoundError) | ||
| 66 | .addError(ControlPlaneStorageError), | ||
| 67 | ) | ||
| 68 | .add( | ||
| 69 | HttpApiEndpoint.post("createOrganization")`/organizations/${organizationIdParam}/policies` | ||
| 70 | .setPayload(CreatePolicyPayloadSchema) | ||
| 71 | .addSuccess(PolicySchema) | ||
| 72 | .addError(ControlPlaneBadRequestError) | ||
| 73 | .addError(ControlPlaneUnauthorizedError) | ||
| 74 | .addError(ControlPlaneForbiddenError) | ||
| 75 | .addError(ControlPlaneNotFoundError) | ||
| 76 | .addError(ControlPlaneStorageError), | ||
| 77 | ) | ||
| 78 | .add( | ||
| 79 | HttpApiEndpoint.get("getOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}` | ||
| 80 | .addSuccess(PolicySchema) | ||
| 81 | .addError(ControlPlaneBadRequestError) | ||
| 82 | .addError(ControlPlaneUnauthorizedError) | ||
| 83 | .addError(ControlPlaneForbiddenError) | ||
| 84 | .addError(ControlPlaneNotFoundError) | ||
| 85 | .addError(ControlPlaneStorageError), | ||
| 86 | ) | ||
| 87 | .add( | ||
| 88 | HttpApiEndpoint.patch("updateOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}` | ||
| 89 | .setPayload(UpdatePolicyPayloadSchema) | ||
| 90 | .addSuccess(PolicySchema) | ||
| 91 | .addError(ControlPlaneBadRequestError) | ||
| 92 | .addError(ControlPlaneUnauthorizedError) | ||
| 93 | .addError(ControlPlaneForbiddenError) | ||
| 94 | .addError(ControlPlaneNotFoundError) | ||
| 95 | .addError(ControlPlaneStorageError), | ||
| 96 | ) | ||
| 97 | .add( | ||
| 98 | HttpApiEndpoint.del("removeOrganization")`/organizations/${organizationIdParam}/policies/${policyIdParam}` | ||
| 99 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | ||
| 100 | .addError(ControlPlaneBadRequestError) | ||
| 101 | .addError(ControlPlaneUnauthorizedError) | ||
| 102 | .addError(ControlPlaneForbiddenError) | ||
| 103 | .addError(ControlPlaneNotFoundError) | ||
| 104 | .addError(ControlPlaneStorageError), | ||
| 105 | ) | ||
| 106 | .add( | 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)) |
| @@ -1,34 +1,17 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import type { OrganizationId, WorkspaceId } from "#schema"; | 2 | import type { WorkspaceId } from "#schema"; |
| 3 | 3 | ||
| 4 | import { requirePermission, withPolicy } from "#domain"; | 4 | import { requirePermission, withPolicy } from "#domain"; |
| 5 | import { | 5 | import { |
| 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 | ||
| 18 | import { ControlPlaneApi } from "../api"; | 13 | import { ControlPlaneApi } from "../api"; |
| 19 | import { withRequestActor, withWorkspaceRequestActor } from "../http-auth"; | 14 | import { withWorkspaceRequestActor } from "../http-auth"; |
| 20 | |||
| 21 | const requireReadOrganizationPolicies = (organizationId: OrganizationId) => | ||
| 22 | requirePermission({ | ||
| 23 | permission: "policies:read", | ||
| 24 | organizationId, | ||
| 25 | }); | ||
| 26 | |||
| 27 | const requireWriteOrganizationPolicies = (organizationId: OrganizationId) => | ||
| 28 | requirePermission({ | ||
| 29 | permission: "policies:write", | ||
| 30 | organizationId, | ||
| 31 | }); | ||
| 32 | 15 | ||
| 33 | const requireReadPolicies = (workspaceId: WorkspaceId) => | 16 | const 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 | handlers | 32 | 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))( |
| @@ -1,83 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | OrganizationIdSchema, | ||
| 4 | WorkspaceIdSchema, | ||
| 5 | WorkspaceSchema, | ||
| 6 | } from "#schema"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | ControlPlaneBadRequestError, | ||
| 11 | ControlPlaneForbiddenError, | ||
| 12 | ControlPlaneNotFoundError, | ||
| 13 | ControlPlaneStorageError, | ||
| 14 | ControlPlaneUnauthorizedError, | ||
| 15 | } from "../errors"; | ||
| 16 | import { | ||
| 17 | OptionalTrimmedNonEmptyStringSchema, | ||
| 18 | TrimmedNonEmptyStringSchema, | ||
| 19 | } from "../string-schemas"; | ||
| 20 | |||
| 21 | export const CreateWorkspacePayloadSchema = Schema.Struct({ | ||
| 22 | name: TrimmedNonEmptyStringSchema, | ||
| 23 | }); | ||
| 24 | |||
| 25 | export type CreateWorkspacePayload = typeof CreateWorkspacePayloadSchema.Type; | ||
| 26 | |||
| 27 | export const UpdateWorkspacePayloadSchema = Schema.Struct({ | ||
| 28 | name: OptionalTrimmedNonEmptyStringSchema, | ||
| 29 | }); | ||
| 30 | |||
| 31 | export type UpdateWorkspacePayload = typeof UpdateWorkspacePayloadSchema.Type; | ||
| 32 | |||
| 33 | const organizationIdParam = HttpApiSchema.param("organizationId", OrganizationIdSchema); | ||
| 34 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | ||
| 35 | |||
| 36 | export class WorkspacesApi extends HttpApiGroup.make("workspaces") | ||
| 37 | .add( | ||
| 38 | HttpApiEndpoint.get("list")`/organizations/${organizationIdParam}/workspaces` | ||
| 39 | .addSuccess(Schema.Array(WorkspaceSchema)) | ||
| 40 | .addError(ControlPlaneBadRequestError) | ||
| 41 | .addError(ControlPlaneUnauthorizedError) | ||
| 42 | .addError(ControlPlaneForbiddenError) | ||
| 43 | .addError(ControlPlaneNotFoundError) | ||
| 44 | .addError(ControlPlaneStorageError), | ||
| 45 | ) | ||
| 46 | .add( | ||
| 47 | HttpApiEndpoint.post("create")`/organizations/${organizationIdParam}/workspaces` | ||
| 48 | .setPayload(CreateWorkspacePayloadSchema) | ||
| 49 | .addSuccess(WorkspaceSchema) | ||
| 50 | .addError(ControlPlaneBadRequestError) | ||
| 51 | .addError(ControlPlaneUnauthorizedError) | ||
| 52 | .addError(ControlPlaneForbiddenError) | ||
| 53 | .addError(ControlPlaneNotFoundError) | ||
| 54 | .addError(ControlPlaneStorageError), | ||
| 55 | ) | ||
| 56 | .add( | ||
| 57 | HttpApiEndpoint.get("get")`/workspaces/${workspaceIdParam}` | ||
| 58 | .addSuccess(WorkspaceSchema) | ||
| 59 | .addError(ControlPlaneBadRequestError) | ||
| 60 | .addError(ControlPlaneUnauthorizedError) | ||
| 61 | .addError(ControlPlaneForbiddenError) | ||
| 62 | .addError(ControlPlaneNotFoundError) | ||
| 63 | .addError(ControlPlaneStorageError), | ||
| 64 | ) | ||
| 65 | .add( | ||
| 66 | HttpApiEndpoint.patch("update")`/workspaces/${workspaceIdParam}` | ||
| 67 | .setPayload(UpdateWorkspacePayloadSchema) | ||
| 68 | .addSuccess(WorkspaceSchema) | ||
| 69 | .addError(ControlPlaneBadRequestError) | ||
| 70 | .addError(ControlPlaneUnauthorizedError) | ||
| 71 | .addError(ControlPlaneForbiddenError) | ||
| 72 | .addError(ControlPlaneNotFoundError) | ||
| 73 | .addError(ControlPlaneStorageError), | ||
| 74 | ) | ||
| 75 | .add( | ||
| 76 | HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}` | ||
| 77 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | ||
| 78 | .addError(ControlPlaneBadRequestError) | ||
| 79 | .addError(ControlPlaneUnauthorizedError) | ||
| 80 | .addError(ControlPlaneForbiddenError) | ||
| 81 | .addError(ControlPlaneStorageError), | ||
| 82 | ) | ||
| 83 | .prefix("/v1") {} | ||
| @@ -1,89 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | ||
| 2 | import type { OrganizationId, WorkspaceId } from "#schema"; | ||
| 3 | |||
| 4 | import { requirePermission, withPolicy } from "#domain"; | ||
| 5 | import { | ||
| 6 | createWorkspace, | ||
| 7 | getWorkspace, | ||
| 8 | listWorkspaces, | ||
| 9 | removeWorkspace, | ||
| 10 | updateWorkspace, | ||
| 11 | } from "../../runtime/organizations-operations"; | ||
| 12 | |||
| 13 | import { ControlPlaneApi } from "../api"; | ||
| 14 | import { | ||
| 15 | withRequestActor, | ||
| 16 | withWorkspaceRequestActor, | ||
| 17 | } from "../http-auth"; | ||
| 18 | |||
| 19 | const requireReadWorkspace = (workspaceId: WorkspaceId) => | ||
| 20 | requirePermission({ | ||
| 21 | permission: "workspace:read", | ||
| 22 | workspaceId, | ||
| 23 | }); | ||
| 24 | |||
| 25 | const requireManageWorkspace = (workspaceId: WorkspaceId) => | ||
| 26 | requirePermission({ | ||
| 27 | permission: "workspace:manage", | ||
| 28 | workspaceId, | ||
| 29 | }); | ||
| 30 | |||
| 31 | const requireOrganizationWorkspaceRead = (organizationId: OrganizationId) => | ||
| 32 | requirePermission({ | ||
| 33 | permission: "workspace:read", | ||
| 34 | organizationId, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const requireOrganizationWorkspaceManage = (organizationId: OrganizationId) => | ||
| 38 | requirePermission({ | ||
| 39 | permission: "workspace:manage", | ||
| 40 | organizationId, | ||
| 41 | }); | ||
| 42 | |||
| 43 | export const ControlPlaneWorkspacesLive = HttpApiBuilder.group( | ||
| 44 | ControlPlaneApi, | ||
| 45 | "workspaces", | ||
| 46 | (handlers) => | ||
| 47 | handlers | ||
| 48 | .handle("list", ({ path }) => | ||
| 49 | withRequestActor("workspaces.list", () => | ||
| 50 | withPolicy( | ||
| 51 | requireOrganizationWorkspaceRead(path.organizationId), | ||
| 52 | )(listWorkspaces(path.organizationId)) | ||
| 53 | ), | ||
| 54 | ) | ||
| 55 | .handle("create", ({ path, payload }) => | ||
| 56 | withRequestActor("workspaces.create", (actor) => | ||
| 57 | withPolicy( | ||
| 58 | requireOrganizationWorkspaceManage(path.organizationId), | ||
| 59 | )( | ||
| 60 | createWorkspace({ | ||
| 61 | organizationId: path.organizationId, | ||
| 62 | payload, | ||
| 63 | createdByAccountId: actor.principal.accountId, | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ), | ||
| 67 | ) | ||
| 68 | .handle("get", ({ path }) => | ||
| 69 | withWorkspaceRequestActor("workspaces.get", path.workspaceId, () => | ||
| 70 | withPolicy(requireReadWorkspace(path.workspaceId))( | ||
| 71 | getWorkspace(path.workspaceId), | ||
| 72 | ), | ||
| 73 | ), | ||
| 74 | ) | ||
| 75 | .handle("update", ({ path, payload }) => | ||
| 76 | withWorkspaceRequestActor("workspaces.update", path.workspaceId, () => | ||
| 77 | withPolicy(requireManageWorkspace(path.workspaceId))( | ||
| 78 | updateWorkspace({ workspaceId: path.workspaceId, payload }), | ||
| 79 | ), | ||
| 80 | ), | ||
| 81 | ) | ||
| 82 | .handle("remove", ({ path }) => | ||
| 83 | withWorkspaceRequestActor("workspaces.remove", path.workspaceId, () => | ||
| 84 | withPolicy(requireManageWorkspace(path.workspaceId))( | ||
| 85 | removeWorkspace({ workspaceId: path.workspaceId }), | ||
| 86 | ), | ||
| 87 | ), | ||
| 88 | ), | ||
| 89 | ); | ||
| @@ -27,7 +27,6 @@ import { · 26 unmodified lines | |||
| 27 | type SqlControlPlanePersistence, | 27 | type SqlControlPlanePersistence, |
| 28 | } from "./index"; | 28 | } from "./index"; |
| 29 | import { runCodeMigrations } from "./code-migrations"; | 29 | import { runCodeMigrations } from "./code-migrations"; |
| 30 | import { loadSourceById } from "../runtime/source-store"; | ||
| 31 | 30 | ||
| 32 | const openApiBindingConfigJson = (specUrl: string): string => | 31 | const 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 | ||
| @@ -3,16 +3,11 @@ import { ControlPlaneAuthHeaders } from "../auth-headers"; · 2 unmodified lines | |||
| 3 | import { | 3 | import { |
| 4 | ControlPlaneActorResolver, | 4 | ControlPlaneActorResolver, |
| 5 | type ControlPlaneActorResolverShape, | 5 | type ControlPlaneActorResolverShape, |
| 6 | deriveWorkspaceMembershipsForPrincipal, | ||
| 7 | } from "#api"; | 6 | } from "#api"; |
| 8 | import { | 7 | import { |
| 9 | ActorUnauthenticatedError, | 8 | ActorUnauthenticatedError, |
| 10 | createActor, | 9 | createActor, |
| 11 | } from "#domain"; | 10 | } from "#domain"; |
| 12 | import { | ||
| 13 | SqlControlPlaneRowsService, | ||
| 14 | type SqlControlPlaneRows, | ||
| 15 | } from "#persistence"; | ||
| 16 | import { | 11 | import { |
| 17 | PrincipalProviderSchema, | 12 | PrincipalProviderSchema, |
| 18 | PrincipalSchema, | 13 | PrincipalSchema, |
| @@ -23,6 +18,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | |||
| 23 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| 24 | import * as ParseResult from "effect/ParseResult"; | 19 | import * as ParseResult from "effect/ParseResult"; |
| 25 | import * as Schema from "effect/Schema"; | 20 | import * as Schema from "effect/Schema"; |
| 21 | import { getRuntimeLocalWorkspaceOption } from "./local-runtime-context"; | ||
| 26 | 22 | ||
| 27 | export { ControlPlaneAuthHeaders }; | 23 | export { ControlPlaneAuthHeaders }; |
| 28 | 24 | ||
| @@ -95,57 +91,44 @@ const readPrincipalFromHeaders = ( · 66 unmodified lines | |||
| 95 | ); | 91 | ); |
| 96 | }); | 92 | }); |
| 97 | 93 | ||
| 98 | export const createHeaderActorResolver = ( | 94 | const createLocalActor = (principal: Principal) => |
| 99 | rows: SqlControlPlaneRows, | 95 | createActor({ |
| 100 | ): ControlPlaneActorResolverShape => ({ | 96 | principal, |
| 97 | workspaceMemberships: [], | ||
| 98 | organizationMemberships: [], | ||
| 99 | }); | ||
| 100 | |||
| 101 | export 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.organizationMemberships | 105 | 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.organizationMemberships | 111 | 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 | actorResolver | 138 | 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 | ); | ||
| @@ -19,7 +19,7 @@ import { · 18 unmodified lines | |||
| 19 | import type { ToolPath } from "@executor/codemode-core"; | 19 | import type { ToolPath } from "@executor/codemode-core"; |
| 20 | 20 | ||
| 21 | import { | 21 | import { |
| 22 | createSqlControlPlaneRuntime, | 22 | createControlPlaneRuntime, |
| 23 | LiveExecutionManagerService, | 23 | LiveExecutionManagerService, |
| 24 | } from "./index"; | 24 | } from "./index"; |
| 25 | import { createSourceFromPayload } from "./source-definitions"; | 25 | import { createSourceFromPayload } from "./source-definitions"; |
| @@ -28,7 +28,7 @@ import { persistSource } from "./source-store"; · 2 unmodified lines | |||
| 28 | import { withControlPlaneClient } from "./test-http-client"; | 28 | import { withControlPlaneClient } from "./test-http-client"; |
| 29 | 29 | ||
| 30 | const makeRuntime = Effect.acquireRelease( | 30 | const 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 | }); |
| @@ -9,7 +9,7 @@ import { makeToolInvokerFromTools } from "@executor/codemode-core"; · 8 unmodified lines | |||
| 9 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; | 9 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 12 | createSqlControlPlaneRuntime, | 12 | createControlPlaneRuntime, |
| 13 | } from "./index"; | 13 | } from "./index"; |
| 14 | import { withControlPlaneClient } from "./test-http-client"; | 14 | import { withControlPlaneClient } from "./test-http-client"; |
| 15 | 15 | ||
| @@ -40,7 +40,7 @@ const makeExecutionResolver = () => { · 24 unmodified lines | |||
| 40 | }; | 40 | }; |
| 41 | 41 | ||
| 42 | const makeRuntime = Effect.acquireRelease( | 42 | const 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(), |
| @@ -18,7 +18,7 @@ import { discoverMcpToolsFromConnector } from "@executor/codemode-mcp"; · 17 unmodified lines | |||
| 18 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; | 18 | import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; |
| 19 | 19 | ||
| 20 | import { | 20 | import { |
| 21 | createSqlControlPlaneRuntime, | 21 | createControlPlaneRuntime, |
| 22 | type ResolveExecutionEnvironment, | 22 | type ResolveExecutionEnvironment, |
| 23 | } from "./index"; | 23 | } from "./index"; |
| 24 | import { withControlPlaneClient } from "./test-http-client"; | 24 | import { 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), |
| @@ -88,12 +88,12 @@ export type RuntimeControlPlaneOptions = { · 87 unmodified lines | |||
| 88 | const detailsFromCause = (cause: unknown): string => | 88 | const detailsFromCause = (cause: unknown): string => |
| 89 | cause instanceof Error ? cause.message : String(cause); | 89 | cause instanceof Error ? cause.message : String(cause); |
| 90 | 90 | ||
| 91 | const toLocalInstallationBootstrapError = ( | 91 | const 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 | ||
| 168 | export type SqlControlPlaneRuntime = { | 168 | export 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 | ||
| 175 | export type CreateSqlControlPlaneRuntimeOptions = CreateSqlRuntimeOptions | 175 | export type CreateControlPlaneRuntimeOptions = CreateSqlRuntimeOptions |
| 176 | & RuntimeControlPlaneOptions; | 176 | & RuntimeControlPlaneOptions; |
| 177 | 177 | ||
| 178 | export const createSqlControlPlaneRuntime = ( | 178 | export 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, |
| @@ -2,17 +2,16 @@ import { existsSync, mkdtempSync } from "node:fs"; · 1 unmodified line | |||
| 2 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import { assertTrue } from "@effect/vitest/utils"; | ||
| 6 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 7 | 6 | ||
| 8 | import { createSqlControlPlaneRuntime } from "./index"; | 7 | import { createControlPlaneRuntime } from "./index"; |
| 9 | import { getOrProvisionLocalInstallation } from "./local-installation"; | 8 | import { getOrProvisionLocalInstallation } from "./local-installation"; |
| 10 | import { resolveLocalWorkspaceContext } from "./local-config"; | 9 | import { resolveLocalWorkspaceContext } from "./local-config"; |
| 11 | 10 | ||
| 12 | const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-")); | 11 | const TEST_WORKSPACE_ROOT = mkdtempSync(join(tmpdir(), "executor-local-installation-")); |
| 13 | 12 | ||
| 14 | const makeRuntime = Effect.acquireRelease( | 13 | const 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 | ||
| 22 | describe("local-installation", () => { | 21 | describe("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", () => |
| @@ -1,283 +1,57 @@ | |||
| 1 | import { type SqlControlPlaneRows } from "#persistence"; | 1 | import { createHash } from "node:crypto"; |
| 2 | |||
| 3 | import type { SqlControlPlaneRows } from "#persistence"; | ||
| 2 | import { | 4 | import { |
| 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"; |
| 14 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 15 | import * as Option from "effect/Option"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | defaultWorkspaceDisplayName, | ||
| 19 | type ResolvedLocalWorkspaceContext, | ||
| 20 | } from "./local-config"; | ||
| 21 | 12 | ||
| 22 | const LEGACY_LOCAL_INSTALLATION_ID = InstallationIdSchema.make("local_default"); | 13 | import type { ResolvedLocalWorkspaceContext } from "./local-config"; |
| 23 | const LOCAL_ACCOUNT_SUBJECT = "local:default"; | ||
| 24 | const PERSONAL_ORGANIZATION_SLUG = "personal"; | ||
| 25 | 14 | ||
| 26 | const trimOrNull = (value: string | null | undefined): string | null => { | 15 | const LOCAL_ACCOUNT_ID = AccountIdSchema.make("acc_local_default"); |
| 27 | if (value == null) { | 16 | const 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 | ||
| 34 | const buildAccount = (now: number): Account => { | 18 | const 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 { | 21 | const 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 | |||
| 48 | const 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 | |||
| 61 | const 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 | |||
| 78 | const 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 | ||
| 92 | const chooseWorkspaceName = ( | 24 | const 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 | |||
| 104 | const ensureLocalAccount = ( | ||
| 105 | rows: SqlControlPlaneRows, | ||
| 106 | now: number, | ||
| 107 | ): Effect.Effect<Account, unknown> => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | const existing = yield* rows.accounts.getByProviderAndSubject("local", LOCAL_ACCOUNT_SUBJECT); | ||
| 110 | if (Option.isSome(existing)) { | ||
| 111 | return existing.value; | ||
| 112 | } | ||
| 113 | |||
| 114 | const account = buildAccount(now); | ||
| 115 | yield* rows.accounts.upsert(account); | ||
| 116 | return account; | ||
| 117 | }); | ||
| 118 | |||
| 119 | const ensurePersonalOrganization = ( | ||
| 120 | rows: SqlControlPlaneRows, | ||
| 121 | accountId: Account["id"], | ||
| 122 | now: number, | ||
| 123 | ): Effect.Effect<Organization, unknown> => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const existing = yield* rows.organizations.getBySlug(PERSONAL_ORGANIZATION_SLUG); | ||
| 126 | if (Option.isSome(existing)) { | ||
| 127 | return existing.value; | ||
| 128 | } | ||
| 129 | |||
| 130 | const organization = buildOrganization(accountId, now); | ||
| 131 | const membership = buildOwnerMembership(organization.id, accountId, now); | ||
| 132 | yield* rows.organizations.insertWithOwnerMembership(organization, membership); | ||
| 133 | return organization; | ||
| 134 | }); | ||
| 135 | |||
| 136 | const ensureOwnerMembership = ( | ||
| 137 | rows: SqlControlPlaneRows, | ||
| 138 | organizationId: Organization["id"], | ||
| 139 | accountId: Account["id"], | ||
| 140 | now: number, | ||
| 141 | ): Effect.Effect<void, unknown> => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const existing = yield* rows.organizationMemberships.getByOrganizationAndAccount( | ||
| 144 | organizationId, | ||
| 145 | accountId, | ||
| 146 | ); | ||
| 147 | if (Option.isSome(existing)) { | ||
| 148 | return; | ||
| 149 | } | ||
| 150 | |||
| 151 | yield* rows.organizationMemberships.upsert( | ||
| 152 | buildOwnerMembership(organizationId, accountId, now), | ||
| 153 | ); | ||
| 154 | }); | ||
| 155 | |||
| 156 | const provisionWorkspaceForContext = (input: { | ||
| 157 | rows: SqlControlPlaneRows; | ||
| 158 | accountId: Account["id"]; | ||
| 159 | organizationId: Organization["id"]; | ||
| 160 | context: ResolvedLocalWorkspaceContext; | ||
| 161 | now: number; | ||
| 162 | }): Effect.Effect<Workspace, unknown> => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const workspaces = yield* input.rows.workspaces.listByOrganizationId(input.organizationId); | ||
| 165 | const workspaceName = chooseWorkspaceName( | ||
| 166 | defaultWorkspaceDisplayName(input.context), | ||
| 167 | workspaces, | ||
| 168 | ); | ||
| 169 | const workspace = buildWorkspace( | ||
| 170 | input.organizationId, | ||
| 171 | input.accountId, | ||
| 172 | workspaceName, | ||
| 173 | input.now, | ||
| 174 | ); | ||
| 175 | yield* input.rows.workspaces.insert(workspace); | ||
| 176 | return workspace; | ||
| 177 | }); | ||
| 178 | 28 | ||
| 179 | const createInstallationForWorkspace = (input: { | 29 | export 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 | ||
| 194 | export const loadLocalInstallation = ( | 41 | export 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 | |||
| 202 | const 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 | ||
| 230 | export const provisionLocalInstallation = (input: { | 47 | export 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 | ||
| 275 | export const getOrProvisionLocalInstallation = (input: { | 53 | export 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 | ); | ||
| @@ -187,27 +187,7 @@ export const getLocalInstallation = () => · 186 unmodified lines | |||
| 187 | const context = runtimeLocalWorkspace?.context | 187 | 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 | ||
| 213 | export const getSourceCredentialInteraction = (input: { | 193 | export const getSourceCredentialInteraction = (input: { |
| @@ -2,7 +2,7 @@ import * as Context from "effect/Context"; · 1 unmodified line | |||
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | 4 | ||
| 5 | import type { AccountId, WorkspaceId } from "#schema"; | 5 | import type { AccountId, OrganizationId, WorkspaceId } from "#schema"; |
| 6 | import type { | 6 | import 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 | |||
| 32 | export 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 | }); | ||
| @@ -1,13 +1,7 @@ | |||
| 1 | import type { SqlControlPlaneRows } from "#persistence"; | 1 | import { type Policy } from "#schema"; |
| 2 | import { | ||
| 3 | type Policy, | ||
| 4 | type Workspace, | ||
| 5 | } from "#schema"; | ||
| 6 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; | ||
| 8 | 3 | ||
| 9 | import { | 4 | import { |
| 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 | ||
| 48 | const 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 | |||
| 75 | const pruneLocalWorkspaceState = (input: { | 42 | const 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 | ||
| 124 | export const synchronizeLocalWorkspaceState = (input: { | 91 | export 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, |
| @@ -1,21 +1,14 @@ | |||
| 1 | import { | 1 | import { |
| 2 | ControlPlaneBadRequestError, | 2 | ControlPlaneBadRequestError, |
| 3 | ControlPlaneNotFoundError, | ||
| 4 | ControlPlaneStorageError, | 3 | ControlPlaneStorageError, |
| 5 | } from "../api/errors"; | 4 | } from "../api/errors"; |
| 6 | import { ControlPlanePersistenceError } from "#persistence"; | 5 | import { ControlPlanePersistenceError } from "#persistence"; |
| 7 | import type { Organization } from "#schema"; | ||
| 8 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 9 | import * as Option from "effect/Option"; | ||
| 10 | 7 | ||
| 11 | import { | 8 | import { |
| 12 | asOperationErrors, | 9 | asOperationErrors, |
| 13 | operationErrors, | ||
| 14 | type OperationErrorsLike, | 10 | type OperationErrorsLike, |
| 15 | } from "./operation-errors"; | 11 | } from "./operation-errors"; |
| 16 | import type { ControlPlaneStoreShape } from "./store"; | ||
| 17 | import { slugify } from "./slug"; | ||
| 18 | |||
| 19 | export type Mutable<T> = { -readonly [K in keyof T]: T[K] }; | 12 | export type Mutable<T> = { -readonly [K in keyof T]: T[K] }; |
| 20 | 13 | ||
| 21 | const isUniqueViolation = (error: ControlPlanePersistenceError): boolean => | 14 | const isUniqueViolation = (error: ControlPlanePersistenceError): boolean => |
| @@ -59,51 +52,3 @@ export const parseJsonString = ( · 37 unmodified lines | |||
| 59 | ); | 52 | ); |
| 60 | }, | 53 | }, |
| 61 | }); | 54 | }); |
| 62 | |||
| 63 | export const ensureUniqueOrganizationSlug = ( | ||
| 64 | store: ControlPlaneStoreShape, | ||
| 65 | baseName: string, | ||
| 66 | operation = operationErrors("organizations.create"), | ||
| 67 | ): Effect.Effect<string, ControlPlaneStorageError> => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const normalized = slugify(baseName); | ||
| 70 | const seed = normalized.length > 0 ? normalized : "item"; | ||
| 71 | |||
| 72 | let counter = 0; | ||
| 73 | while (true) { | ||
| 74 | const candidate = counter === 0 ? seed : `${seed}-${counter + 1}`; | ||
| 75 | |||
| 76 | const existing = yield* operation.child("slug_lookup").mapStorage( | ||
| 77 | store.organizations.getBySlug(candidate as Organization["slug"]), | ||
| 78 | ); | ||
| 79 | |||
| 80 | if (Option.isNone(existing)) { | ||
| 81 | return candidate; | ||
| 82 | } | ||
| 83 | |||
| 84 | counter += 1; | ||
| 85 | } | ||
| 86 | }); | ||
| 87 | |||
| 88 | export const ensureOrganizationExists = ( | ||
| 89 | store: ControlPlaneStoreShape, | ||
| 90 | operation: OperationErrorsLike, | ||
| 91 | organizationId: Organization["id"], | ||
| 92 | ): Effect.Effect<Organization, ControlPlaneNotFoundError | ControlPlaneStorageError> => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const errors = asOperationErrors(operation); | ||
| 95 | const organization = yield* errors.child("organization_lookup").mapStorage( | ||
| 96 | store.organizations.getById(organizationId), | ||
| 97 | ); | ||
| 98 | |||
| 99 | if (Option.isNone(organization)) { | ||
| 100 | return yield* Effect.fail( | ||
| 101 | errors.notFound( | ||
| 102 | "Organization not found", | ||
| 103 | `organizationId=${organizationId}`, | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | return organization.value; | ||
| 109 | }); | ||
| @@ -1,434 +0,0 @@ | |||
| 1 | import type { | ||
| 2 | CreateMembershipPayload, | ||
| 3 | UpdateMembershipPayload, | ||
| 4 | } from "../api/memberships/api"; | ||
| 5 | import type { | ||
| 6 | CreateOrganizationPayload, | ||
| 7 | UpdateOrganizationPayload, | ||
| 8 | } from "../api/organizations/api"; | ||
| 9 | import type { | ||
| 10 | CreateWorkspacePayload, | ||
| 11 | UpdateWorkspacePayload, | ||
| 12 | } from "../api/workspaces/api"; | ||
| 13 | import { | ||
| 14 | OrganizationIdSchema, | ||
| 15 | OrganizationMemberIdSchema, | ||
| 16 | WorkspaceIdSchema, | ||
| 17 | type AccountId, | ||
| 18 | type Organization, | ||
| 19 | type OrganizationId, | ||
| 20 | type OrganizationMembership, | ||
| 21 | type Workspace, | ||
| 22 | type WorkspaceId, | ||
| 23 | } from "#schema"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Option from "effect/Option"; | ||
| 26 | |||
| 27 | import { | ||
| 28 | ensureOrganizationExists, | ||
| 29 | ensureUniqueOrganizationSlug, | ||
| 30 | mapPersistenceError, | ||
| 31 | type Mutable, | ||
| 32 | } from "./operations-shared"; | ||
| 33 | import { | ||
| 34 | operationErrors, | ||
| 35 | } from "./operation-errors"; | ||
| 36 | import { | ||
| 37 | ControlPlaneStore, | ||
| 38 | type ControlPlaneStoreShape, | ||
| 39 | } from "./store"; | ||
| 40 | |||
| 41 | const organizationOps = { | ||
| 42 | list: operationErrors("organizations.list"), | ||
| 43 | create: operationErrors("organizations.create"), | ||
| 44 | get: operationErrors("organizations.get"), | ||
| 45 | update: operationErrors("organizations.update"), | ||
| 46 | remove: operationErrors("organizations.remove"), | ||
| 47 | } as const; | ||
| 48 | |||
| 49 | const membershipOps = { | ||
| 50 | list: operationErrors("memberships.list"), | ||
| 51 | create: operationErrors("memberships.create"), | ||
| 52 | update: operationErrors("memberships.update"), | ||
| 53 | remove: operationErrors("memberships.remove"), | ||
| 54 | } as const; | ||
| 55 | |||
| 56 | const workspaceOps = { | ||
| 57 | list: operationErrors("workspaces.list"), | ||
| 58 | create: operationErrors("workspaces.create"), | ||
| 59 | get: operationErrors("workspaces.get"), | ||
| 60 | update: operationErrors("workspaces.update"), | ||
| 61 | remove: operationErrors("workspaces.remove"), | ||
| 62 | } as const; | ||
| 63 | |||
| 64 | const listOrganizationsWithStore = ( | ||
| 65 | store: ControlPlaneStoreShape, | ||
| 66 | input: { accountId: AccountId }, | ||
| 67 | ) => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const memberships = yield* organizationOps.list.child("memberships").mapStorage( | ||
| 70 | store.organizationMemberships.listByAccountId(input.accountId), | ||
| 71 | ); | ||
| 72 | |||
| 73 | const activeOrganizationIds = Array.from( | ||
| 74 | new Set( | ||
| 75 | memberships | ||
| 76 | .filter((membership) => membership.status === "active") | ||
| 77 | .map((membership) => membership.organizationId), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | |||
| 81 | const organizations = yield* Effect.forEach(activeOrganizationIds, (organizationId) => | ||
| 82 | organizationOps.list.child("organization").mapStorage( | ||
| 83 | store.organizations.getById(organizationId), | ||
| 84 | ).pipe( | ||
| 85 | Effect.map((result) => (Option.isSome(result) ? result.value : null)), | ||
| 86 | )); | ||
| 87 | |||
| 88 | return organizations.filter((organization): organization is Organization => organization !== null); | ||
| 89 | }); | ||
| 90 | |||
| 91 | export const listOrganizations = (input: { accountId: AccountId }) => | ||
| 92 | Effect.flatMap(ControlPlaneStore, (store) => listOrganizationsWithStore(store, input)); | ||
| 93 | |||
| 94 | export const createOrganization = (input: { | ||
| 95 | payload: CreateOrganizationPayload; | ||
| 96 | createdByAccountId?: Organization["createdByAccountId"]; | ||
| 97 | }) => | ||
| 98 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const name = input.payload.name; | ||
| 101 | const now = Date.now(); | ||
| 102 | |||
| 103 | const slug = input.payload.slug | ||
| 104 | ? input.payload.slug | ||
| 105 | : yield* ensureUniqueOrganizationSlug(store, name, organizationOps.create); | ||
| 106 | |||
| 107 | const organization: Organization = { | ||
| 108 | id: OrganizationIdSchema.make(`org_${crypto.randomUUID()}`), | ||
| 109 | slug, | ||
| 110 | name, | ||
| 111 | status: "active", | ||
| 112 | createdByAccountId: input.createdByAccountId ?? null, | ||
| 113 | createdAt: now, | ||
| 114 | updatedAt: now, | ||
| 115 | }; | ||
| 116 | |||
| 117 | const ownerMembership: OrganizationMembership | null = input.createdByAccountId | ||
| 118 | ? { | ||
| 119 | id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`), | ||
| 120 | organizationId: organization.id, | ||
| 121 | accountId: input.createdByAccountId, | ||
| 122 | role: "owner", | ||
| 123 | status: "active", | ||
| 124 | billable: true, | ||
| 125 | invitedByAccountId: null, | ||
| 126 | joinedAt: now, | ||
| 127 | createdAt: now, | ||
| 128 | updatedAt: now, | ||
| 129 | } | ||
| 130 | : null; | ||
| 131 | |||
| 132 | yield* mapPersistenceError( | ||
| 133 | organizationOps.create, | ||
| 134 | store.organizations.insertWithOwnerMembership(organization, ownerMembership), | ||
| 135 | ); | ||
| 136 | |||
| 137 | return organization; | ||
| 138 | })); | ||
| 139 | |||
| 140 | export const getOrganization = (input: { | ||
| 141 | organizationId: OrganizationId; | ||
| 142 | accountId: AccountId; | ||
| 143 | }) => | ||
| 144 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const membership = yield* organizationOps.get.child("membership").mapStorage( | ||
| 147 | store.organizationMemberships.getByOrganizationAndAccount( | ||
| 148 | input.organizationId, | ||
| 149 | input.accountId, | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | |||
| 153 | if (Option.isNone(membership) || membership.value.status !== "active") { | ||
| 154 | return yield* Effect.fail( | ||
| 155 | organizationOps.get.notFound( | ||
| 156 | "Organization not found", | ||
| 157 | `organizationId=${input.organizationId}`, | ||
| 158 | ), | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | |||
| 162 | const existing = yield* organizationOps.get.mapStorage( | ||
| 163 | store.organizations.getById(input.organizationId), | ||
| 164 | ); | ||
| 165 | |||
| 166 | if (Option.isNone(existing)) { | ||
| 167 | return yield* Effect.fail( | ||
| 168 | organizationOps.get.notFound( | ||
| 169 | "Organization not found", | ||
| 170 | `organizationId=${input.organizationId}`, | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | |||
| 175 | return existing.value; | ||
| 176 | })); | ||
| 177 | |||
| 178 | export const updateOrganization = (input: { | ||
| 179 | organizationId: OrganizationId; | ||
| 180 | payload: UpdateOrganizationPayload; | ||
| 181 | }) => | ||
| 182 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | const patch: Partial<Omit<Mutable<Organization>, "id" | "createdAt">> = { | ||
| 185 | updatedAt: Date.now(), | ||
| 186 | }; | ||
| 187 | |||
| 188 | if (input.payload.name !== undefined) { | ||
| 189 | patch.name = input.payload.name; | ||
| 190 | } | ||
| 191 | if (input.payload.status !== undefined) { | ||
| 192 | patch.status = input.payload.status; | ||
| 193 | } | ||
| 194 | |||
| 195 | const updated = yield* mapPersistenceError( | ||
| 196 | organizationOps.update, | ||
| 197 | store.organizations.update(input.organizationId, patch), | ||
| 198 | ); | ||
| 199 | |||
| 200 | if (Option.isNone(updated)) { | ||
| 201 | return yield* Effect.fail( | ||
| 202 | organizationOps.update.notFound( | ||
| 203 | "Organization not found", | ||
| 204 | `organizationId=${input.organizationId}`, | ||
| 205 | ), | ||
| 206 | ); | ||
| 207 | } | ||
| 208 | |||
| 209 | return updated.value; | ||
| 210 | })); | ||
| 211 | |||
| 212 | export const removeOrganization = (input: { organizationId: OrganizationId }) => | ||
| 213 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 214 | organizationOps.remove.mapStorage( | ||
| 215 | store.organizations.removeTreeById(input.organizationId), | ||
| 216 | ).pipe(Effect.map((removed) => ({ removed }))) | ||
| 217 | ); | ||
| 218 | |||
| 219 | export const listMemberships = (organizationId: OrganizationId) => | ||
| 220 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 221 | Effect.gen(function* () { | ||
| 222 | yield* ensureOrganizationExists(store, membershipOps.list, organizationId); | ||
| 223 | |||
| 224 | return yield* membershipOps.list.mapStorage( | ||
| 225 | store.organizationMemberships.listByOrganizationId(organizationId), | ||
| 226 | ); | ||
| 227 | })); | ||
| 228 | |||
| 229 | export const createMembership = (input: { | ||
| 230 | organizationId: OrganizationId; | ||
| 231 | payload: CreateMembershipPayload; | ||
| 232 | }) => | ||
| 233 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | yield* ensureOrganizationExists(store, membershipOps.create, input.organizationId); | ||
| 236 | |||
| 237 | const now = Date.now(); | ||
| 238 | const membership: OrganizationMembership = { | ||
| 239 | id: OrganizationMemberIdSchema.make(`org_mem_${crypto.randomUUID()}`), | ||
| 240 | organizationId: input.organizationId, | ||
| 241 | accountId: input.payload.accountId, | ||
| 242 | role: input.payload.role, | ||
| 243 | status: input.payload.status ?? "active", | ||
| 244 | billable: input.payload.billable ?? true, | ||
| 245 | invitedByAccountId: input.payload.invitedByAccountId ?? null, | ||
| 246 | joinedAt: (input.payload.status ?? "active") === "active" ? now : null, | ||
| 247 | createdAt: now, | ||
| 248 | updatedAt: now, | ||
| 249 | }; | ||
| 250 | |||
| 251 | yield* mapPersistenceError( | ||
| 252 | membershipOps.create, | ||
| 253 | store.organizationMemberships.upsert(membership), | ||
| 254 | ); | ||
| 255 | |||
| 256 | const stored = yield* membershipOps.create.mapStorage( | ||
| 257 | store.organizationMemberships.getByOrganizationAndAccount( | ||
| 258 | input.organizationId, | ||
| 259 | input.payload.accountId, | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | if (Option.isNone(stored)) { | ||
| 263 | return yield* Effect.fail( | ||
| 264 | membershipOps.create.badRequest( | ||
| 265 | "Membership was not persisted", | ||
| 266 | `organizationId=${input.organizationId} accountId=${input.payload.accountId}`, | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | return stored.value; | ||
| 272 | })); | ||
| 273 | |||
| 274 | export const updateMembership = (input: { | ||
| 275 | organizationId: OrganizationId; | ||
| 276 | accountId: AccountId; | ||
| 277 | payload: UpdateMembershipPayload; | ||
| 278 | }) => | ||
| 279 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 280 | Effect.gen(function* () { | ||
| 281 | yield* ensureOrganizationExists(store, membershipOps.update, input.organizationId); | ||
| 282 | |||
| 283 | const existing = yield* membershipOps.update.mapStorage( | ||
| 284 | store.organizationMemberships.getByOrganizationAndAccount( | ||
| 285 | input.organizationId, | ||
| 286 | input.accountId, | ||
| 287 | ), | ||
| 288 | ); | ||
| 289 | |||
| 290 | if (Option.isNone(existing)) { | ||
| 291 | return yield* Effect.fail( | ||
| 292 | membershipOps.update.badRequest( | ||
| 293 | "Membership not found", | ||
| 294 | `organizationId=${input.organizationId} accountId=${input.accountId}`, | ||
| 295 | ), | ||
| 296 | ); | ||
| 297 | } | ||
| 298 | |||
| 299 | const current = existing.value; | ||
| 300 | const now = Date.now(); | ||
| 301 | const next: OrganizationMembership = { | ||
| 302 | ...current, | ||
| 303 | role: input.payload.role ?? current.role, | ||
| 304 | status: input.payload.status ?? current.status, | ||
| 305 | billable: input.payload.billable ?? current.billable, | ||
| 306 | joinedAt: | ||
| 307 | (input.payload.status ?? current.status) === "active" | ||
| 308 | ? (current.joinedAt ?? now) | ||
| 309 | : current.joinedAt, | ||
| 310 | updatedAt: now, | ||
| 311 | }; | ||
| 312 | |||
| 313 | yield* mapPersistenceError( | ||
| 314 | membershipOps.update, | ||
| 315 | store.organizationMemberships.upsert(next), | ||
| 316 | ); | ||
| 317 | |||
| 318 | return next; | ||
| 319 | })); | ||
| 320 | |||
| 321 | export const removeMembership = (input: { | ||
| 322 | organizationId: OrganizationId; | ||
| 323 | accountId: AccountId; | ||
| 324 | }) => | ||
| 325 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 326 | Effect.gen(function* () { | ||
| 327 | yield* ensureOrganizationExists(store, membershipOps.remove, input.organizationId); | ||
| 328 | |||
| 329 | const removed = yield* membershipOps.remove.mapStorage( | ||
| 330 | store.organizationMemberships.removeByOrganizationAndAccount( | ||
| 331 | input.organizationId, | ||
| 332 | input.accountId, | ||
| 333 | ), | ||
| 334 | ); | ||
| 335 | |||
| 336 | return { removed }; | ||
| 337 | })); | ||
| 338 | |||
| 339 | export const listWorkspaces = (organizationId: OrganizationId) => | ||
| 340 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 341 | Effect.gen(function* () { | ||
| 342 | yield* ensureOrganizationExists(store, workspaceOps.list, organizationId); | ||
| 343 | |||
| 344 | return yield* workspaceOps.list.mapStorage( | ||
| 345 | store.workspaces.listByOrganizationId(organizationId), | ||
| 346 | ); | ||
| 347 | })); | ||
| 348 | |||
| 349 | export const createWorkspace = (input: { | ||
| 350 | organizationId: OrganizationId; | ||
| 351 | payload: CreateWorkspacePayload; | ||
| 352 | createdByAccountId?: Workspace["createdByAccountId"]; | ||
| 353 | }) => | ||
| 354 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 355 | Effect.gen(function* () { | ||
| 356 | yield* ensureOrganizationExists(store, workspaceOps.create, input.organizationId); | ||
| 357 | const name = input.payload.name; | ||
| 358 | const now = Date.now(); | ||
| 359 | |||
| 360 | const workspace: Workspace = { | ||
| 361 | id: WorkspaceIdSchema.make(`ws_${crypto.randomUUID()}`), | ||
| 362 | organizationId: input.organizationId, | ||
| 363 | name, | ||
| 364 | createdByAccountId: input.createdByAccountId ?? null, | ||
| 365 | createdAt: now, | ||
| 366 | updatedAt: now, | ||
| 367 | }; | ||
| 368 | |||
| 369 | yield* mapPersistenceError( | ||
| 370 | workspaceOps.create, | ||
| 371 | store.workspaces.insert(workspace), | ||
| 372 | ); | ||
| 373 | |||
| 374 | return workspace; | ||
| 375 | })); | ||
| 376 | |||
| 377 | export const getWorkspace = (workspaceId: WorkspaceId) => | ||
| 378 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 379 | Effect.gen(function* () { | ||
| 380 | const existing = yield* workspaceOps.get.mapStorage( | ||
| 381 | store.workspaces.getById(workspaceId), | ||
| 382 | ); | ||
| 383 | |||
| 384 | if (Option.isNone(existing)) { | ||
| 385 | return yield* Effect.fail( | ||
| 386 | workspaceOps.get.notFound( | ||
| 387 | "Workspace not found", | ||
| 388 | `workspaceId=${workspaceId}`, | ||
| 389 | ), | ||
| 390 | ); | ||
| 391 | } | ||
| 392 | |||
| 393 | return existing.value; | ||
| 394 | })); | ||
| 395 | |||
| 396 | export const updateWorkspace = (input: { | ||
| 397 | workspaceId: WorkspaceId; | ||
| 398 | payload: UpdateWorkspacePayload; | ||
| 399 | }) => | ||
| 400 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 401 | Effect.gen(function* () { | ||
| 402 | const patch: Partial< | ||
| 403 | Omit<Mutable<Workspace>, "id" | "organizationId" | "createdAt"> | ||
| 404 | > = { | ||
| 405 | updatedAt: Date.now(), | ||
| 406 | }; | ||
| 407 | |||
| 408 | if (input.payload.name !== undefined) { | ||
| 409 | patch.name = input.payload.name; | ||
| 410 | } | ||
| 411 | |||
| 412 | const updated = yield* mapPersistenceError( | ||
| 413 | workspaceOps.update, | ||
| 414 | store.workspaces.update(input.workspaceId, patch), | ||
| 415 | ); | ||
| 416 | |||
| 417 | if (Option.isNone(updated)) { | ||
| 418 | return yield* Effect.fail( | ||
| 419 | workspaceOps.update.notFound( | ||
| 420 | "Workspace not found", | ||
| 421 | `workspaceId=${input.workspaceId}`, | ||
| 422 | ), | ||
| 423 | ); | ||
| 424 | } | ||
| 425 | |||
| 426 | return updated.value; | ||
| 427 | })); | ||
| 428 | |||
| 429 | export const removeWorkspace = (input: { workspaceId: WorkspaceId }) => | ||
| 430 | Effect.flatMap(ControlPlaneStore, (store) => | ||
| 431 | workspaceOps.remove.mapStorage( | ||
| 432 | store.workspaces.removeById(input.workspaceId), | ||
| 433 | ).pipe(Effect.map((removed) => ({ removed }))) | ||
| 434 | ); | ||
| @@ -7,20 +7,18 @@ import type { · 6 unmodified lines | |||
| 7 | import { | 7 | import { |
| 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"; |
| 15 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 16 | import * as Option from "effect/Option"; | ||
| 17 | 15 | ||
| 18 | import { | 16 | import { |
| 19 | loadLocalExecutorConfig, | 17 | loadLocalExecutorConfig, |
| 20 | writeProjectLocalExecutorConfig, | 18 | writeProjectLocalExecutorConfig, |
| 21 | } from "./local-config"; | 19 | } from "./local-config"; |
| 22 | import { | 20 | import { |
| 23 | getRuntimeLocalWorkspaceOption, | 21 | requireRuntimeLocalWorkspace, |
| 24 | } from "./local-runtime-context"; | 22 | } from "./local-runtime-context"; |
| 25 | import { | 23 | import { |
| 26 | loadLocalWorkspaceState, | 24 | loadLocalWorkspaceState, |
| @@ -29,17 +27,10 @@ import { · 2 unmodified lines | |||
| 29 | import { | 27 | import { |
| 30 | derivePolicyConfigKey, | 28 | derivePolicyConfigKey, |
| 31 | } from "./local-workspace-sync"; | 29 | } from "./local-workspace-sync"; |
| 32 | import { | ||
| 33 | mapPersistenceError, | ||
| 34 | parseJsonString, | ||
| 35 | type Mutable, | ||
| 36 | } from "./operations-shared"; | ||
| 37 | import { | 30 | import { |
| 38 | type OperationErrors, | 31 | type OperationErrors, |
| 39 | operationErrors, | 32 | operationErrors, |
| 40 | } from "./operation-errors"; | 33 | } from "./operation-errors"; |
| 41 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; | ||
| 42 | |||
| 43 | const policyOps = { | 34 | const 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 | |||
| 57 | const cloneJson = <T>(value: T): T => | 48 | const cloneJson = <T>(value: T): T => |
| 58 | JSON.parse(JSON.stringify(value)) as T; | 49 | JSON.parse(JSON.stringify(value)) as T; |
| 59 | 50 | ||
| 60 | const trimOrNull = (value: string | null | undefined): string | null => { | ||
| 61 | if (value == null) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | const trimmed = value.trim(); | ||
| 66 | return trimmed.length > 0 ? trimmed : null; | ||
| 67 | }; | ||
| 68 | |||
| 69 | const localPolicyIdForConfigKey = (input: { | 51 | const 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 | ||
| 124 | export const loadRuntimeLocalWorkspacePolicies = (input: { | 106 | export 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 | ||
| 189 | const loadWorkspacePolicyContext = ( | 155 | const 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 | ||
| 214 | const 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 | |||
| 239 | const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean => | 176 | const policyMatchesScope = (policy: Policy, scope: PolicyScopeContext): boolean => |
| 240 | policy.scopeType === scope.scopeType | 177 | policy.scopeType === scope.scopeType |
| 241 | && policy.organizationId === scope.organizationId | 178 | && policy.organizationId === scope.organizationId |
| @@ -270,527 +207,249 @@ const writeLocalPolicyFiles = (input: { · 28 unmodified lines | |||
| 270 | ), | 207 | ), |
| 271 | ); | 208 | ); |
| 272 | 209 | ||
| 273 | const 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 | |||
| 403 | const 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 | |||
| 452 | const 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 | |||
| 613 | const 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 | |||
| 676 | export 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 | |||
| 685 | export const listPolicies = (workspaceId: WorkspaceId) => | 210 | export 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 | |||
| 708 | export 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 | ||
| 718 | export const createPolicy = (input: { | 224 | export 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 | ||
| 728 | export 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 | ||
| 738 | export const getPolicy = (input: { | 308 | export 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, | |
| 748 | export 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 | ||
| 763 | export const updatePolicy = (input: { | 334 | export 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 | ||
| 778 | export 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 | ||
| 788 | export const removePolicy = (input: { | 414 | export 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 | }); | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 1 | import { createServer } from "node:http"; | 2 | import { createServer } from "node:http"; |
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 2 | 5 | ||
| 3 | import { describe, expect, it } from "vitest"; | 6 | import { describe, expect, it } from "vitest"; |
| 4 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| @@ -26,6 +29,17 @@ import { · 21 unmodified lines | |||
| 26 | createTerminalSourceAuthSessionPatch, | 29 | createTerminalSourceAuthSessionPatch, |
| 27 | } from "./source-auth-service"; | 30 | } from "./source-auth-service"; |
| 28 | import { createLiveExecutionManager } from "./live-execution"; | 31 | import { createLiveExecutionManager } from "./live-execution"; |
| 32 | import { | ||
| 33 | loadLocalExecutorConfig, | ||
| 34 | resolveLocalWorkspaceContext, | ||
| 35 | } from "./local-config"; | ||
| 36 | import { | ||
| 37 | readLocalSourceArtifact, | ||
| 38 | } from "./local-source-artifacts"; | ||
| 39 | import { | ||
| 40 | RuntimeLocalWorkspaceService, | ||
| 41 | type RuntimeLocalWorkspaceState, | ||
| 42 | } from "./local-runtime-context"; | ||
| 29 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | 43 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; |
| 30 | import { resolveSourceAuthMaterial } from "./source-auth-material"; | 44 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| 31 | 45 | ||
| @@ -163,6 +177,38 @@ const makePersistence = async (): Promise<SqlControlPlanePersistence> => · 131 unmodified lines | |||
| 163 | }), | 177 | }), |
| 164 | ); | 178 | ); |
| 165 | 179 | ||
| 180 | const makeRuntimeLocalWorkspaceState = async ( | ||
| 181 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, | ||
| 182 | accountId: ReturnType<typeof AccountIdSchema.make>, | ||
| 183 | ): Promise<RuntimeLocalWorkspaceState> => | ||
| 184 | Effect.runPromise( | ||
| 185 | Effect.gen(function* () { | ||
| 186 | const workspaceRoot = mkdtempSync( | ||
| 187 | join(tmpdir(), "executor-source-auth-service-"), | ||
| 188 | ); | ||
| 189 | const context = yield* 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 | |||
| 166 | describe("source-auth-service", () => { | 212 | describe("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 | ||
| @@ -1,11 +1,14 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 1 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 3 | 7 | ||
| 4 | import { | 8 | import { |
| 5 | AccountIdSchema, | 9 | AccountIdSchema, |
| 6 | OrganizationIdSchema, | 10 | type LocalConfigSource, |
| 7 | SourceIdSchema, | 11 | SourceIdSchema, |
| 8 | SourceRecipeIdSchema, | ||
| 9 | SourceRecipeRevisionIdSchema, | 12 | SourceRecipeRevisionIdSchema, |
| 10 | SourceRecipeSchemaBundleIdSchema, | 13 | SourceRecipeSchemaBundleIdSchema, |
| 11 | WorkspaceIdSchema, | 14 | WorkspaceIdSchema, |
| @@ -17,11 +20,27 @@ import { · 5 unmodified lines | |||
| 17 | type SqlControlPlanePersistence, | 20 | type SqlControlPlanePersistence, |
| 18 | } from "#persistence"; | 21 | } from "#persistence"; |
| 19 | 22 | ||
| 23 | import { | ||
| 24 | loadLocalExecutorConfig, | ||
| 25 | resolveLocalWorkspaceContext, | ||
| 26 | writeProjectLocalExecutorConfig, | ||
| 27 | } from "./local-config"; | ||
| 28 | import { | ||
| 29 | buildLocalSourceArtifact, | ||
| 30 | writeLocalSourceArtifact, | ||
| 31 | } from "./local-source-artifacts"; | ||
| 20 | import { | 32 | import { |
| 21 | getSourceInspection, | 33 | getSourceInspection, |
| 22 | getSourceInspectionSchemaBundle, | 34 | getSourceInspectionSchemaBundle, |
| 23 | getSourceInspectionToolDetail, | 35 | getSourceInspectionToolDetail, |
| 24 | } from "./source-inspection"; | 36 | } from "./source-inspection"; |
| 37 | import { | ||
| 38 | RuntimeLocalWorkspaceService, | ||
| 39 | type RuntimeLocalWorkspaceState, | ||
| 40 | } from "./local-runtime-context"; | ||
| 41 | import { | ||
| 42 | writeLocalWorkspaceState, | ||
| 43 | } from "./local-workspace-state"; | ||
| 25 | import { ControlPlaneStore } from "./store"; | 44 | import { ControlPlaneStore } from "./store"; |
| 26 | 45 | ||
| 27 | const makePersistence = () => | 46 | const makePersistence = () => |
| @@ -31,30 +50,37 @@ const makePersistence = () => · 3 unmodified lines | |||
| 31 | }), | 50 | }), |
| 32 | ); | 51 | ); |
| 33 | 52 | ||
| 34 | const seedWorkspace = async (input: { | 53 | const makeRuntimeLocalWorkspaceState = ( |
| 35 | persistence: SqlControlPlanePersistence; | 54 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, |
| 36 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | 55 | accountId: ReturnType<typeof AccountIdSchema.make>, |
| 37 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; | 56 | ) => |
| 38 | accountId: ReturnType<typeof AccountIdSchema.make>; | 57 | Effect.runPromise( |
| 39 | }) => { | 58 | Effect.gen(function* () { |
| 40 | await Effect.runPromise(input.persistence.rows.organizations.insert({ | 59 | const workspaceRoot = mkdtempSync( |
| 41 | id: input.organizationId, | 60 | join(tmpdir(), "executor-source-inspection-"), |
| 42 | slug: `org-${input.organizationId}`, | 61 | ); |
| 43 | name: `Org ${input.organizationId}`, | 62 | const context = yield* 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 | ||
| 59 | const makeSource = (input: { | 85 | const 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 | ||
| 162 | const 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 | |||
| 136 | describe("source-inspection", () => { | 182 | describe("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 | ||
| @@ -11,7 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | writeLocalSourceArtifact, | 11 | writeLocalSourceArtifact, |
| 12 | } from "./local-source-artifacts"; | 12 | } from "./local-source-artifacts"; |
| 13 | import { | 13 | import { |
| 14 | getRuntimeLocalWorkspaceOption, | 14 | requireRuntimeLocalWorkspace, |
| 15 | } from "./local-runtime-context"; | 15 | } from "./local-runtime-context"; |
| 16 | import { | 16 | import { |
| 17 | loadLocalWorkspaceState, | 17 | loadLocalWorkspaceState, |
| @@ -29,7 +29,6 @@ import { · 11 unmodified lines | |||
| 29 | import type { | 29 | import type { |
| 30 | ResolveSecretMaterial as ResolveSourceSecretMaterial, | 30 | ResolveSecretMaterial as ResolveSourceSecretMaterial, |
| 31 | } from "./secret-material-providers"; | 31 | } from "./secret-material-providers"; |
| 32 | import { persistRecipeMaterialization } from "./source-recipe-support"; | ||
| 33 | 32 | ||
| 34 | const shouldIndexSource = (source: Source): boolean => | 33 | const shouldIndexSource = (source: Source): boolean => |
| 35 | source.enabled | 34 | 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 !== null | 146 | recipeRevisionId: "src_recipe_rev_materialization" as never, |
| 177 | && runtimeLocalWorkspace.installation.workspaceId === input.source.workspaceId | 147 | 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 | }); |
| @@ -1,3 +1,7 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 1 | import { describe, expect, it } from "vitest"; | 5 | import { describe, expect, it } from "vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 3 | import { | 7 | import { |
| @@ -8,8 +12,8 @@ import { · 4 unmodified lines | |||
| 8 | 12 | ||
| 9 | import { | 13 | import { |
| 10 | AccountIdSchema, | 14 | AccountIdSchema, |
| 11 | OrganizationIdSchema, | ||
| 12 | SourceIdSchema, | 15 | SourceIdSchema, |
| 16 | type LocalConfigSource, | ||
| 13 | SourceRecipeIdSchema, | 17 | SourceRecipeIdSchema, |
| 14 | SourceRecipeRevisionIdSchema, | 18 | SourceRecipeRevisionIdSchema, |
| 15 | WorkspaceIdSchema, | 19 | WorkspaceIdSchema, |
| @@ -21,6 +25,22 @@ import { · 5 unmodified lines | |||
| 21 | type SqlControlPlanePersistence, | 25 | type SqlControlPlanePersistence, |
| 22 | } from "#persistence"; | 26 | } from "#persistence"; |
| 23 | 27 | ||
| 28 | import { | ||
| 29 | loadLocalExecutorConfig, | ||
| 30 | resolveLocalWorkspaceContext, | ||
| 31 | writeProjectLocalExecutorConfig, | ||
| 32 | } from "./local-config"; | ||
| 33 | import { | ||
| 34 | buildLocalSourceArtifact, | ||
| 35 | writeLocalSourceArtifact, | ||
| 36 | } from "./local-source-artifacts"; | ||
| 37 | import { | ||
| 38 | RuntimeLocalWorkspaceService, | ||
| 39 | type RuntimeLocalWorkspaceState, | ||
| 40 | } from "./local-runtime-context"; | ||
| 41 | import { | ||
| 42 | writeLocalWorkspaceState, | ||
| 43 | } from "./local-workspace-state"; | ||
| 24 | import { | 44 | import { |
| 25 | expandRecipeTools, | 45 | expandRecipeTools, |
| 26 | loadSourceWithRecipe, | 46 | loadSourceWithRecipe, |
| @@ -38,6 +58,45 @@ const makePersistence = () => · 11 unmodified lines | |||
| 38 | }), | 58 | }), |
| 39 | ); | 59 | ); |
| 40 | 60 | ||
| 61 | const makeRuntimeLocalWorkspaceState = ( | ||
| 62 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>, | ||
| 63 | ) => | ||
| 64 | Effect.runPromise( | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const workspaceRoot = mkdtempSync( | ||
| 67 | join(tmpdir(), "executor-source-recipes-runtime-"), | ||
| 68 | ); | ||
| 69 | const context = yield* 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 | |||
| 92 | const withRuntimeLocalWorkspace = <A, E>( | ||
| 93 | effect: Effect.Effect<A, E, never>, | ||
| 94 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | ||
| 95 | ) => | ||
| 96 | effect.pipe( | ||
| 97 | Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | ||
| 98 | ); | ||
| 99 | |||
| 41 | const openApiBindingConfigJson = (specUrl: string): string => | 100 | const 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 | ||
| 224 | const seedWorkspace = async (input: { | 283 | const 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 | |||
| 351 | const 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 | ||
| 250 | describe("source-recipes-runtime", () => { | 407 | describe("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 | } |
| @@ -13,13 +13,12 @@ import type { · 12 unmodified lines | |||
| 13 | WorkspaceId, | 13 | WorkspaceId, |
| 14 | } from "#schema"; | 14 | } from "#schema"; |
| 15 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Option from "effect/Option"; | ||
| 17 | 16 | ||
| 18 | import { | 17 | import { |
| 19 | readLocalSourceArtifact, | 18 | readLocalSourceArtifact, |
| 20 | } from "./local-source-artifacts"; | 19 | } from "./local-source-artifacts"; |
| 21 | import { | 20 | import { |
| 22 | getRuntimeLocalWorkspaceOption, | 21 | requireRuntimeLocalWorkspace, |
| 23 | } from "./local-runtime-context"; | 22 | } from "./local-runtime-context"; |
| 24 | import { namespaceFromSourceName } from "./source-names"; | 23 | import { namespaceFromSourceName } from "./source-names"; |
| 25 | import { | 24 | import { |
| @@ -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 | ||
| 294 | export const loadSourceWithRecipe = (input: { | 221 | export 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 | }); |
| @@ -1,10 +1,13 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 6 | ||
| 3 | import { | 7 | import { |
| 4 | AccountIdSchema, | 8 | AccountIdSchema, |
| 5 | decodeBuiltInAuthArtifactConfig, | 9 | decodeBuiltInAuthArtifactConfig, |
| 6 | McpSourceAuthSessionDataJsonSchema, | 10 | McpSourceAuthSessionDataJsonSchema, |
| 7 | OrganizationIdSchema, | ||
| 8 | SecretMaterialIdSchema, | 11 | SecretMaterialIdSchema, |
| 9 | SourceAuthSessionIdSchema, | 12 | SourceAuthSessionIdSchema, |
| 10 | SourceIdSchema, | 13 | SourceIdSchema, |
| @@ -21,9 +24,16 @@ import { · 10 unmodified lines | |||
| 21 | type SqlControlPlanePersistence, | 24 | type SqlControlPlanePersistence, |
| 22 | } from "../persistence"; | 25 | } from "../persistence"; |
| 23 | import { | 26 | import { |
| 24 | stableSourceRecipeId, | 27 | loadLocalExecutorConfig, |
| 25 | stableSourceRecipeRevisionId, | 28 | resolveLocalWorkspaceContext, |
| 26 | } from "./source-definitions"; | 29 | } from "./local-config"; |
| 30 | import { | ||
| 31 | RuntimeLocalWorkspaceService, | ||
| 32 | type RuntimeLocalWorkspaceState, | ||
| 33 | } from "./local-runtime-context"; | ||
| 34 | import { | ||
| 35 | loadLocalWorkspaceState, | ||
| 36 | } from "./local-workspace-state"; | ||
| 27 | import { persistSource, removeSourceById } from "./source-store"; | 37 | import { persistSource, removeSourceById } from "./source-store"; |
| 28 | 38 | ||
| 29 | const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | 39 | const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = |
| @@ -40,6 +50,42 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 10 unmodified lines | |||
| 40 | 50 | ||
| 41 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | 51 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 42 | 52 | ||
| 53 | const makeRuntimeLocalWorkspace = (input: { | ||
| 54 | workspaceId: Source["workspaceId"]; | ||
| 55 | accountId: ReturnType<typeof AccountIdSchema.make>; | ||
| 56 | }) => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-source-store-")); | ||
| 59 | const context = yield* 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 | |||
| 81 | const withRuntimeLocalWorkspace = <A, E>( | ||
| 82 | effect: Effect.Effect<A, E, never>, | ||
| 83 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState, | ||
| 84 | ) => | ||
| 85 | effect.pipe( | ||
| 86 | Effect.provideService(RuntimeLocalWorkspaceService, runtimeLocalWorkspace), | ||
| 87 | ); | ||
| 88 | |||
| 43 | const makeOpenApiSource = (input: { | 89 | const 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 | }); |
| @@ -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"; |
| 13 | import { SourceIdSchema } from "#schema"; | 11 | import { SourceIdSchema } from "#schema"; |
| 14 | import { type SqlControlPlaneRows } from "#persistence"; | 12 | import { type SqlControlPlaneRows } from "#persistence"; |
| 15 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 16 | import * as Option from "effect/Option"; | ||
| 17 | 14 | ||
| 18 | import { | 15 | import { |
| 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"; |
| 20 | import { | ||
| 21 | sourceAuthFromAuthArtifact, | ||
| 22 | } from "./auth-artifacts"; | ||
| 27 | import { | 23 | import { |
| 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"; |
| 36 | import { | 33 | import { |
| 37 | getRuntimeLocalWorkspaceOption, | 34 | requireRuntimeLocalWorkspace, |
| 38 | } from "./local-runtime-context"; | 35 | } from "./local-runtime-context"; |
| 39 | import { | 36 | import { |
| 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 | ||
| 139 | const resolveLocalConfigSecretProviderAlias = (config: Awaited<ReturnType<typeof loadLocalExecutorConfig>>["config"]): string | null => { | 136 | const 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 | ||
| 148 | const sourceAuthFromConfigInput = (input: { | 145 | const 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 = providerAlias | 174 | 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 | |||
| 199 | const configAuthFromSource = (input: { | 198 | const 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 | ||
| 227 | const resolveRuntimeLocalWorkspace = (workspaceId: WorkspaceId) => | 234 | const 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 | |||
| 253 | const buildLocalSourceRecord = (input: { | 256 | const 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 | ||
| 404 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { | 393 | export 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 | ||
| 465 | const configSourceFromLocalSource = (input: { | 420 | const 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 | ||
| 557 | const 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 | |||
| 591 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { | 512 | export 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.id | 595 | : 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 === nextRecipeId | 616 | ...(projectConfig.sources ?? {}), |
| 860 | ? existing.value.recipeRevisionId | 617 | }; |
| 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 | }); |
| @@ -15,10 +15,10 @@ import { · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | import { | 16 | import { |
| 17 | ControlPlaneAuthHeaders, | 17 | ControlPlaneAuthHeaders, |
| 18 | type SqlControlPlaneRuntime, | 18 | type ControlPlaneRuntime, |
| 19 | } from "./index"; | 19 | } from "./index"; |
| 20 | 20 | ||
| 21 | const createClientLayer = (runtime: SqlControlPlaneRuntime) => { | 21 | const 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 | ||
| 49 | export const withControlPlaneClient = <A, E>( | 49 | export 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>, |
| @@ -1,5 +1,8 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { createServer } from "node:http"; | 3 | import { createServer } from "node:http"; |
| 4 | import { tmpdir } from "node:os"; | ||
| 5 | import { join } from "node:path"; | ||
| 3 | 6 | ||
| 4 | import { | 7 | import { |
| 5 | HttpApi, | 8 | HttpApi, |
| @@ -45,7 +48,6 @@ import * as Effect from "effect/Effect"; · 39 unmodified lines | |||
| 45 | import * as Exit from "effect/Exit"; | 48 | import * as Exit from "effect/Exit"; |
| 46 | import * as Layer from "effect/Layer"; | 49 | import * as Layer from "effect/Layer"; |
| 47 | import * as Scope from "effect/Scope"; | 50 | import * as Scope from "effect/Scope"; |
| 48 | import * as Option from "effect/Option"; | ||
| 49 | import { Schema } from "effect"; | 51 | import { Schema } from "effect"; |
| 50 | import * as z from "zod/v4"; | 52 | import * as z from "zod/v4"; |
| 51 | 53 | ||
| @@ -59,9 +61,28 @@ import { · 7 unmodified lines | |||
| 59 | createRuntimeSourceAuthService, | 61 | createRuntimeSourceAuthService, |
| 60 | type RuntimeSourceAuthService, | 62 | type RuntimeSourceAuthService, |
| 61 | } from "./source-auth-service"; | 63 | } from "./source-auth-service"; |
| 64 | import { | ||
| 65 | loadLocalExecutorConfig, | ||
| 66 | resolveLocalWorkspaceContext, | ||
| 67 | writeProjectLocalExecutorConfig, | ||
| 68 | } from "./local-config"; | ||
| 62 | import { createLiveExecutionManager } from "./live-execution"; | 69 | import { createLiveExecutionManager } from "./live-execution"; |
| 63 | import { persistSource } from "./source-store"; | 70 | import { |
| 64 | import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization"; | 71 | RuntimeLocalWorkspaceService, |
| 72 | type RuntimeLocalWorkspaceState, | ||
| 73 | } from "./local-runtime-context"; | ||
| 74 | import { | ||
| 75 | buildLocalSourceArtifact, | ||
| 76 | readLocalSourceArtifact, | ||
| 77 | writeLocalSourceArtifact, | ||
| 78 | } from "./local-source-artifacts"; | ||
| 79 | import { | ||
| 80 | loadLocalWorkspaceState, | ||
| 81 | writeLocalWorkspaceState, | ||
| 82 | } from "./local-workspace-state"; | ||
| 83 | import { | ||
| 84 | materializationFromMcpManifestEntries, | ||
| 85 | } from "./source-adapters/mcp"; | ||
| 65 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; | 86 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 66 | 87 | ||
| 67 | type CountedMcpServer = { | 88 | type CountedMcpServer = { |
| @@ -579,7 +600,7 @@ const makeGraphqlServer = Effect.acquireRelease( · 511 unmodified lines | |||
| 579 | ); | 600 | ); |
| 580 | 601 | ||
| 581 | const persistConnectedEchoTool = (input: { | 602 | const persistConnectedEchoTool = (input: { |
| 582 | persistence: SqlControlPlanePersistence; | 603 | runtimeLocalWorkspace: RuntimeLocalWorkspaceState; |
| 583 | endpoint: string; | 604 | endpoint: string; |
| 584 | workspaceId: WorkspaceId; | 605 | workspaceId: WorkspaceId; |
| 585 | sourceId: SourceId; | 606 | sourceId: SourceId; |
| @@ -610,32 +631,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 | ||
| 637 | const persistConnectedGithubOpenApiSource = (input: { | 711 | const 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 > 0 | 963 | 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 | |||
| 1087 | const 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 | ||
| 968 | const makeResolver = (persistence: SqlControlPlanePersistence) => | 1111 | const 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 | ||
| 988 | const makeResolverWithLiveSourceAuth = ( | 1137 | const 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 | ||
| 1000 | describe("workspace-execution-environment", () => { | 1153 | describe("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 | }); |
| @@ -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.workspaces | 398 | 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.organizationId | 412 | 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" && |
| @@ -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 | ||
| 11 | export const LocalConfigEnvSecretProviderSchema = Schema.Struct({ | 12 | export const LocalConfigEnvSecretProviderSchema = Schema.Struct({ |
| @@ -1,10 +1,5 @@ | |||
| 1 | import { | 1 | import * as Schema from "effect/Schema"; |
| 2 | createInsertSchema, | ||
| 3 | createSelectSchema, | ||
| 4 | createUpdateSchema, | ||
| 5 | } from "drizzle-orm/effect-schema"; | ||
| 6 | 2 | ||
| 7 | import { localInstallationsTable } from "../../persistence/schema"; | ||
| 8 | import { TimestampMsSchema } from "../common"; | 3 | import { TimestampMsSchema } from "../common"; |
| 9 | import { | 4 | import { |
| 10 | AccountIdSchema, | 5 | AccountIdSchema, |
| @@ -13,28 +8,16 @@ import { · 2 unmodified lines | |||
| 13 | WorkspaceIdSchema, | 8 | WorkspaceIdSchema, |
| 14 | } from "../ids"; | 9 | } from "../ids"; |
| 15 | 10 | ||
| 16 | const localInstallationSchemaOverrides = { | 11 | export 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 | |||
| 25 | export const LocalInstallationSchema = createSelectSchema( | ||
| 26 | localInstallationsTable, | ||
| 27 | localInstallationSchemaOverrides, | ||
| 28 | ); | ||
| 29 | |||
| 30 | export const LocalInstallationInsertSchema = createInsertSchema( | ||
| 31 | localInstallationsTable, | ||
| 32 | localInstallationSchemaOverrides, | ||
| 33 | ); | ||
| 34 | 19 | ||
| 35 | export const LocalInstallationUpdateSchema = createUpdateSchema( | 20 | export const LocalInstallationInsertSchema = LocalInstallationSchema; |
| 36 | localInstallationsTable, | 21 | export const LocalInstallationUpdateSchema = Schema.partial(LocalInstallationSchema); |
| 37 | localInstallationSchemaOverrides, | ||
| 38 | ); | ||
| 39 | 22 | ||
| 40 | export type LocalInstallation = typeof LocalInstallationSchema.Type; | 23 | export type LocalInstallation = typeof LocalInstallationSchema.Type; |
| @@ -5,10 +5,10 @@ import { NodeHttpServer } from "@effect/platform-node"; · 4 unmodified lines | |||
| 5 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { | 6 | import { |
| 7 | createControlPlaneApiLayer, | 7 | createControlPlaneApiLayer, |
| 8 | createSqlControlPlaneRuntime, | 8 | createControlPlaneRuntime, |
| 9 | type LocalInstallation, | 9 | type LocalInstallation, |
| 10 | type Source, | 10 | type Source, |
| 11 | type SqlControlPlaneRuntime, | 11 | type ControlPlaneRuntime, |
| 12 | } from "@executor/control-plane"; | 12 | } from "@executor/control-plane"; |
| 13 | import * as Context from "effect/Context"; | 13 | import * as Context from "effect/Context"; |
| 14 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| @@ -69,7 +69,7 @@ type RunningServer = { · 54 unmodified lines | |||
| 69 | }; | 69 | }; |
| 70 | 70 | ||
| 71 | type ApiServer = RunningServer & { | 71 | type ApiServer = RunningServer & { |
| 72 | runtime: SqlControlPlaneRuntime; | 72 | runtime: ControlPlaneRuntime; |
| 73 | }; | 73 | }; |
| 74 | 74 | ||
| 75 | type HookHarness<T> = { | 75 | type HookHarness<T> = { |
| @@ -95,7 +95,7 @@ const closeScope = (scope: Scope.CloseableScope) => · 19 unmodified lines | |||
| 95 | 95 | ||
| 96 | const startControlPlaneServer = async (): Promise<ApiServer> => { | 96 | const startControlPlaneServer = async (): Promise<ApiServer> => { |
| 97 | const runtime = await Effect.runPromise( | 97 | const runtime = await Effect.runPromise( |
| 98 | createSqlControlPlaneRuntime({ | 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 | }), |
| @@ -11,10 +11,10 @@ import { Readable } from "node:stream"; · 10 unmodified lines | |||
| 11 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; | 11 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 12 | import { | 12 | import { |
| 13 | createControlPlaneApiLayer, | 13 | createControlPlaneApiLayer, |
| 14 | createSqlControlPlaneRuntime, | 14 | createControlPlaneRuntime, |
| 15 | type ResolveExecutionEnvironment, | 15 | type ResolveExecutionEnvironment, |
| 16 | type ResolveSecretMaterial, | 16 | type ResolveSecretMaterial, |
| 17 | type SqlControlPlaneRuntime, | 17 | type ControlPlaneRuntime, |
| 18 | } from "@executor/control-plane"; | 18 | } from "@executor/control-plane"; |
| 19 | import { createExecutorMcpRequestHandler } from "@executor/executor-mcp"; | 19 | import { createExecutorMcpRequestHandler } from "@executor/executor-mcp"; |
| 20 | import * as Cause from "effect/Cause"; | 20 | import * as Cause from "effect/Cause"; |
| @@ -58,7 +58,7 @@ type StaticUiOptions = { · 37 unmodified lines | |||
| 58 | }; | 58 | }; |
| 59 | 59 | ||
| 60 | export type LocalExecutorServer = { | 60 | export type LocalExecutorServer = { |
| 61 | readonly runtime: SqlControlPlaneRuntime; | 61 | readonly runtime: ControlPlaneRuntime; |
| 62 | readonly port: number; | 62 | readonly port: number; |
| 63 | readonly host: string; | 63 | readonly host: string; |
| 64 | readonly baseUrl: string; | 64 | readonly baseUrl: string; |
| @@ -77,7 +77,7 @@ export type StartLocalExecutorServerOptions = { · 12 unmodified lines | |||
| 77 | }; | 77 | }; |
| 78 | 78 | ||
| 79 | export type LocalExecutorRequestHandler = { | 79 | export 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 | |||
| 86 | type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>; | 86 | type ControlPlaneWebHandler = ReturnType<typeof HttpApiBuilder.toWebHandler>; |
| 87 | type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; | 87 | type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; |
| 88 | 88 | ||
| 89 | const disposeRuntime = (runtime: SqlControlPlaneRuntime) => | 89 | const 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 | ||
| 212 | const createControlPlaneWebHandler = (runtime: SqlControlPlaneRuntime) => | 212 | const createControlPlaneWebHandler = (runtime: ControlPlaneRuntime) => |
| 213 | Effect.acquireRelease( | 213 | Effect.acquireRelease( |
| 214 | Effect.sync(() => | 214 | Effect.sync(() => |
| 215 | HttpApiBuilder.toWebHandler( | 215 | HttpApiBuilder.toWebHandler( |