Unified · Split
| @@ -17,9 +17,6 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 16 unmodified lines | |||
| 17 | # When enabled, local Bun execution is allowed. | 17 | # When enabled, local Bun execution is allowed. |
| 18 | # Leave unset in production to default to Cloudflare runtime. | 18 | # Leave unset in production to default to Cloudflare runtime. |
| 19 | # DANGEROUSLY_ALLOW_LOCAL_VM=1 | 19 | # DANGEROUSLY_ALLOW_LOCAL_VM=1 |
| 20 | # Optional external OpenAPI generator endpoint used by Convex for heavy parse/prepare. | ||
| 21 | # Expects GET /api/generate?specUrl=...&sourceName=...&includeDts=0|1. | ||
| 22 | # EXECUTOR_GENERATE_URL=http://localhost:4312 | ||
| 23 | 20 | ||
| 24 | # ── Agent storage backend (optional) ── | 21 | # ── Agent storage backend (optional) ── |
| 25 | # Default: agentfs-local | 22 | # Default: agentfs-local |
| @@ -34,10 +31,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 8 unmodified lines | |||
| 34 | # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config. | 31 | # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config. |
| 35 | 32 | ||
| 36 | # ── WorkOS (optional — enables auth, omit for anonymous mode) ── | 33 | # ── WorkOS (optional — enables auth, omit for anonymous mode) ── |
| 34 | # WorkOS auth is auto-disabled when CONTROL_PLANE_UPSTREAM_URL points to local PM | ||
| 35 | # (localhost/127.0.0.1/0.0.0.0/::1) unless you force it on with WORKOS_FORCE_ENABLED=1. | ||
| 37 | # WORKOS_CLIENT_ID=client_... | 36 | # WORKOS_CLIENT_ID=client_... |
| 38 | # WORKOS_API_KEY=sk_test_... | 37 | # WORKOS_API_KEY=sk_test_... |
| 39 | # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32> | 38 | # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32> |
| 40 | # WORKOS_WEBHOOK_SECRET=... | 39 | # WORKOS_WEBHOOK_SECRET=... |
| 40 | # WORKOS_FORCE_ENABLED=1 | ||
| 41 | 41 | ||
| 42 | # ── Anonymous JWT Auth (optional — enables anonymous bearer auth via Convex Auth) ── | 42 | # ── Anonymous JWT Auth (optional — enables anonymous bearer auth via Convex Auth) ── |
| 43 | # Generate an ES256 key pair (PKCS8 private + SPKI public), then set both PEM values. | 43 | # Generate an ES256 key pair (PKCS8 private + SPKI public), then set both PEM values. |
| @@ -10,10 +10,19 @@ · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | import type * as controlPlane from "../controlPlane.js"; | 11 | import type * as controlPlane from "../controlPlane.js"; |
| 12 | import type * as control_plane_actor from "../control_plane/actor.js"; | 12 | import type * as control_plane_actor from "../control_plane/actor.js"; |
| 13 | import type * as control_plane_approvals from "../control_plane/approvals.js"; | ||
| 14 | import type * as control_plane_credentials from "../control_plane/credentials.js"; | ||
| 13 | import type * as control_plane_errors from "../control_plane/errors.js"; | 15 | import type * as control_plane_errors from "../control_plane/errors.js"; |
| 14 | import type * as control_plane_http from "../control_plane/http.js"; | 16 | import type * as control_plane_http from "../control_plane/http.js"; |
| 17 | import type * as control_plane_openapi_ingest from "../control_plane/openapi_ingest.js"; | ||
| 18 | import type * as control_plane_openapi_ingest_mvp from "../control_plane/openapi_ingest_mvp.js"; | ||
| 19 | import type * as control_plane_organizations from "../control_plane/organizations.js"; | ||
| 20 | import type * as control_plane_policies from "../control_plane/policies.js"; | ||
| 15 | import type * as control_plane_service from "../control_plane/service.js"; | 21 | import type * as control_plane_service from "../control_plane/service.js"; |
| 16 | import type * as control_plane_sources from "../control_plane/sources.js"; | 22 | import type * as control_plane_sources from "../control_plane/sources.js"; |
| 23 | import type * as control_plane_storage from "../control_plane/storage.js"; | ||
| 24 | import type * as control_plane_tools from "../control_plane/tools.js"; | ||
| 25 | import type * as control_plane_workspaces from "../control_plane/workspaces.js"; | ||
| 17 | import type * as credential_resolver from "../credential_resolver.js"; | 26 | import type * as credential_resolver from "../credential_resolver.js"; |
| 18 | import type * as executor from "../executor.js"; | 27 | import type * as executor from "../executor.js"; |
| 19 | import type * as http from "../http.js"; | 28 | import type * as http from "../http.js"; |
| @@ -22,6 +31,8 @@ import type * as run_executor from "../run_executor.js"; · 2 unmodified lines | |||
| 22 | import type * as runtimeCallbacks from "../runtimeCallbacks.js"; | 31 | import type * as runtimeCallbacks from "../runtimeCallbacks.js"; |
| 23 | import type * as runtime_adapter from "../runtime_adapter.js"; | 32 | import type * as runtime_adapter from "../runtime_adapter.js"; |
| 24 | import type * as runtime_execution_port from "../runtime_execution_port.js"; | 33 | import type * as runtime_execution_port from "../runtime_execution_port.js"; |
| 34 | import type * as source_tool_registry from "../source_tool_registry.js"; | ||
| 35 | import type * as task_runs from "../task_runs.js"; | ||
| 25 | 36 | ||
| 26 | import type { | 37 | import type { |
| 27 | ApiFromModules, | 38 | ApiFromModules, |
| @@ -32,10 +43,19 @@ import type { · 4 unmodified lines | |||
| 32 | declare const fullApi: ApiFromModules<{ | 43 | declare const fullApi: ApiFromModules<{ |
| 33 | controlPlane: typeof controlPlane; | 44 | controlPlane: typeof controlPlane; |
| 34 | "control_plane/actor": typeof control_plane_actor; | 45 | "control_plane/actor": typeof control_plane_actor; |
| 46 | "control_plane/approvals": typeof control_plane_approvals; | ||
| 47 | "control_plane/credentials": typeof control_plane_credentials; | ||
| 35 | "control_plane/errors": typeof control_plane_errors; | 48 | "control_plane/errors": typeof control_plane_errors; |
| 36 | "control_plane/http": typeof control_plane_http; | 49 | "control_plane/http": typeof control_plane_http; |
| 50 | "control_plane/openapi_ingest": typeof control_plane_openapi_ingest; | ||
| 51 | "control_plane/openapi_ingest_mvp": typeof control_plane_openapi_ingest_mvp; | ||
| 52 | "control_plane/organizations": typeof control_plane_organizations; | ||
| 53 | "control_plane/policies": typeof control_plane_policies; | ||
| 37 | "control_plane/service": typeof control_plane_service; | 54 | "control_plane/service": typeof control_plane_service; |
| 38 | "control_plane/sources": typeof control_plane_sources; | 55 | "control_plane/sources": typeof control_plane_sources; |
| 56 | "control_plane/storage": typeof control_plane_storage; | ||
| 57 | "control_plane/tools": typeof control_plane_tools; | ||
| 58 | "control_plane/workspaces": typeof control_plane_workspaces; | ||
| 39 | credential_resolver: typeof credential_resolver; | 59 | credential_resolver: typeof credential_resolver; |
| 40 | executor: typeof executor; | 60 | executor: typeof executor; |
| 41 | http: typeof http; | 61 | http: typeof http; |
| @@ -44,6 +64,8 @@ declare const fullApi: ApiFromModules<{ · 2 unmodified lines | |||
| 44 | runtimeCallbacks: typeof runtimeCallbacks; | 64 | runtimeCallbacks: typeof runtimeCallbacks; |
| 45 | runtime_adapter: typeof runtime_adapter; | 65 | runtime_adapter: typeof runtime_adapter; |
| 46 | runtime_execution_port: typeof runtime_execution_port; | 66 | runtime_execution_port: typeof runtime_execution_port; |
| 67 | source_tool_registry: typeof source_tool_registry; | ||
| 68 | task_runs: typeof task_runs; | ||
| 47 | }>; | 69 | }>; |
| 48 | 70 | ||
| 49 | /** | 71 | /** |
| @@ -2,16 +2,68 @@ import { · 1 unmodified line | |||
| 2 | listApprovals as listApprovalsImpl, | 2 | listApprovals as listApprovalsImpl, |
| 3 | resolveApproval as resolveApprovalImpl, | 3 | resolveApproval as resolveApprovalImpl, |
| 4 | } from "./control_plane/approvals"; | 4 | } from "./control_plane/approvals"; |
| 5 | import { | ||
| 6 | listCredentialBindings as listCredentialBindingsImpl, | ||
| 7 | removeCredentialBinding as removeCredentialBindingImpl, | ||
| 8 | upsertCredentialBinding as upsertCredentialBindingImpl, | ||
| 9 | } from "./control_plane/credentials"; | ||
| 5 | import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http"; | 10 | import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http"; |
| 11 | import { | ||
| 12 | listOrganizations as listOrganizationsImpl, | ||
| 13 | upsertOrganization as upsertOrganizationImpl, | ||
| 14 | } from "./control_plane/organizations"; | ||
| 15 | import { | ||
| 16 | listPolicies as listPoliciesImpl, | ||
| 17 | removePolicy as removePolicyImpl, | ||
| 18 | upsertPolicy as upsertPolicyImpl, | ||
| 19 | } from "./control_plane/policies"; | ||
| 6 | import { | 20 | import { |
| 7 | listSources as listSourcesImpl, | 21 | listSources as listSourcesImpl, |
| 8 | removeSource as removeSourceImpl, | 22 | removeSource as removeSourceImpl, |
| 9 | upsertSource as upsertSourceImpl, | 23 | upsertSource as upsertSourceImpl, |
| 10 | } from "./control_plane/sources"; | 24 | } from "./control_plane/sources"; |
| 25 | import { | ||
| 26 | closeStorageInstance as closeStorageInstanceImpl, | ||
| 27 | listStorageDirectory as listStorageDirectoryImpl, | ||
| 28 | listStorageInstances as listStorageInstancesImpl, | ||
| 29 | listStorageKv as listStorageKvImpl, | ||
| 30 | openStorageInstance as openStorageInstanceImpl, | ||
| 31 | queryStorageSql as queryStorageSqlImpl, | ||
| 32 | readStorageFile as readStorageFileImpl, | ||
| 33 | removeStorageInstance as removeStorageInstanceImpl, | ||
| 34 | } from "./control_plane/storage"; | ||
| 35 | import { | ||
| 36 | listSourceTools as listSourceToolsImpl, | ||
| 37 | listWorkspaceTools as listWorkspaceToolsImpl, | ||
| 38 | } from "./control_plane/tools"; | ||
| 39 | import { | ||
| 40 | listWorkspaces as listWorkspacesImpl, | ||
| 41 | upsertWorkspace as upsertWorkspaceImpl, | ||
| 42 | } from "./control_plane/workspaces"; | ||
| 11 | 43 | ||
| 12 | export const listSources = listSourcesImpl; | 44 | export const listSources = listSourcesImpl; |
| 13 | export const upsertSource = upsertSourceImpl; | 45 | export const upsertSource = upsertSourceImpl; |
| 14 | export const removeSource = removeSourceImpl; | 46 | export const removeSource = removeSourceImpl; |
| 47 | export const listCredentialBindings = listCredentialBindingsImpl; | ||
| 48 | export const upsertCredentialBinding = upsertCredentialBindingImpl; | ||
| 49 | export const removeCredentialBinding = removeCredentialBindingImpl; | ||
| 50 | export const listPolicies = listPoliciesImpl; | ||
| 51 | export const upsertPolicy = upsertPolicyImpl; | ||
| 52 | export const removePolicy = removePolicyImpl; | ||
| 53 | export const listOrganizations = listOrganizationsImpl; | ||
| 54 | export const upsertOrganization = upsertOrganizationImpl; | ||
| 55 | export const listWorkspaces = listWorkspacesImpl; | ||
| 56 | export const upsertWorkspace = upsertWorkspaceImpl; | ||
| 57 | export const listWorkspaceTools = listWorkspaceToolsImpl; | ||
| 58 | export const listSourceTools = listSourceToolsImpl; | ||
| 59 | export const listStorageInstances = listStorageInstancesImpl; | ||
| 60 | export const openStorageInstance = openStorageInstanceImpl; | ||
| 61 | export const closeStorageInstance = closeStorageInstanceImpl; | ||
| 62 | export const removeStorageInstance = removeStorageInstanceImpl; | ||
| 63 | export const listStorageDirectory = listStorageDirectoryImpl; | ||
| 64 | export const readStorageFile = readStorageFileImpl; | ||
| 65 | export const listStorageKv = listStorageKvImpl; | ||
| 66 | export const queryStorageSql = queryStorageSqlImpl; | ||
| 15 | export const listApprovals = listApprovalsImpl; | 67 | export const listApprovals = listApprovalsImpl; |
| 16 | export const resolveApproval = resolveApprovalImpl; | 68 | export const resolveApproval = resolveApprovalImpl; |
| 17 | 69 | ||
| @@ -71,6 +71,34 @@ export const listOrganizationMembershipsForActor = internalQuery({ · 70 unmodified lines | |||
| 71 | }, | 71 | }, |
| 72 | }); | 72 | }); |
| 73 | 73 | ||
| 74 | export const listWorkspacesForActor = internalQuery({ | ||
| 75 | args: { | ||
| 76 | accountId: v.string(), | ||
| 77 | organizationIds: v.array(v.string()), | ||
| 78 | }, | ||
| 79 | handler: async (ctx, args): Promise<ReadonlyArray<Workspace>> => { | ||
| 80 | const ownRows = await ctx.db | ||
| 81 | .query("workspaces") | ||
| 82 | .withIndex("by_createdByAccountId", (q) => q.eq("createdByAccountId", args.accountId)) | ||
| 83 | .collect(); | ||
| 84 | |||
| 85 | const organizationRows = await Promise.all( | ||
| 86 | args.organizationIds.map((organizationId) => | ||
| 87 | ctx.db | ||
| 88 | .query("workspaces") | ||
| 89 | .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | ||
| 90 | .collect(), | ||
| 91 | ), | ||
| 92 | ); | ||
| 93 | |||
| 94 | const workspaces = [...ownRows, ...organizationRows.flat()].map((row) => | ||
| 95 | decodeWorkspace(stripConvexSystemFields(row as unknown as Record<string, unknown>)), | ||
| 96 | ); | ||
| 97 | |||
| 98 | return Array.from(new Map(workspaces.map((workspace) => [workspace.id, workspace])).values()); | ||
| 99 | }, | ||
| 100 | }); | ||
| 101 | |||
| 74 | export const ensureWorkspaceForActor = internalMutation({ | 102 | export const ensureWorkspaceForActor = internalMutation({ |
| 75 | args: { | 103 | args: { |
| 76 | workspaceId: v.string(), | 104 | workspaceId: v.string(), |
| @@ -139,6 +167,44 @@ const runQueryEffect = <A>( · 62 unmodified lines | |||
| 139 | 167 | ||
| 140 | export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => | 168 | export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => |
| 141 | ControlPlaneActorResolverLive({ | 169 | ControlPlaneActorResolverLive({ |
| 170 | resolveActor: (input) => | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const principal = yield* requirePrincipalFromHeaders(input.headers); | ||
| 173 | |||
| 174 | const organizationMemberships = yield* runQueryEffect( | ||
| 175 | "organizationMembership.list", | ||
| 176 | () => | ||
| 177 | ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, { | ||
| 178 | accountId: principal.accountId, | ||
| 179 | }), | ||
| 180 | ); | ||
| 181 | |||
| 182 | const organizationIds = organizationMemberships.map( | ||
| 183 | (membership) => membership.organizationId, | ||
| 184 | ); | ||
| 185 | |||
| 186 | const workspaces = yield* runQueryEffect("workspace.list", () => | ||
| 187 | ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, { | ||
| 188 | accountId: principal.accountId, | ||
| 189 | organizationIds, | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | |||
| 193 | const workspaceMemberships = workspaces.flatMap((workspace) => | ||
| 194 | deriveWorkspaceMembershipsForPrincipal({ | ||
| 195 | principalAccountId: principal.accountId, | ||
| 196 | workspaceId: workspace.id, | ||
| 197 | workspace, | ||
| 198 | organizationMemberships, | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | |||
| 202 | return yield* makeActor({ | ||
| 203 | principal, | ||
| 204 | workspaceMemberships, | ||
| 205 | organizationMemberships, | ||
| 206 | }); | ||
| 207 | }), | ||
| 142 | resolveWorkspaceActor: (input) => | 208 | resolveWorkspaceActor: (input) => |
| 143 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 144 | const principal = yield* requirePrincipalFromHeaders(input.headers); | 210 | const principal = yield* requirePrincipalFromHeaders(input.headers); |
| @@ -0,0 +1,231 @@ | |||
| 1 | import { type UpsertCredentialBindingPayload } from "@executor-v2/management-api"; | ||
| 2 | import { | ||
| 3 | SourceCredentialBindingSchema, | ||
| 4 | type SourceCredentialBinding, | ||
| 5 | } from "@executor-v2/schema"; | ||
| 6 | import { v } from "convex/values"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | mutation, | ||
| 11 | query, | ||
| 12 | type MutationCtx, | ||
| 13 | type QueryCtx, | ||
| 14 | } from "../_generated/server"; | ||
| 15 | |||
| 16 | const decodeSourceCredentialBinding = Schema.decodeUnknownSync( | ||
| 17 | SourceCredentialBindingSchema, | ||
| 18 | ); | ||
| 19 | |||
| 20 | const stripConvexSystemFields = ( | ||
| 21 | value: Record<string, unknown>, | ||
| 22 | ): Record<string, unknown> => { | ||
| 23 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 24 | return rest; | ||
| 25 | }; | ||
| 26 | |||
| 27 | const toSourceCredentialBinding = ( | ||
| 28 | document: Record<string, unknown>, | ||
| 29 | ): SourceCredentialBinding => | ||
| 30 | decodeSourceCredentialBinding(stripConvexSystemFields(document)); | ||
| 31 | |||
| 32 | const credentialProviderValidator = v.union( | ||
| 33 | v.literal("api_key"), | ||
| 34 | v.literal("bearer"), | ||
| 35 | v.literal("oauth2"), | ||
| 36 | v.literal("custom"), | ||
| 37 | ); | ||
| 38 | |||
| 39 | const credentialScopeTypeValidator = v.union( | ||
| 40 | v.literal("workspace"), | ||
| 41 | v.literal("organization"), | ||
| 42 | v.literal("account"), | ||
| 43 | ); | ||
| 44 | |||
| 45 | const sortSourceCredentialBindings = ( | ||
| 46 | bindings: ReadonlyArray<SourceCredentialBinding>, | ||
| 47 | ): Array<SourceCredentialBinding> => | ||
| 48 | [...bindings].sort((left, right) => { | ||
| 49 | const leftKey = `${left.sourceKey}:${left.provider}`.toLowerCase(); | ||
| 50 | const rightKey = `${right.sourceKey}:${right.provider}`.toLowerCase(); | ||
| 51 | |||
| 52 | if (leftKey === rightKey) { | ||
| 53 | return left.id.localeCompare(right.id); | ||
| 54 | } | ||
| 55 | |||
| 56 | return leftKey.localeCompare(rightKey); | ||
| 57 | }); | ||
| 58 | |||
| 59 | const resolveWorkspaceOrganizationId = async ( | ||
| 60 | ctx: QueryCtx | MutationCtx, | ||
| 61 | workspaceId: string, | ||
| 62 | ): Promise<string> => { | ||
| 63 | const workspace = await ctx.db | ||
| 64 | .query("workspaces") | ||
| 65 | .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | ||
| 66 | .unique(); | ||
| 67 | |||
| 68 | if (workspace?.organizationId !== null && workspace?.organizationId !== undefined) { | ||
| 69 | return workspace.organizationId; | ||
| 70 | } | ||
| 71 | |||
| 72 | return `org_${workspaceId}`; | ||
| 73 | }; | ||
| 74 | |||
| 75 | const canAccessSourceCredentialBinding = ( | ||
| 76 | binding: SourceCredentialBinding, | ||
| 77 | input: { | ||
| 78 | workspaceId: string; | ||
| 79 | organizationId: string; | ||
| 80 | }, | ||
| 81 | ): boolean => | ||
| 82 | binding.workspaceId === input.workspaceId | ||
| 83 | || (binding.workspaceId === null && binding.organizationId === input.organizationId); | ||
| 84 | |||
| 85 | export const listCredentialBindings = query({ | ||
| 86 | args: { | ||
| 87 | workspaceId: v.string(), | ||
| 88 | }, | ||
| 89 | handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => { | ||
| 90 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 91 | |||
| 92 | const workspaceRows = await ctx.db | ||
| 93 | .query("sourceCredentialBindings") | ||
| 94 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 95 | .collect(); | ||
| 96 | |||
| 97 | const organizationRows = await ctx.db | ||
| 98 | .query("sourceCredentialBindings") | ||
| 99 | .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | ||
| 100 | .collect(); | ||
| 101 | |||
| 102 | const bindings = [...workspaceRows, ...organizationRows] | ||
| 103 | .map((row) => | ||
| 104 | toSourceCredentialBinding(row as unknown as Record<string, unknown>), | ||
| 105 | ) | ||
| 106 | .filter((binding) => | ||
| 107 | canAccessSourceCredentialBinding(binding, { | ||
| 108 | workspaceId: args.workspaceId, | ||
| 109 | organizationId, | ||
| 110 | }) | ||
| 111 | ); | ||
| 112 | |||
| 113 | const uniqueBindings = Array.from( | ||
| 114 | new Map(bindings.map((binding) => [binding.id, binding])).values(), | ||
| 115 | ); | ||
| 116 | |||
| 117 | return sortSourceCredentialBindings(uniqueBindings); | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | |||
| 121 | export const upsertCredentialBinding = mutation({ | ||
| 122 | args: { | ||
| 123 | workspaceId: v.string(), | ||
| 124 | payload: v.object({ | ||
| 125 | id: v.optional(v.string()), | ||
| 126 | credentialId: v.string(), | ||
| 127 | scopeType: credentialScopeTypeValidator, | ||
| 128 | sourceKey: v.string(), | ||
| 129 | provider: credentialProviderValidator, | ||
| 130 | secretRef: v.string(), | ||
| 131 | accountId: v.optional(v.union(v.string(), v.null())), | ||
| 132 | additionalHeadersJson: v.optional(v.union(v.string(), v.null())), | ||
| 133 | boundAuthFingerprint: v.optional(v.union(v.string(), v.null())), | ||
| 134 | }), | ||
| 135 | }, | ||
| 136 | handler: async (ctx, args): Promise<SourceCredentialBinding> => { | ||
| 137 | const payload = args.payload as UpsertCredentialBindingPayload; | ||
| 138 | |||
| 139 | if (payload.scopeType === "account" && payload.accountId === null) { | ||
| 140 | throw new Error("Account scope credentials require accountId"); | ||
| 141 | } | ||
| 142 | |||
| 143 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 144 | const now = Date.now(); | ||
| 145 | const bindingId = payload.id ?? `credential_binding_${crypto.randomUUID()}`; | ||
| 146 | |||
| 147 | const existing = await ctx.db | ||
| 148 | .query("sourceCredentialBindings") | ||
| 149 | .withIndex("by_domainId", (q) => q.eq("id", bindingId)) | ||
| 150 | .unique(); | ||
| 151 | |||
| 152 | const existingBinding = existing | ||
| 153 | ? toSourceCredentialBinding(existing as unknown as Record<string, unknown>) | ||
| 154 | : null; | ||
| 155 | |||
| 156 | if ( | ||
| 157 | existingBinding !== null | ||
| 158 | && !canAccessSourceCredentialBinding(existingBinding, { | ||
| 159 | workspaceId: args.workspaceId, | ||
| 160 | organizationId, | ||
| 161 | }) | ||
| 162 | ) { | ||
| 163 | throw new Error(`Credential binding not found: ${bindingId}`); | ||
| 164 | } | ||
| 165 | |||
| 166 | const nextBinding = decodeSourceCredentialBinding({ | ||
| 167 | id: bindingId, | ||
| 168 | credentialId: payload.credentialId, | ||
| 169 | organizationId, | ||
| 170 | workspaceId: payload.scopeType === "workspace" ? args.workspaceId : null, | ||
| 171 | accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null, | ||
| 172 | scopeType: payload.scopeType, | ||
| 173 | sourceKey: payload.sourceKey, | ||
| 174 | provider: payload.provider, | ||
| 175 | secretRef: payload.secretRef, | ||
| 176 | additionalHeadersJson: payload.additionalHeadersJson ?? null, | ||
| 177 | boundAuthFingerprint: payload.boundAuthFingerprint ?? null, | ||
| 178 | createdAt: existingBinding?.createdAt ?? now, | ||
| 179 | updatedAt: now, | ||
| 180 | }); | ||
| 181 | |||
| 182 | if (existing) { | ||
| 183 | await ctx.db.patch(existing._id, nextBinding); | ||
| 184 | } else { | ||
| 185 | await ctx.db.insert("sourceCredentialBindings", nextBinding); | ||
| 186 | } | ||
| 187 | |||
| 188 | return nextBinding; | ||
| 189 | }, | ||
| 190 | }); | ||
| 191 | |||
| 192 | export const removeCredentialBinding = mutation({ | ||
| 193 | args: { | ||
| 194 | workspaceId: v.string(), | ||
| 195 | credentialBindingId: v.string(), | ||
| 196 | }, | ||
| 197 | handler: async ( | ||
| 198 | ctx, | ||
| 199 | args, | ||
| 200 | ): Promise<{ | ||
| 201 | removed: boolean; | ||
| 202 | }> => { | ||
| 203 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 204 | |||
| 205 | const existing = await ctx.db | ||
| 206 | .query("sourceCredentialBindings") | ||
| 207 | .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId)) | ||
| 208 | .unique(); | ||
| 209 | |||
| 210 | if (!existing) { | ||
| 211 | return { removed: false }; | ||
| 212 | } | ||
| 213 | |||
| 214 | const existingBinding = toSourceCredentialBinding( | ||
| 215 | existing as unknown as Record<string, unknown>, | ||
| 216 | ); | ||
| 217 | |||
| 218 | if ( | ||
| 219 | !canAccessSourceCredentialBinding(existingBinding, { | ||
| 220 | workspaceId: args.workspaceId, | ||
| 221 | organizationId, | ||
| 222 | }) | ||
| 223 | ) { | ||
| 224 | return { removed: false }; | ||
| 225 | } | ||
| 226 | |||
| 227 | await ctx.db.delete(existing._id); | ||
| 228 | |||
| 229 | return { removed: true }; | ||
| 230 | }, | ||
| 231 | }); | ||
| @@ -0,0 +1,113 @@ | |||
| 1 | "use node"; | ||
| 2 | |||
| 3 | import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api"; | ||
| 4 | import { v } from "convex/values"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | |||
| 7 | import { internal } from "../_generated/api"; | ||
| 8 | import { internalAction } from "../_generated/server"; | ||
| 9 | |||
| 10 | const runtimeInternal = internal as any; | ||
| 11 | |||
| 12 | const extractorVersion = "openapi_v2"; | ||
| 13 | const writeBatchSize = 500; | ||
| 14 | |||
| 15 | const formatError = (cause: unknown): string => | ||
| 16 | cause instanceof Error ? cause.message : String(cause); | ||
| 17 | |||
| 18 | export const ingestSourceArtifact = internalAction({ | ||
| 19 | args: { | ||
| 20 | workspaceId: v.string(), | ||
| 21 | sourceId: v.string(), | ||
| 22 | }, | ||
| 23 | handler: async (ctx, args): Promise<void> => { | ||
| 24 | const source = await ctx.runQuery(runtimeInternal.control_plane.sources.getSourceForIngest, { | ||
| 25 | sourceId: args.sourceId, | ||
| 26 | }); | ||
| 27 | |||
| 28 | if (!source || source.workspaceId !== args.workspaceId) { | ||
| 29 | return; | ||
| 30 | } | ||
| 31 | |||
| 32 | if (source.kind !== "openapi") { | ||
| 33 | await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, { | ||
| 34 | sourceId: source.id, | ||
| 35 | status: "error", | ||
| 36 | lastError: `Source ingest for kind '${source.kind}' is not implemented yet`, | ||
| 37 | }); | ||
| 38 | return; | ||
| 39 | } | ||
| 40 | |||
| 41 | await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, { | ||
| 42 | sourceId: source.id, | ||
| 43 | status: "probing", | ||
| 44 | lastError: null, | ||
| 45 | }); | ||
| 46 | |||
| 47 | const manifestResult = await Effect.runPromise( | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const openApiDocument = yield* Effect.tryPromise(() => fetchOpenApiDocument(source.endpoint)); | ||
| 50 | return yield* extractOpenApiManifest(source.name, openApiDocument); | ||
| 51 | }).pipe(Effect.either), | ||
| 52 | ); | ||
| 53 | |||
| 54 | if (manifestResult._tag === "Left") { | ||
| 55 | await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, { | ||
| 56 | sourceId: source.id, | ||
| 57 | status: "error", | ||
| 58 | lastError: formatError(manifestResult.left), | ||
| 59 | }); | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | |||
| 63 | const manifest = manifestResult.right; | ||
| 64 | |||
| 65 | const artifactMeta = await ctx.runMutation( | ||
| 66 | runtimeInternal.control_plane.openapi_ingest_mvp.upsertOpenApiArtifactMeta, | ||
| 67 | { | ||
| 68 | sourceHash: manifest.sourceHash, | ||
| 69 | extractorVersion, | ||
| 70 | toolCount: manifest.tools.length, | ||
| 71 | }, | ||
| 72 | ); | ||
| 73 | |||
| 74 | if (artifactMeta.created) { | ||
| 75 | const allTools = manifest.tools.map((tool) => ({ | ||
| 76 | toolId: tool.toolId, | ||
| 77 | name: tool.name, | ||
| 78 | description: tool.description, | ||
| 79 | method: tool.method, | ||
| 80 | path: tool.path, | ||
| 81 | operationHash: tool.operationHash, | ||
| 82 | invocationJson: JSON.stringify(tool.invocation), | ||
| 83 | })); | ||
| 84 | |||
| 85 | for (let index = 0; index < allTools.length; index += writeBatchSize) { | ||
| 86 | const batch = allTools.slice(index, index + writeBatchSize); | ||
| 87 | await ctx.runMutation( | ||
| 88 | runtimeInternal.control_plane.openapi_ingest_mvp.putOpenApiArtifactToolsBatch, | ||
| 89 | { | ||
| 90 | artifactId: artifactMeta.artifactId, | ||
| 91 | insertOnly: true, | ||
| 92 | tools: batch, | ||
| 93 | }, | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | await ctx.runMutation(runtimeInternal.control_plane.openapi_ingest_mvp.bindSourceToOpenApiArtifact, { | ||
| 99 | workspaceId: source.workspaceId, | ||
| 100 | sourceId: source.id, | ||
| 101 | artifactId: artifactMeta.artifactId, | ||
| 102 | sourceHash: manifest.sourceHash, | ||
| 103 | extractorVersion, | ||
| 104 | }); | ||
| 105 | |||
| 106 | await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, { | ||
| 107 | sourceId: source.id, | ||
| 108 | status: "connected", | ||
| 109 | sourceHash: manifest.sourceHash, | ||
| 110 | lastError: null, | ||
| 111 | }); | ||
| 112 | }, | ||
| 113 | }); | ||
| @@ -0,0 +1,345 @@ | |||
| 1 | import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api"; | ||
| 2 | import { v } from "convex/values"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import { api, internal } from "../_generated/api"; | ||
| 6 | import { action, internalMutation, internalQuery } from "../_generated/server"; | ||
| 7 | |||
| 8 | const runtimeApi = api as any; | ||
| 9 | const runtimeInternal = internal as any; | ||
| 10 | |||
| 11 | const defaultExtractorVersion = "openapi_mvp_v1"; | ||
| 12 | const defaultToolBatchSize = 100; | ||
| 13 | const maxToolBatchSize = 1000; | ||
| 14 | |||
| 15 | |||
| 16 | export const upsertOpenApiArtifactMeta = internalMutation({ | ||
| 17 | args: { | ||
| 18 | sourceHash: v.string(), | ||
| 19 | extractorVersion: v.string(), | ||
| 20 | toolCount: v.number(), | ||
| 21 | }, | ||
| 22 | handler: async (ctx, args) => { | ||
| 23 | const now = Date.now(); | ||
| 24 | const existing = await ctx.db | ||
| 25 | .query("openApiArtifacts") | ||
| 26 | .withIndex("by_sourceHash_extractorVersion", (q) => | ||
| 27 | q.eq("sourceHash", args.sourceHash).eq("extractorVersion", args.extractorVersion) | ||
| 28 | ) | ||
| 29 | .unique(); | ||
| 30 | |||
| 31 | if (existing) { | ||
| 32 | await ctx.db.patch(existing._id, { | ||
| 33 | toolCount: args.toolCount, | ||
| 34 | updatedAt: now, | ||
| 35 | }); | ||
| 36 | |||
| 37 | return { | ||
| 38 | artifactId: existing.id, | ||
| 39 | created: false, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | const artifactId = `oa_${args.sourceHash}_${args.extractorVersion}`; | ||
| 44 | |||
| 45 | await ctx.db.insert("openApiArtifacts", { | ||
| 46 | id: artifactId, | ||
| 47 | sourceHash: args.sourceHash, | ||
| 48 | extractorVersion: args.extractorVersion, | ||
| 49 | toolCount: args.toolCount, | ||
| 50 | createdAt: now, | ||
| 51 | updatedAt: now, | ||
| 52 | }); | ||
| 53 | |||
| 54 | return { | ||
| 55 | artifactId, | ||
| 56 | created: true, | ||
| 57 | }; | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | export const putOpenApiArtifactToolsBatch = internalMutation({ | ||
| 62 | args: { | ||
| 63 | artifactId: v.string(), | ||
| 64 | insertOnly: v.optional(v.boolean()), | ||
| 65 | tools: v.array( | ||
| 66 | v.object({ | ||
| 67 | toolId: v.string(), | ||
| 68 | name: v.string(), | ||
| 69 | description: v.union(v.string(), v.null()), | ||
| 70 | method: v.string(), | ||
| 71 | path: v.string(), | ||
| 72 | operationHash: v.string(), | ||
| 73 | invocationJson: v.string(), | ||
| 74 | }) | ||
| 75 | ), | ||
| 76 | }, | ||
| 77 | handler: async (ctx, args) => { | ||
| 78 | const now = Date.now(); | ||
| 79 | let insertedCount = 0; | ||
| 80 | |||
| 81 | if (args.insertOnly === true) { | ||
| 82 | for (const tool of args.tools) { | ||
| 83 | await ctx.db.insert("openApiArtifactTools", { | ||
| 84 | id: `${args.artifactId}:${tool.toolId}`, | ||
| 85 | artifactId: args.artifactId, | ||
| 86 | toolId: tool.toolId, | ||
| 87 | name: tool.name, | ||
| 88 | description: tool.description, | ||
| 89 | method: tool.method, | ||
| 90 | path: tool.path, | ||
| 91 | operationHash: tool.operationHash, | ||
| 92 | invocationJson: tool.invocationJson, | ||
| 93 | createdAt: now, | ||
| 94 | updatedAt: now, | ||
| 95 | }); | ||
| 96 | insertedCount += 1; | ||
| 97 | } | ||
| 98 | |||
| 99 | return { insertedCount }; | ||
| 100 | } | ||
| 101 | |||
| 102 | for (const tool of args.tools) { | ||
| 103 | const existing = await ctx.db | ||
| 104 | .query("openApiArtifactTools") | ||
| 105 | .withIndex("by_artifactId_toolId", (q) => | ||
| 106 | q.eq("artifactId", args.artifactId).eq("toolId", tool.toolId) | ||
| 107 | ) | ||
| 108 | .unique(); | ||
| 109 | |||
| 110 | const row = { | ||
| 111 | id: `${args.artifactId}:${tool.toolId}`, | ||
| 112 | artifactId: args.artifactId, | ||
| 113 | toolId: tool.toolId, | ||
| 114 | name: tool.name, | ||
| 115 | description: tool.description, | ||
| 116 | method: tool.method, | ||
| 117 | path: tool.path, | ||
| 118 | operationHash: tool.operationHash, | ||
| 119 | invocationJson: tool.invocationJson, | ||
| 120 | createdAt: existing?.createdAt ?? now, | ||
| 121 | updatedAt: now, | ||
| 122 | }; | ||
| 123 | |||
| 124 | if (existing) { | ||
| 125 | await ctx.db.patch(existing._id, row); | ||
| 126 | } else { | ||
| 127 | await ctx.db.insert("openApiArtifactTools", row); | ||
| 128 | insertedCount += 1; | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | return { insertedCount }; | ||
| 133 | }, | ||
| 134 | }); | ||
| 135 | |||
| 136 | export const bindSourceToOpenApiArtifact = internalMutation({ | ||
| 137 | args: { | ||
| 138 | workspaceId: v.string(), | ||
| 139 | sourceId: v.string(), | ||
| 140 | artifactId: v.string(), | ||
| 141 | sourceHash: v.string(), | ||
| 142 | extractorVersion: v.string(), | ||
| 143 | }, | ||
| 144 | handler: async (ctx, args) => { | ||
| 145 | const now = Date.now(); | ||
| 146 | const existing = await ctx.db | ||
| 147 | .query("openApiSourceArtifactBindings") | ||
| 148 | .withIndex("by_workspaceId_sourceId", (q) => | ||
| 149 | q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId) | ||
| 150 | ) | ||
| 151 | .unique(); | ||
| 152 | |||
| 153 | const row = { | ||
| 154 | id: existing?.id ?? `oab_${args.workspaceId}_${args.sourceId}`, | ||
| 155 | workspaceId: args.workspaceId, | ||
| 156 | sourceId: args.sourceId, | ||
| 157 | artifactId: args.artifactId, | ||
| 158 | sourceHash: args.sourceHash, | ||
| 159 | extractorVersion: args.extractorVersion, | ||
| 160 | updatedAt: now, | ||
| 161 | }; | ||
| 162 | |||
| 163 | if (existing) { | ||
| 164 | await ctx.db.patch(existing._id, row); | ||
| 165 | } else { | ||
| 166 | await ctx.db.insert("openApiSourceArtifactBindings", row); | ||
| 167 | } | ||
| 168 | |||
| 169 | return row; | ||
| 170 | }, | ||
| 171 | }); | ||
| 172 | |||
| 173 | export const listSourceOpenApiToolsMvp = internalQuery({ | ||
| 174 | args: { | ||
| 175 | workspaceId: v.string(), | ||
| 176 | sourceId: v.string(), | ||
| 177 | }, | ||
| 178 | handler: async (ctx, args) => { | ||
| 179 | const binding = await ctx.db | ||
| 180 | .query("openApiSourceArtifactBindings") | ||
| 181 | .withIndex("by_workspaceId_sourceId", (q) => | ||
| 182 | q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId) | ||
| 183 | ) | ||
| 184 | .unique(); | ||
| 185 | |||
| 186 | if (!binding) { | ||
| 187 | return []; | ||
| 188 | } | ||
| 189 | |||
| 190 | const rows = await ctx.db | ||
| 191 | .query("openApiArtifactTools") | ||
| 192 | .withIndex("by_artifactId", (q) => q.eq("artifactId", binding.artifactId)) | ||
| 193 | .collect(); | ||
| 194 | |||
| 195 | return rows.map((row) => ({ | ||
| 196 | toolId: row.toolId, | ||
| 197 | name: row.name, | ||
| 198 | method: row.method, | ||
| 199 | path: row.path, | ||
| 200 | })); | ||
| 201 | }, | ||
| 202 | }); | ||
| 203 | |||
| 204 | export const profileOpenApiIngestMvp = action({ | ||
| 205 | args: { | ||
| 206 | workspaceId: v.string(), | ||
| 207 | sourceName: v.string(), | ||
| 208 | specUrl: v.string(), | ||
| 209 | sourceId: v.optional(v.string()), | ||
| 210 | extractorVersion: v.optional(v.string()), | ||
| 211 | writeMode: v.optional(v.union(v.literal("batched"), v.literal("single"))), | ||
| 212 | toolBatchSize: v.optional(v.number()), | ||
| 213 | }, | ||
| 214 | handler: async (ctx, args) => { | ||
| 215 | const startedAt = Date.now(); | ||
| 216 | const selectedExtractorVersion = args.extractorVersion ?? defaultExtractorVersion; | ||
| 217 | const selectedWriteMode = args.writeMode ?? "batched"; | ||
| 218 | const selectedToolBatchSize = Math.max( | ||
| 219 | 1, | ||
| 220 | Math.min(maxToolBatchSize, Math.floor(args.toolBatchSize ?? defaultToolBatchSize)) | ||
| 221 | ); | ||
| 222 | |||
| 223 | const source = await ctx.runMutation(runtimeApi.controlPlane.upsertSource, { | ||
| 224 | workspaceId: args.workspaceId, | ||
| 225 | payload: { | ||
| 226 | ...(args.sourceId ? { id: args.sourceId } : {}), | ||
| 227 | name: args.sourceName, | ||
| 228 | kind: "openapi", | ||
| 229 | endpoint: args.specUrl, | ||
| 230 | enabled: true, | ||
| 231 | configJson: "{}", | ||
| 232 | sourceHash: null, | ||
| 233 | lastError: null, | ||
| 234 | }, | ||
| 235 | }); | ||
| 236 | |||
| 237 | const afterSourceUpsertAt = Date.now(); | ||
| 238 | |||
| 239 | const manifest = await Effect.runPromise( | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const document = yield* Effect.tryPromise(() => fetchOpenApiDocument(args.specUrl)); | ||
| 242 | return yield* extractOpenApiManifest(args.sourceName, document); | ||
| 243 | }) | ||
| 244 | ); | ||
| 245 | |||
| 246 | const afterManifestAt = Date.now(); | ||
| 247 | |||
| 248 | const artifactMeta = await ctx.runMutation( | ||
| 249 | runtimeInternal.control_plane.openapi_ingest_mvp.upsertOpenApiArtifactMeta, | ||
| 250 | { | ||
| 251 | sourceHash: manifest.sourceHash, | ||
| 252 | extractorVersion: selectedExtractorVersion, | ||
| 253 | toolCount: manifest.tools.length, | ||
| 254 | } | ||
| 255 | ); | ||
| 256 | |||
| 257 | const afterArtifactMetaAt = Date.now(); | ||
| 258 | |||
| 259 | let insertedToolCount = 0; | ||
| 260 | |||
| 261 | if (artifactMeta.created) { | ||
| 262 | const allTools = manifest.tools.map((tool) => ({ | ||
| 263 | toolId: tool.toolId, | ||
| 264 | name: tool.name, | ||
| 265 | description: tool.description, | ||
| 266 | method: tool.method, | ||
| 267 | path: tool.path, | ||
| 268 | operationHash: tool.operationHash, | ||
| 269 | invocationJson: JSON.stringify(tool.invocation), | ||
| 270 | })); | ||
| 271 | |||
| 272 | if (selectedWriteMode === "single") { | ||
| 273 | const writeResult = await ctx.runMutation( | ||
| 274 | runtimeInternal.control_plane.openapi_ingest_mvp.putOpenApiArtifactToolsBatch, | ||
| 275 | { | ||
| 276 | artifactId: artifactMeta.artifactId, | ||
| 277 | insertOnly: true, | ||
| 278 | tools: allTools, | ||
| 279 | } | ||
| 280 | ); | ||
| 281 | insertedToolCount += writeResult.insertedCount; | ||
| 282 | } else { | ||
| 283 | for ( | ||
| 284 | let index = 0; | ||
| 285 | index < allTools.length; | ||
| 286 | index += selectedToolBatchSize | ||
| 287 | ) { | ||
| 288 | const batch = allTools.slice(index, index + selectedToolBatchSize); | ||
| 289 | const writeResult = await ctx.runMutation( | ||
| 290 | runtimeInternal.control_plane.openapi_ingest_mvp.putOpenApiArtifactToolsBatch, | ||
| 291 | { | ||
| 292 | artifactId: artifactMeta.artifactId, | ||
| 293 | insertOnly: true, | ||
| 294 | tools: batch, | ||
| 295 | } | ||
| 296 | ); | ||
| 297 | |||
| 298 | insertedToolCount += writeResult.insertedCount; | ||
| 299 | } | ||
| 300 | } | ||
| 301 | } | ||
| 302 | |||
| 303 | const afterToolWritesAt = Date.now(); | ||
| 304 | |||
| 305 | await ctx.runMutation(runtimeInternal.control_plane.openapi_ingest_mvp.bindSourceToOpenApiArtifact, { | ||
| 306 | workspaceId: args.workspaceId, | ||
| 307 | sourceId: source.id, | ||
| 308 | artifactId: artifactMeta.artifactId, | ||
| 309 | sourceHash: manifest.sourceHash, | ||
| 310 | extractorVersion: selectedExtractorVersion, | ||
| 311 | }); | ||
| 312 | |||
| 313 | const afterBindingAt = Date.now(); | ||
| 314 | |||
| 315 | const mvpTools = await ctx.runQuery( | ||
| 316 | runtimeInternal.control_plane.openapi_ingest_mvp.listSourceOpenApiToolsMvp, | ||
| 317 | { | ||
| 318 | workspaceId: args.workspaceId, | ||
| 319 | sourceId: source.id, | ||
| 320 | } | ||
| 321 | ); | ||
| 322 | |||
| 323 | const completedAt = Date.now(); | ||
| 324 | |||
| 325 | return { | ||
| 326 | sourceId: source.id, | ||
| 327 | artifactId: artifactMeta.artifactId, | ||
| 328 | createdArtifact: artifactMeta.created, | ||
| 329 | manifestToolCount: manifest.tools.length, | ||
| 330 | insertedToolCount, | ||
| 331 | boundToolCount: mvpTools.length, | ||
| 332 | extractorVersion: selectedExtractorVersion, | ||
| 333 | writeMode: selectedWriteMode, | ||
| 334 | toolBatchSize: selectedToolBatchSize, | ||
| 335 | timingsMs: { | ||
| 336 | total: completedAt - startedAt, | ||
| 337 | sourceUpsert: afterSourceUpsertAt - startedAt, | ||
| 338 | manifestBuild: afterManifestAt - afterSourceUpsertAt, | ||
| 339 | artifactMeta: afterArtifactMetaAt - afterManifestAt, | ||
| 340 | toolWrites: afterToolWritesAt - afterArtifactMetaAt, | ||
| 341 | bindingAndRead: completedAt - afterToolWritesAt, | ||
| 342 | }, | ||
| 343 | }; | ||
| 344 | }, | ||
| 345 | }); | ||
| @@ -0,0 +1,88 @@ | |||
| 1 | import { type UpsertOrganizationPayload } from "@executor-v2/management-api"; | ||
| 2 | import { OrganizationSchema, type Organization } from "@executor-v2/schema"; | ||
| 3 | import { v } from "convex/values"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import { mutation, query } from "../_generated/server"; | ||
| 7 | |||
| 8 | const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema); | ||
| 9 | |||
| 10 | const stripConvexSystemFields = ( | ||
| 11 | value: Record<string, unknown>, | ||
| 12 | ): Record<string, unknown> => { | ||
| 13 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 14 | return rest; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const toOrganization = (document: Record<string, unknown>): Organization => | ||
| 18 | decodeOrganization(stripConvexSystemFields(document)); | ||
| 19 | |||
| 20 | const organizationStatusValidator = v.union( | ||
| 21 | v.literal("active"), | ||
| 22 | v.literal("suspended"), | ||
| 23 | v.literal("archived"), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const sortOrganizations = ( | ||
| 27 | organizations: ReadonlyArray<Organization>, | ||
| 28 | ): Array<Organization> => | ||
| 29 | [...organizations].sort((left, right) => { | ||
| 30 | const leftName = left.name.toLowerCase(); | ||
| 31 | const rightName = right.name.toLowerCase(); | ||
| 32 | |||
| 33 | if (leftName === rightName) { | ||
| 34 | return left.id.localeCompare(right.id); | ||
| 35 | } | ||
| 36 | |||
| 37 | return leftName.localeCompare(rightName); | ||
| 38 | }); | ||
| 39 | |||
| 40 | export const listOrganizations = query({ | ||
| 41 | args: {}, | ||
| 42 | handler: async (ctx): Promise<Array<Organization>> => { | ||
| 43 | const rows = await ctx.db.query("organizations").collect(); | ||
| 44 | |||
| 45 | return sortOrganizations( | ||
| 46 | rows.map((row) => toOrganization(row as unknown as Record<string, unknown>)), | ||
| 47 | ); | ||
| 48 | }, | ||
| 49 | }); | ||
| 50 | |||
| 51 | export const upsertOrganization = mutation({ | ||
| 52 | args: { | ||
| 53 | payload: v.object({ | ||
| 54 | id: v.optional(v.string()), | ||
| 55 | slug: v.string(), | ||
| 56 | name: v.string(), | ||
| 57 | status: v.optional(organizationStatusValidator), | ||
| 58 | }), | ||
| 59 | }, | ||
| 60 | handler: async (ctx, args): Promise<Organization> => { | ||
| 61 | const payload = args.payload as UpsertOrganizationPayload; | ||
| 62 | const now = Date.now(); | ||
| 63 | const organizationId = payload.id ?? `org_${crypto.randomUUID()}`; | ||
| 64 | |||
| 65 | const existing = await ctx.db | ||
| 66 | .query("organizations") | ||
| 67 | .withIndex("by_domainId", (q) => q.eq("id", organizationId)) | ||
| 68 | .unique(); | ||
| 69 | |||
| 70 | const nextOrganization = decodeOrganization({ | ||
| 71 | id: organizationId, | ||
| 72 | slug: payload.slug, | ||
| 73 | name: payload.name, | ||
| 74 | status: payload.status ?? existing?.status ?? "active", | ||
| 75 | createdByAccountId: existing?.createdByAccountId ?? null, | ||
| 76 | createdAt: existing?.createdAt ?? now, | ||
| 77 | updatedAt: now, | ||
| 78 | }); | ||
| 79 | |||
| 80 | if (existing) { | ||
| 81 | await ctx.db.patch(existing._id, nextOrganization); | ||
| 82 | } else { | ||
| 83 | await ctx.db.insert("organizations", nextOrganization); | ||
| 84 | } | ||
| 85 | |||
| 86 | return nextOrganization; | ||
| 87 | }, | ||
| 88 | }); | ||
| @@ -0,0 +1,120 @@ | |||
| 1 | import { type UpsertPolicyPayload } from "@executor-v2/management-api"; | ||
| 2 | import { PolicySchema, type Policy } from "@executor-v2/schema"; | ||
| 3 | import { v } from "convex/values"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import { mutation, query } from "../_generated/server"; | ||
| 7 | |||
| 8 | const decodePolicy = Schema.decodeUnknownSync(PolicySchema); | ||
| 9 | |||
| 10 | const stripConvexSystemFields = ( | ||
| 11 | value: Record<string, unknown>, | ||
| 12 | ): Record<string, unknown> => { | ||
| 13 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 14 | return rest; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const toPolicy = (document: Record<string, unknown>): Policy => | ||
| 18 | decodePolicy(stripConvexSystemFields(document)); | ||
| 19 | |||
| 20 | const policyDecisionValidator = v.union( | ||
| 21 | v.literal("allow"), | ||
| 22 | v.literal("require_approval"), | ||
| 23 | v.literal("deny"), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> => | ||
| 27 | [...policies].sort((left, right) => { | ||
| 28 | const leftPattern = left.toolPathPattern.toLowerCase(); | ||
| 29 | const rightPattern = right.toolPathPattern.toLowerCase(); | ||
| 30 | |||
| 31 | if (leftPattern === rightPattern) { | ||
| 32 | return left.id.localeCompare(right.id); | ||
| 33 | } | ||
| 34 | |||
| 35 | return leftPattern.localeCompare(rightPattern); | ||
| 36 | }); | ||
| 37 | |||
| 38 | export const listPolicies = query({ | ||
| 39 | args: { | ||
| 40 | workspaceId: v.string(), | ||
| 41 | }, | ||
| 42 | handler: async (ctx, args): Promise<Array<Policy>> => { | ||
| 43 | const rows = await ctx.db | ||
| 44 | .query("policies") | ||
| 45 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 46 | .collect(); | ||
| 47 | |||
| 48 | return sortPolicies( | ||
| 49 | rows.map((row) => toPolicy(row as unknown as Record<string, unknown>)), | ||
| 50 | ); | ||
| 51 | }, | ||
| 52 | }); | ||
| 53 | |||
| 54 | export const upsertPolicy = mutation({ | ||
| 55 | args: { | ||
| 56 | workspaceId: v.string(), | ||
| 57 | payload: v.object({ | ||
| 58 | id: v.optional(v.string()), | ||
| 59 | toolPathPattern: v.string(), | ||
| 60 | decision: policyDecisionValidator, | ||
| 61 | }), | ||
| 62 | }, | ||
| 63 | handler: async (ctx, args): Promise<Policy> => { | ||
| 64 | const payload = args.payload as UpsertPolicyPayload; | ||
| 65 | const now = Date.now(); | ||
| 66 | const policyId = payload.id ?? `pol_${crypto.randomUUID()}`; | ||
| 67 | |||
| 68 | const existing = await ctx.db | ||
| 69 | .query("policies") | ||
| 70 | .withIndex("by_domainId", (q) => q.eq("id", policyId)) | ||
| 71 | .unique(); | ||
| 72 | |||
| 73 | if (existing && existing.workspaceId !== args.workspaceId) { | ||
| 74 | throw new Error(`Policy not found: ${policyId}`); | ||
| 75 | } | ||
| 76 | |||
| 77 | const nextPolicy = decodePolicy({ | ||
| 78 | id: policyId, | ||
| 79 | workspaceId: args.workspaceId, | ||
| 80 | toolPathPattern: payload.toolPathPattern, | ||
| 81 | decision: payload.decision, | ||
| 82 | createdAt: existing?.createdAt ?? now, | ||
| 83 | updatedAt: now, | ||
| 84 | }); | ||
| 85 | |||
| 86 | if (existing) { | ||
| 87 | await ctx.db.patch(existing._id, nextPolicy); | ||
| 88 | } else { | ||
| 89 | await ctx.db.insert("policies", nextPolicy); | ||
| 90 | } | ||
| 91 | |||
| 92 | return nextPolicy; | ||
| 93 | }, | ||
| 94 | }); | ||
| 95 | |||
| 96 | export const removePolicy = mutation({ | ||
| 97 | args: { | ||
| 98 | workspaceId: v.string(), | ||
| 99 | policyId: v.string(), | ||
| 100 | }, | ||
| 101 | handler: async ( | ||
| 102 | ctx, | ||
| 103 | args, | ||
| 104 | ): Promise<{ | ||
| 105 | removed: boolean; | ||
| 106 | }> => { | ||
| 107 | const existing = await ctx.db | ||
| 108 | .query("policies") | ||
| 109 | .withIndex("by_domainId", (q) => q.eq("id", args.policyId)) | ||
| 110 | .unique(); | ||
| 111 | |||
| 112 | if (!existing || existing.workspaceId !== args.workspaceId) { | ||
| 113 | return { removed: false }; | ||
| 114 | } | ||
| 115 | |||
| 116 | await ctx.db.delete(existing._id); | ||
| 117 | |||
| 118 | return { removed: true }; | ||
| 119 | }, | ||
| 120 | }); | ||
| @@ -38,6 +38,184 @@ export const makeConvexControlPlaneService = ( · 37 unmodified lines | |||
| 38 | }), | 38 | }), |
| 39 | catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause), | 39 | catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause), |
| 40 | }), | 40 | }), |
| 41 | listCredentialBindings: (workspaceId) => | ||
| 42 | Effect.tryPromise({ | ||
| 43 | try: () => | ||
| 44 | ctx.runQuery(api.controlPlane.listCredentialBindings, { | ||
| 45 | workspaceId, | ||
| 46 | }), | ||
| 47 | catch: (cause) => | ||
| 48 | toSourceStoreError("controlPlane.listCredentialBindings", cause), | ||
| 49 | }), | ||
| 50 | upsertCredentialBinding: (input) => | ||
| 51 | Effect.tryPromise({ | ||
| 52 | try: () => | ||
| 53 | ctx.runMutation(api.controlPlane.upsertCredentialBinding, { | ||
| 54 | workspaceId: input.workspaceId, | ||
| 55 | payload: input.payload, | ||
| 56 | }), | ||
| 57 | catch: (cause) => | ||
| 58 | toSourceStoreError("controlPlane.upsertCredentialBinding", cause), | ||
| 59 | }), | ||
| 60 | removeCredentialBinding: (input) => | ||
| 61 | Effect.tryPromise({ | ||
| 62 | try: () => | ||
| 63 | ctx.runMutation(api.controlPlane.removeCredentialBinding, { | ||
| 64 | workspaceId: input.workspaceId, | ||
| 65 | credentialBindingId: input.credentialBindingId, | ||
| 66 | }), | ||
| 67 | catch: (cause) => | ||
| 68 | toSourceStoreError("controlPlane.removeCredentialBinding", cause), | ||
| 69 | }), | ||
| 70 | listPolicies: (workspaceId) => | ||
| 71 | Effect.tryPromise({ | ||
| 72 | try: () => | ||
| 73 | ctx.runQuery(api.controlPlane.listPolicies, { | ||
| 74 | workspaceId, | ||
| 75 | }), | ||
| 76 | catch: (cause) => toSourceStoreError("controlPlane.listPolicies", cause), | ||
| 77 | }), | ||
| 78 | upsertPolicy: (input) => | ||
| 79 | Effect.tryPromise({ | ||
| 80 | try: () => | ||
| 81 | ctx.runMutation(api.controlPlane.upsertPolicy, { | ||
| 82 | workspaceId: input.workspaceId, | ||
| 83 | payload: input.payload, | ||
| 84 | }), | ||
| 85 | catch: (cause) => toSourceStoreError("controlPlane.upsertPolicy", cause), | ||
| 86 | }), | ||
| 87 | removePolicy: (input) => | ||
| 88 | Effect.tryPromise({ | ||
| 89 | try: () => | ||
| 90 | ctx.runMutation(api.controlPlane.removePolicy, { | ||
| 91 | workspaceId: input.workspaceId, | ||
| 92 | policyId: input.policyId, | ||
| 93 | }), | ||
| 94 | catch: (cause) => toSourceStoreError("controlPlane.removePolicy", cause), | ||
| 95 | }), | ||
| 96 | listOrganizations: () => | ||
| 97 | Effect.tryPromise({ | ||
| 98 | try: () => ctx.runQuery(api.controlPlane.listOrganizations, {}), | ||
| 99 | catch: (cause) => toSourceStoreError("controlPlane.listOrganizations", cause), | ||
| 100 | }), | ||
| 101 | upsertOrganization: (input) => | ||
| 102 | Effect.tryPromise({ | ||
| 103 | try: () => | ||
| 104 | ctx.runMutation(api.controlPlane.upsertOrganization, { | ||
| 105 | payload: input.payload, | ||
| 106 | }), | ||
| 107 | catch: (cause) => toSourceStoreError("controlPlane.upsertOrganization", cause), | ||
| 108 | }), | ||
| 109 | listWorkspaces: () => | ||
| 110 | Effect.tryPromise({ | ||
| 111 | try: () => ctx.runQuery(api.controlPlane.listWorkspaces, {}), | ||
| 112 | catch: (cause) => toSourceStoreError("controlPlane.listWorkspaces", cause), | ||
| 113 | }), | ||
| 114 | upsertWorkspace: (input) => | ||
| 115 | Effect.tryPromise({ | ||
| 116 | try: () => | ||
| 117 | ctx.runMutation(api.controlPlane.upsertWorkspace, { | ||
| 118 | payload: input.payload, | ||
| 119 | }), | ||
| 120 | catch: (cause) => toSourceStoreError("controlPlane.upsertWorkspace", cause), | ||
| 121 | }), | ||
| 122 | listWorkspaceTools: (workspaceId) => | ||
| 123 | Effect.tryPromise({ | ||
| 124 | try: () => | ||
| 125 | ctx.runQuery(api.controlPlane.listWorkspaceTools, { | ||
| 126 | workspaceId, | ||
| 127 | }), | ||
| 128 | catch: (cause) => toSourceStoreError("controlPlane.listWorkspaceTools", cause), | ||
| 129 | }), | ||
| 130 | listSourceTools: (input) => | ||
| 131 | Effect.tryPromise({ | ||
| 132 | try: () => | ||
| 133 | ctx.runQuery(api.controlPlane.listSourceTools, { | ||
| 134 | workspaceId: input.workspaceId, | ||
| 135 | sourceId: input.sourceId, | ||
| 136 | }), | ||
| 137 | catch: (cause) => toSourceStoreError("controlPlane.listSourceTools", cause), | ||
| 138 | }), | ||
| 139 | listStorageInstances: (workspaceId) => | ||
| 140 | Effect.tryPromise({ | ||
| 141 | try: () => | ||
| 142 | ctx.runQuery(api.controlPlane.listStorageInstances, { | ||
| 143 | workspaceId, | ||
| 144 | }), | ||
| 145 | catch: (cause) => | ||
| 146 | toSourceStoreError("controlPlane.listStorageInstances", cause), | ||
| 147 | }), | ||
| 148 | openStorageInstance: (input) => | ||
| 149 | Effect.tryPromise({ | ||
| 150 | try: () => | ||
| 151 | ctx.runMutation(api.controlPlane.openStorageInstance, { | ||
| 152 | workspaceId: input.workspaceId, | ||
| 153 | payload: input.payload, | ||
| 154 | }), | ||
| 155 | catch: (cause) => | ||
| 156 | toSourceStoreError("controlPlane.openStorageInstance", cause), | ||
| 157 | }), | ||
| 158 | closeStorageInstance: (input) => | ||
| 159 | Effect.tryPromise({ | ||
| 160 | try: () => | ||
| 161 | ctx.runMutation(api.controlPlane.closeStorageInstance, { | ||
| 162 | workspaceId: input.workspaceId, | ||
| 163 | storageInstanceId: input.storageInstanceId, | ||
| 164 | }), | ||
| 165 | catch: (cause) => | ||
| 166 | toSourceStoreError("controlPlane.closeStorageInstance", cause), | ||
| 167 | }), | ||
| 168 | removeStorageInstance: (input) => | ||
| 169 | Effect.tryPromise({ | ||
| 170 | try: () => | ||
| 171 | ctx.runMutation(api.controlPlane.removeStorageInstance, { | ||
| 172 | workspaceId: input.workspaceId, | ||
| 173 | storageInstanceId: input.storageInstanceId, | ||
| 174 | }), | ||
| 175 | catch: (cause) => | ||
| 176 | toSourceStoreError("controlPlane.removeStorageInstance", cause), | ||
| 177 | }), | ||
| 178 | listStorageDirectory: (input) => | ||
| 179 | Effect.tryPromise({ | ||
| 180 | try: () => | ||
| 181 | ctx.runQuery(api.controlPlane.listStorageDirectory, { | ||
| 182 | workspaceId: input.workspaceId, | ||
| 183 | storageInstanceId: input.storageInstanceId, | ||
| 184 | payload: input.payload, | ||
| 185 | }), | ||
| 186 | catch: (cause) => | ||
| 187 | toSourceStoreError("controlPlane.listStorageDirectory", cause), | ||
| 188 | }), | ||
| 189 | readStorageFile: (input) => | ||
| 190 | Effect.tryPromise({ | ||
| 191 | try: () => | ||
| 192 | ctx.runQuery(api.controlPlane.readStorageFile, { | ||
| 193 | workspaceId: input.workspaceId, | ||
| 194 | storageInstanceId: input.storageInstanceId, | ||
| 195 | payload: input.payload, | ||
| 196 | }), | ||
| 197 | catch: (cause) => toSourceStoreError("controlPlane.readStorageFile", cause), | ||
| 198 | }), | ||
| 199 | listStorageKv: (input) => | ||
| 200 | Effect.tryPromise({ | ||
| 201 | try: () => | ||
| 202 | ctx.runQuery(api.controlPlane.listStorageKv, { | ||
| 203 | workspaceId: input.workspaceId, | ||
| 204 | storageInstanceId: input.storageInstanceId, | ||
| 205 | payload: input.payload, | ||
| 206 | }), | ||
| 207 | catch: (cause) => toSourceStoreError("controlPlane.listStorageKv", cause), | ||
| 208 | }), | ||
| 209 | queryStorageSql: (input) => | ||
| 210 | Effect.tryPromise({ | ||
| 211 | try: () => | ||
| 212 | ctx.runMutation(api.controlPlane.queryStorageSql, { | ||
| 213 | workspaceId: input.workspaceId, | ||
| 214 | storageInstanceId: input.storageInstanceId, | ||
| 215 | payload: input.payload, | ||
| 216 | }), | ||
| 217 | catch: (cause) => toSourceStoreError("controlPlane.queryStorageSql", cause), | ||
| 218 | }), | ||
| 41 | listApprovals: (workspaceId) => | 219 | listApprovals: (workspaceId) => |
| 42 | Effect.tryPromise({ | 220 | Effect.tryPromise({ |
| 43 | try: () => | 221 | try: () => |
| @@ -3,7 +3,8 @@ import { SourceSchema, type Source } from "@executor-v2/schema"; · 2 unmodified lines | |||
| 3 | import { v } from "convex/values"; | 3 | import { v } from "convex/values"; |
| 4 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | ||
| 6 | import { mutation, query } from "../_generated/server"; | 6 | import { api } from "../_generated/api"; |
| 7 | import { internalQuery, internalMutation, mutation, query } from "../_generated/server"; | ||
| 7 | 8 | ||
| 8 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); | 9 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); |
| 9 | 10 | ||
| @@ -109,10 +110,63 @@ export const upsertSource = mutation({ · 99 unmodified lines | |||
| 109 | await ctx.db.insert("sources", source); | 110 | await ctx.db.insert("sources", source); |
| 110 | } | 111 | } |
| 111 | 112 | ||
| 113 | await ctx.scheduler.runAfter( | ||
| 114 | 0, | ||
| 115 | (api as any).control_plane.openapi_ingest.ingestSourceArtifact, | ||
| 116 | { | ||
| 117 | workspaceId: args.workspaceId, | ||
| 118 | sourceId, | ||
| 119 | }, | ||
| 120 | ); | ||
| 121 | |||
| 112 | return source; | 122 | return source; |
| 113 | }, | 123 | }, |
| 114 | }); | 124 | }); |
| 115 | 125 | ||
| 126 | export const getSourceForIngest = internalQuery({ | ||
| 127 | args: { | ||
| 128 | sourceId: v.string(), | ||
| 129 | }, | ||
| 130 | handler: async (ctx, args): Promise<Source | null> => { | ||
| 131 | const sourceRow = await ctx.db | ||
| 132 | .query("sources") | ||
| 133 | .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | ||
| 134 | .unique(); | ||
| 135 | |||
| 136 | if (!sourceRow) { | ||
| 137 | return null; | ||
| 138 | } | ||
| 139 | |||
| 140 | return toSource(sourceRow as unknown as Record<string, unknown>); | ||
| 141 | }, | ||
| 142 | }); | ||
| 143 | |||
| 144 | export const setSourceIngestState = internalMutation({ | ||
| 145 | args: { | ||
| 146 | sourceId: v.string(), | ||
| 147 | status: sourceStatusValidator, | ||
| 148 | sourceHash: v.optional(v.union(v.string(), v.null())), | ||
| 149 | lastError: v.optional(v.union(v.string(), v.null())), | ||
| 150 | }, | ||
| 151 | handler: async (ctx, args): Promise<void> => { | ||
| 152 | const sourceRow = await ctx.db | ||
| 153 | .query("sources") | ||
| 154 | .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | ||
| 155 | .unique(); | ||
| 156 | |||
| 157 | if (!sourceRow) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | |||
| 161 | await ctx.db.patch(sourceRow._id, { | ||
| 162 | status: args.status, | ||
| 163 | sourceHash: args.sourceHash ?? sourceRow.sourceHash, | ||
| 164 | lastError: args.lastError ?? sourceRow.lastError, | ||
| 165 | updatedAt: Date.now(), | ||
| 166 | }); | ||
| 167 | }, | ||
| 168 | }); | ||
| 169 | |||
| 116 | export const removeSource = mutation({ | 170 | export const removeSource = mutation({ |
| 117 | args: { | 171 | args: { |
| 118 | workspaceId: v.string(), | 172 | workspaceId: v.string(), |
| @@ -133,6 +187,17 @@ export const removeSource = mutation({ · 14 unmodified lines | |||
| 133 | return { removed: false }; | 187 | return { removed: false }; |
| 134 | } | 188 | } |
| 135 | 189 | ||
| 190 | const existingBinding = await ctx.db | ||
| 191 | .query("openApiSourceArtifactBindings") | ||
| 192 | .withIndex("by_workspaceId_sourceId", (q) => | ||
| 193 | q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId) | ||
| 194 | ) | ||
| 195 | .unique(); | ||
| 196 | |||
| 197 | if (existingBinding) { | ||
| 198 | await ctx.db.delete(existingBinding._id); | ||
| 199 | } | ||
| 200 | |||
| 136 | await ctx.db.delete(existing._id); | 201 | await ctx.db.delete(existing._id); |
| 137 | 202 | ||
| 138 | return { removed: true }; | 203 | return { removed: true }; |
| @@ -0,0 +1,1069 @@ | |||
| 1 | import { | ||
| 2 | type ListStorageDirectoryResult, | ||
| 3 | type ListStorageKvResult, | ||
| 4 | type OpenStorageInstancePayload, | ||
| 5 | type QueryStorageSqlResult, | ||
| 6 | type ReadStorageFileResult, | ||
| 7 | } from "@executor-v2/management-api"; | ||
| 8 | import { StorageInstanceSchema, type StorageInstance } from "@executor-v2/schema"; | ||
| 9 | import { v } from "convex/values"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | internalMutation, | ||
| 14 | mutation, | ||
| 15 | query, | ||
| 16 | type MutationCtx, | ||
| 17 | type QueryCtx, | ||
| 18 | } from "../_generated/server"; | ||
| 19 | |||
| 20 | const decodeStorageInstance = Schema.decodeUnknownSync(StorageInstanceSchema); | ||
| 21 | |||
| 22 | const StorageFileEntrySchema = Schema.Struct({ | ||
| 23 | id: Schema.String, | ||
| 24 | storageInstanceId: Schema.String, | ||
| 25 | path: Schema.String, | ||
| 26 | contentBase64: Schema.String, | ||
| 27 | sizeBytes: Schema.Number, | ||
| 28 | updatedAt: Schema.Number, | ||
| 29 | }); | ||
| 30 | |||
| 31 | type StorageFileEntry = typeof StorageFileEntrySchema.Type; | ||
| 32 | const decodeStorageFileEntry = Schema.decodeUnknownSync(StorageFileEntrySchema); | ||
| 33 | |||
| 34 | const StorageKvEntrySchema = Schema.Struct({ | ||
| 35 | id: Schema.String, | ||
| 36 | storageInstanceId: Schema.String, | ||
| 37 | key: Schema.String, | ||
| 38 | valueJson: Schema.String, | ||
| 39 | updatedAt: Schema.Number, | ||
| 40 | }); | ||
| 41 | |||
| 42 | type StorageKvEntry = typeof StorageKvEntrySchema.Type; | ||
| 43 | const decodeStorageKvEntry = Schema.decodeUnknownSync(StorageKvEntrySchema); | ||
| 44 | |||
| 45 | const StorageSqlKvEntrySchema = Schema.Struct({ | ||
| 46 | id: Schema.String, | ||
| 47 | storageInstanceId: Schema.String, | ||
| 48 | key: Schema.String, | ||
| 49 | value: Schema.String, | ||
| 50 | updatedAt: Schema.Number, | ||
| 51 | }); | ||
| 52 | |||
| 53 | type StorageSqlKvEntry = typeof StorageSqlKvEntrySchema.Type; | ||
| 54 | const decodeStorageSqlKvEntry = Schema.decodeUnknownSync(StorageSqlKvEntrySchema); | ||
| 55 | |||
| 56 | const DEFAULT_KV_LIMIT = 100; | ||
| 57 | const MAX_KV_LIMIT = 1000; | ||
| 58 | const DEFAULT_SQL_MAX_ROWS = 200; | ||
| 59 | const MAX_SQL_MAX_ROWS = 5000; | ||
| 60 | |||
| 61 | const stripConvexSystemFields = ( | ||
| 62 | value: Record<string, unknown>, | ||
| 63 | ): Record<string, unknown> => { | ||
| 64 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 65 | return rest; | ||
| 66 | }; | ||
| 67 | |||
| 68 | const toStorageInstance = (document: Record<string, unknown>): StorageInstance => | ||
| 69 | decodeStorageInstance(stripConvexSystemFields(document)); | ||
| 70 | |||
| 71 | const toStorageFileEntry = (document: Record<string, unknown>): StorageFileEntry => | ||
| 72 | decodeStorageFileEntry(stripConvexSystemFields(document)); | ||
| 73 | |||
| 74 | const toStorageKvEntry = (document: Record<string, unknown>): StorageKvEntry => | ||
| 75 | decodeStorageKvEntry(stripConvexSystemFields(document)); | ||
| 76 | |||
| 77 | const toStorageSqlKvEntry = (document: Record<string, unknown>): StorageSqlKvEntry => | ||
| 78 | decodeStorageSqlKvEntry(stripConvexSystemFields(document)); | ||
| 79 | |||
| 80 | const storageScopeTypeValidator = v.union( | ||
| 81 | v.literal("scratch"), | ||
| 82 | v.literal("account"), | ||
| 83 | v.literal("workspace"), | ||
| 84 | v.literal("organization"), | ||
| 85 | ); | ||
| 86 | |||
| 87 | const storageDurabilityValidator = v.union( | ||
| 88 | v.literal("ephemeral"), | ||
| 89 | v.literal("durable"), | ||
| 90 | ); | ||
| 91 | |||
| 92 | const storageProviderValidator = v.union( | ||
| 93 | v.literal("agentfs-local"), | ||
| 94 | v.literal("agentfs-cloudflare"), | ||
| 95 | ); | ||
| 96 | |||
| 97 | const readStorageEncodingValidator = v.union( | ||
| 98 | v.literal("utf8"), | ||
| 99 | v.literal("base64"), | ||
| 100 | ); | ||
| 101 | |||
| 102 | const sortStorageInstances = ( | ||
| 103 | storageInstances: ReadonlyArray<StorageInstance>, | ||
| 104 | ): Array<StorageInstance> => | ||
| 105 | [...storageInstances].sort((left, right) => { | ||
| 106 | if (left.updatedAt !== right.updatedAt) { | ||
| 107 | return right.updatedAt - left.updatedAt; | ||
| 108 | } | ||
| 109 | |||
| 110 | return left.id.localeCompare(right.id); | ||
| 111 | }); | ||
| 112 | |||
| 113 | const resolveWorkspaceOrganizationId = async ( | ||
| 114 | ctx: QueryCtx | MutationCtx, | ||
| 115 | workspaceId: string, | ||
| 116 | ): Promise<string> => { | ||
| 117 | const workspace = await ctx.db | ||
| 118 | .query("workspaces") | ||
| 119 | .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | ||
| 120 | .unique(); | ||
| 121 | |||
| 122 | if (workspace?.organizationId !== null && workspace?.organizationId !== undefined) { | ||
| 123 | return workspace.organizationId; | ||
| 124 | } | ||
| 125 | |||
| 126 | return `org_${workspaceId}`; | ||
| 127 | }; | ||
| 128 | |||
| 129 | const canAccessStorageInstance = ( | ||
| 130 | storageInstance: StorageInstance, | ||
| 131 | input: { | ||
| 132 | workspaceId: string; | ||
| 133 | organizationId: string; | ||
| 134 | }, | ||
| 135 | ): boolean => | ||
| 136 | storageInstance.workspaceId === input.workspaceId | ||
| 137 | || ( | ||
| 138 | storageInstance.workspaceId === null | ||
| 139 | && storageInstance.organizationId === input.organizationId | ||
| 140 | ); | ||
| 141 | |||
| 142 | const normalizeStoragePath = (pathValue: string): string => { | ||
| 143 | const trimmed = pathValue.trim().replaceAll("\\", "/"); | ||
| 144 | const prefixed = trimmed.length > 0 | ||
| 145 | ? (trimmed.startsWith("/") ? trimmed : `/${trimmed}`) | ||
| 146 | : "/"; | ||
| 147 | |||
| 148 | const parts = prefixed.split("/"); | ||
| 149 | const normalized: Array<string> = []; | ||
| 150 | |||
| 151 | for (const part of parts) { | ||
| 152 | if (part.length === 0 || part === ".") { | ||
| 153 | continue; | ||
| 154 | } | ||
| 155 | |||
| 156 | if (part === "..") { | ||
| 157 | if (normalized.length === 0) { | ||
| 158 | throw new Error("Path escapes storage root"); | ||
| 159 | } | ||
| 160 | normalized.pop(); | ||
| 161 | continue; | ||
| 162 | } | ||
| 163 | |||
| 164 | normalized.push(part); | ||
| 165 | } | ||
| 166 | |||
| 167 | if (normalized.length === 0) { | ||
| 168 | return "/"; | ||
| 169 | } | ||
| 170 | |||
| 171 | return `/${normalized.join("/")}`; | ||
| 172 | }; | ||
| 173 | |||
| 174 | const encodeUtf8Base64 = (value: string): string => { | ||
| 175 | if (typeof Buffer !== "undefined") { | ||
| 176 | return Buffer.from(value, "utf8").toString("base64"); | ||
| 177 | } | ||
| 178 | |||
| 179 | const bytes = new TextEncoder().encode(value); | ||
| 180 | let binary = ""; | ||
| 181 | for (const byte of bytes) { | ||
| 182 | binary += String.fromCharCode(byte); | ||
| 183 | } | ||
| 184 | return btoa(binary); | ||
| 185 | }; | ||
| 186 | |||
| 187 | const decodeBase64Utf8 = (value: string): string => { | ||
| 188 | if (typeof Buffer !== "undefined") { | ||
| 189 | return Buffer.from(value, "base64").toString("utf8"); | ||
| 190 | } | ||
| 191 | |||
| 192 | const binary = atob(value); | ||
| 193 | const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); | ||
| 194 | return new TextDecoder().decode(bytes); | ||
| 195 | }; | ||
| 196 | |||
| 197 | const parseJsonOrString = (value: string): unknown => { | ||
| 198 | try { | ||
| 199 | return JSON.parse(value) as unknown; | ||
| 200 | } catch { | ||
| 201 | return value; | ||
| 202 | } | ||
| 203 | }; | ||
| 204 | |||
| 205 | const resolveAccessibleStorageInstance = async ( | ||
| 206 | ctx: QueryCtx | MutationCtx, | ||
| 207 | input: { | ||
| 208 | workspaceId: string; | ||
| 209 | storageInstanceId: string; | ||
| 210 | }, | ||
| 211 | ): Promise<StorageInstance | null> => { | ||
| 212 | const organizationId = await resolveWorkspaceOrganizationId(ctx, input.workspaceId); | ||
| 213 | const storageInstanceRow = await ctx.db | ||
| 214 | .query("storageInstances") | ||
| 215 | .withIndex("by_domainId", (q) => q.eq("id", input.storageInstanceId)) | ||
| 216 | .unique(); | ||
| 217 | |||
| 218 | if (!storageInstanceRow) { | ||
| 219 | return null; | ||
| 220 | } | ||
| 221 | |||
| 222 | const storageInstance = toStorageInstance( | ||
| 223 | storageInstanceRow as unknown as Record<string, unknown>, | ||
| 224 | ); | ||
| 225 | |||
| 226 | if ( | ||
| 227 | !canAccessStorageInstance(storageInstance, { | ||
| 228 | workspaceId: input.workspaceId, | ||
| 229 | organizationId, | ||
| 230 | }) | ||
| 231 | ) { | ||
| 232 | return null; | ||
| 233 | } | ||
| 234 | |||
| 235 | return storageInstance; | ||
| 236 | }; | ||
| 237 | |||
| 238 | export const upsertStorageFileEntry = internalMutation({ | ||
| 239 | args: { | ||
| 240 | workspaceId: v.string(), | ||
| 241 | storageInstanceId: v.string(), | ||
| 242 | payload: v.object({ | ||
| 243 | path: v.string(), | ||
| 244 | content: v.string(), | ||
| 245 | }), | ||
| 246 | }, | ||
| 247 | handler: async (ctx, args): Promise<StorageFileEntry> => { | ||
| 248 | const storageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 249 | workspaceId: args.workspaceId, | ||
| 250 | storageInstanceId: args.storageInstanceId, | ||
| 251 | }); | ||
| 252 | |||
| 253 | if (!storageInstance) { | ||
| 254 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 255 | } | ||
| 256 | |||
| 257 | const normalizedPath = normalizeStoragePath(args.payload.path); | ||
| 258 | const now = Date.now(); | ||
| 259 | const contentBase64 = encodeUtf8Base64(args.payload.content); | ||
| 260 | const sizeBytes = args.payload.content.length; | ||
| 261 | |||
| 262 | const existingRow = await ctx.db | ||
| 263 | .query("storageFileEntries") | ||
| 264 | .withIndex("by_storageInstanceId_path", (q) => | ||
| 265 | q.eq("storageInstanceId", args.storageInstanceId).eq("path", normalizedPath) | ||
| 266 | ) | ||
| 267 | .unique(); | ||
| 268 | |||
| 269 | const nextEntry = decodeStorageFileEntry({ | ||
| 270 | id: | ||
| 271 | (existingRow | ||
| 272 | ? toStorageFileEntry(existingRow as unknown as Record<string, unknown>).id | ||
| 273 | : `storage_file_${crypto.randomUUID()}`), | ||
| 274 | storageInstanceId: args.storageInstanceId, | ||
| 275 | path: normalizedPath, | ||
| 276 | contentBase64, | ||
| 277 | sizeBytes, | ||
| 278 | updatedAt: now, | ||
| 279 | }); | ||
| 280 | |||
| 281 | if (existingRow) { | ||
| 282 | await ctx.db.patch(existingRow._id, nextEntry); | ||
| 283 | } else { | ||
| 284 | await ctx.db.insert("storageFileEntries", nextEntry); | ||
| 285 | } | ||
| 286 | |||
| 287 | return nextEntry; | ||
| 288 | }, | ||
| 289 | }); | ||
| 290 | |||
| 291 | export const upsertStorageKvEntry = internalMutation({ | ||
| 292 | args: { | ||
| 293 | workspaceId: v.string(), | ||
| 294 | storageInstanceId: v.string(), | ||
| 295 | payload: v.object({ | ||
| 296 | key: v.string(), | ||
| 297 | valueJson: v.string(), | ||
| 298 | }), | ||
| 299 | }, | ||
| 300 | handler: async (ctx, args): Promise<StorageKvEntry> => { | ||
| 301 | const storageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 302 | workspaceId: args.workspaceId, | ||
| 303 | storageInstanceId: args.storageInstanceId, | ||
| 304 | }); | ||
| 305 | |||
| 306 | if (!storageInstance) { | ||
| 307 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 308 | } | ||
| 309 | |||
| 310 | const now = Date.now(); | ||
| 311 | |||
| 312 | const existingRow = await ctx.db | ||
| 313 | .query("storageKvEntries") | ||
| 314 | .withIndex("by_storageInstanceId_key", (q) => | ||
| 315 | q.eq("storageInstanceId", args.storageInstanceId).eq("key", args.payload.key) | ||
| 316 | ) | ||
| 317 | .unique(); | ||
| 318 | |||
| 319 | const nextEntry = decodeStorageKvEntry({ | ||
| 320 | id: | ||
| 321 | (existingRow | ||
| 322 | ? toStorageKvEntry(existingRow as unknown as Record<string, unknown>).id | ||
| 323 | : `storage_kv_${crypto.randomUUID()}`), | ||
| 324 | storageInstanceId: args.storageInstanceId, | ||
| 325 | key: args.payload.key, | ||
| 326 | valueJson: args.payload.valueJson, | ||
| 327 | updatedAt: now, | ||
| 328 | }); | ||
| 329 | |||
| 330 | if (existingRow) { | ||
| 331 | await ctx.db.patch(existingRow._id, nextEntry); | ||
| 332 | } else { | ||
| 333 | await ctx.db.insert("storageKvEntries", nextEntry); | ||
| 334 | } | ||
| 335 | |||
| 336 | return nextEntry; | ||
| 337 | }, | ||
| 338 | }); | ||
| 339 | |||
| 340 | const decodeSqlStringLiteral = (value: string): string => value.replaceAll("''", "'"); | ||
| 341 | |||
| 342 | const parseSqlWhere = ( | ||
| 343 | whereClause: string | undefined, | ||
| 344 | ): ((key: string) => boolean) => { | ||
| 345 | if (whereClause === undefined) { | ||
| 346 | return () => true; | ||
| 347 | } | ||
| 348 | |||
| 349 | const equalsMatch = /^key\s*=\s*'((?:''|[^'])*)'$/i.exec(whereClause.trim()); | ||
| 350 | if (equalsMatch) { | ||
| 351 | const expectedKey = decodeSqlStringLiteral(equalsMatch[1] ?? ""); | ||
| 352 | return (key) => key === expectedKey; | ||
| 353 | } | ||
| 354 | |||
| 355 | const likeMatch = /^key\s+like\s+'((?:''|[^'])*)'$/i.exec(whereClause.trim()); | ||
| 356 | if (likeMatch) { | ||
| 357 | const pattern = decodeSqlStringLiteral(likeMatch[1] ?? ""); | ||
| 358 | if (!pattern.endsWith("%") || pattern.slice(0, -1).includes("%")) { | ||
| 359 | throw new Error("Only trailing % LIKE patterns are supported"); | ||
| 360 | } | ||
| 361 | |||
| 362 | const prefix = pattern.slice(0, -1); | ||
| 363 | return (key) => key.startsWith(prefix); | ||
| 364 | } | ||
| 365 | |||
| 366 | throw new Error("Unsupported SQL WHERE clause for kv_store"); | ||
| 367 | }; | ||
| 368 | |||
| 369 | const parseSelectColumns = (columnsClause: string): Array<"key" | "value"> => { | ||
| 370 | const normalized = columnsClause.trim().toLowerCase(); | ||
| 371 | if (normalized === "*") { | ||
| 372 | return ["key", "value"]; | ||
| 373 | } | ||
| 374 | |||
| 375 | const columns = normalized | ||
| 376 | .split(",") | ||
| 377 | .map((column) => column.trim()) | ||
| 378 | .filter((column) => column.length > 0); | ||
| 379 | |||
| 380 | if (columns.length === 0) { | ||
| 381 | throw new Error("SQL SELECT columns are required"); | ||
| 382 | } | ||
| 383 | |||
| 384 | const parsedColumns: Array<"key" | "value"> = []; | ||
| 385 | |||
| 386 | for (const column of columns) { | ||
| 387 | if (column !== "key" && column !== "value") { | ||
| 388 | throw new Error("Only key/value columns are supported for kv_store"); | ||
| 389 | } | ||
| 390 | |||
| 391 | if (!parsedColumns.includes(column)) { | ||
| 392 | parsedColumns.push(column); | ||
| 393 | } | ||
| 394 | } | ||
| 395 | |||
| 396 | return parsedColumns; | ||
| 397 | }; | ||
| 398 | |||
| 399 | type SqlWrite = | ||
| 400 | | { | ||
| 401 | type: "upsert"; | ||
| 402 | key: string; | ||
| 403 | value: string; | ||
| 404 | insertOnly: boolean; | ||
| 405 | } | ||
| 406 | | { | ||
| 407 | type: "delete"; | ||
| 408 | key: string; | ||
| 409 | }; | ||
| 410 | |||
| 411 | const executeSqlForStorage = ( | ||
| 412 | sqlText: string, | ||
| 413 | sqlRows: ReadonlyArray<StorageSqlKvEntry>, | ||
| 414 | maxRows: number, | ||
| 415 | ): { | ||
| 416 | result: QueryStorageSqlResult; | ||
| 417 | writes: Array<SqlWrite>; | ||
| 418 | } => { | ||
| 419 | const trimmed = sqlText.trim(); | ||
| 420 | |||
| 421 | if (/^create\s+table\s+if\s+not\s+exists\s+kv_store\b/i.test(trimmed)) { | ||
| 422 | return { | ||
| 423 | result: { | ||
| 424 | rows: [], | ||
| 425 | columns: [], | ||
| 426 | rowCount: 0, | ||
| 427 | }, | ||
| 428 | writes: [], | ||
| 429 | }; | ||
| 430 | } | ||
| 431 | |||
| 432 | if (/^(begin|commit|rollback)\b/i.test(trimmed)) { | ||
| 433 | return { | ||
| 434 | result: { | ||
| 435 | rows: [], | ||
| 436 | columns: [], | ||
| 437 | rowCount: 0, | ||
| 438 | }, | ||
| 439 | writes: [], | ||
| 440 | }; | ||
| 441 | } | ||
| 442 | |||
| 443 | const sortedRows = [...sqlRows].sort((left, right) => left.key.localeCompare(right.key)); | ||
| 444 | |||
| 445 | const selectMatch = /^select\s+(.+?)\s+from\s+kv_store(?:\s+where\s+(.+?))?(?:\s+limit\s+(\d+))?\s*;?$/i.exec(trimmed); | ||
| 446 | if (selectMatch) { | ||
| 447 | const columns = parseSelectColumns(selectMatch[1] ?? ""); | ||
| 448 | const where = parseSqlWhere(selectMatch[2]); | ||
| 449 | const sqlLimit = | ||
| 450 | selectMatch[3] !== undefined | ||
| 451 | ? Math.max(1, Math.min(maxRows, Number.parseInt(selectMatch[3], 10))) | ||
| 452 | : maxRows; | ||
| 453 | |||
| 454 | const rows = sortedRows | ||
| 455 | .filter((row) => where(row.key)) | ||
| 456 | .slice(0, sqlLimit) | ||
| 457 | .map((row) => { | ||
| 458 | const resultRow: Record<string, unknown> = {}; | ||
| 459 | |||
| 460 | for (const column of columns) { | ||
| 461 | if (column === "key") { | ||
| 462 | resultRow.key = row.key; | ||
| 463 | } else { | ||
| 464 | resultRow.value = row.value; | ||
| 465 | } | ||
| 466 | } | ||
| 467 | |||
| 468 | return resultRow; | ||
| 469 | }); | ||
| 470 | |||
| 471 | return { | ||
| 472 | result: { | ||
| 473 | rows, | ||
| 474 | columns, | ||
| 475 | rowCount: rows.length, | ||
| 476 | }, | ||
| 477 | writes: [], | ||
| 478 | }; | ||
| 479 | } | ||
| 480 | |||
| 481 | const insertMatch = /^insert\s+into\s+kv_store\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*'((?:''|[^'])*)'\s*,\s*'((?:''|[^'])*)'\s*\)\s*;?$/i.exec(trimmed); | ||
| 482 | if (insertMatch) { | ||
| 483 | return { | ||
| 484 | result: { | ||
| 485 | rows: [], | ||
| 486 | columns: [], | ||
| 487 | rowCount: 0, | ||
| 488 | }, | ||
| 489 | writes: [{ | ||
| 490 | type: "upsert", | ||
| 491 | key: decodeSqlStringLiteral(insertMatch[1] ?? ""), | ||
| 492 | value: decodeSqlStringLiteral(insertMatch[2] ?? ""), | ||
| 493 | insertOnly: true, | ||
| 494 | }], | ||
| 495 | }; | ||
| 496 | } | ||
| 497 | |||
| 498 | const replaceMatch = /^replace\s+into\s+kv_store\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*'((?:''|[^'])*)'\s*,\s*'((?:''|[^'])*)'\s*\)\s*;?$/i.exec(trimmed); | ||
| 499 | if (replaceMatch) { | ||
| 500 | return { | ||
| 501 | result: { | ||
| 502 | rows: [], | ||
| 503 | columns: [], | ||
| 504 | rowCount: 0, | ||
| 505 | }, | ||
| 506 | writes: [{ | ||
| 507 | type: "upsert", | ||
| 508 | key: decodeSqlStringLiteral(replaceMatch[1] ?? ""), | ||
| 509 | value: decodeSqlStringLiteral(replaceMatch[2] ?? ""), | ||
| 510 | insertOnly: false, | ||
| 511 | }], | ||
| 512 | }; | ||
| 513 | } | ||
| 514 | |||
| 515 | const updateMatch = /^update\s+kv_store\s+set\s+value\s*=\s*'((?:''|[^'])*)'\s+where\s+key\s*=\s*'((?:''|[^'])*)'\s*;?$/i.exec(trimmed); | ||
| 516 | if (updateMatch) { | ||
| 517 | return { | ||
| 518 | result: { | ||
| 519 | rows: [], | ||
| 520 | columns: [], | ||
| 521 | rowCount: 0, | ||
| 522 | }, | ||
| 523 | writes: [{ | ||
| 524 | type: "upsert", | ||
| 525 | key: decodeSqlStringLiteral(updateMatch[2] ?? ""), | ||
| 526 | value: decodeSqlStringLiteral(updateMatch[1] ?? ""), | ||
| 527 | insertOnly: false, | ||
| 528 | }], | ||
| 529 | }; | ||
| 530 | } | ||
| 531 | |||
| 532 | const deleteMatch = /^delete\s+from\s+kv_store\s+where\s+key\s*=\s*'((?:''|[^'])*)'\s*;?$/i.exec(trimmed); | ||
| 533 | if (deleteMatch) { | ||
| 534 | return { | ||
| 535 | result: { | ||
| 536 | rows: [], | ||
| 537 | columns: [], | ||
| 538 | rowCount: 0, | ||
| 539 | }, | ||
| 540 | writes: [{ | ||
| 541 | type: "delete", | ||
| 542 | key: decodeSqlStringLiteral(deleteMatch[1] ?? ""), | ||
| 543 | }], | ||
| 544 | }; | ||
| 545 | } | ||
| 546 | |||
| 547 | throw new Error("Unsupported SQL statement for Convex storage inspector"); | ||
| 548 | }; | ||
| 549 | |||
| 550 | export const listStorageInstances = query({ | ||
| 551 | args: { | ||
| 552 | workspaceId: v.string(), | ||
| 553 | }, | ||
| 554 | handler: async (ctx, args): Promise<Array<StorageInstance>> => { | ||
| 555 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 556 | |||
| 557 | const workspaceRows = await ctx.db | ||
| 558 | .query("storageInstances") | ||
| 559 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 560 | .collect(); | ||
| 561 | |||
| 562 | const organizationRows = await ctx.db | ||
| 563 | .query("storageInstances") | ||
| 564 | .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | ||
| 565 | .collect(); | ||
| 566 | |||
| 567 | const storageInstances = [...workspaceRows, ...organizationRows] | ||
| 568 | .map((row) => | ||
| 569 | toStorageInstance(row as unknown as Record<string, unknown>), | ||
| 570 | ) | ||
| 571 | .filter((storageInstance) => | ||
| 572 | canAccessStorageInstance(storageInstance, { | ||
| 573 | workspaceId: args.workspaceId, | ||
| 574 | organizationId, | ||
| 575 | }) | ||
| 576 | ); | ||
| 577 | |||
| 578 | const uniqueStorageInstances = Array.from( | ||
| 579 | new Map(storageInstances.map((storageInstance) => [storageInstance.id, storageInstance])) | ||
| 580 | .values(), | ||
| 581 | ); | ||
| 582 | |||
| 583 | return sortStorageInstances(uniqueStorageInstances); | ||
| 584 | }, | ||
| 585 | }); | ||
| 586 | |||
| 587 | export const openStorageInstance = mutation({ | ||
| 588 | args: { | ||
| 589 | workspaceId: v.string(), | ||
| 590 | payload: v.object({ | ||
| 591 | scopeType: storageScopeTypeValidator, | ||
| 592 | durability: storageDurabilityValidator, | ||
| 593 | provider: v.optional(storageProviderValidator), | ||
| 594 | purpose: v.optional(v.string()), | ||
| 595 | ttlHours: v.optional(v.number()), | ||
| 596 | accountId: v.optional(v.string()), | ||
| 597 | sessionId: v.optional(v.string()), | ||
| 598 | }), | ||
| 599 | }, | ||
| 600 | handler: async (ctx, args): Promise<StorageInstance> => { | ||
| 601 | const payload = args.payload as OpenStorageInstancePayload; | ||
| 602 | |||
| 603 | if (payload.scopeType === "account" && payload.accountId === undefined) { | ||
| 604 | throw new Error("Account scope storage requires accountId"); | ||
| 605 | } | ||
| 606 | |||
| 607 | const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | ||
| 608 | const now = Date.now(); | ||
| 609 | const storageInstanceId = `storage_${crypto.randomUUID()}`; | ||
| 610 | const ttlHours = | ||
| 611 | payload.ttlHours !== undefined && Number.isFinite(payload.ttlHours) | ||
| 612 | ? Math.max(1, Math.floor(payload.ttlHours)) | ||
| 613 | : 24; | ||
| 614 | |||
| 615 | const nextStorageInstance = decodeStorageInstance({ | ||
| 616 | id: storageInstanceId, | ||
| 617 | scopeType: payload.scopeType, | ||
| 618 | durability: payload.durability, | ||
| 619 | status: "active", | ||
| 620 | provider: payload.provider ?? "agentfs-local", | ||
| 621 | backendKey: `convex:${storageInstanceId}`, | ||
| 622 | organizationId, | ||
| 623 | workspaceId: | ||
| 624 | payload.scopeType === "workspace" || payload.scopeType === "scratch" | ||
| 625 | ? args.workspaceId | ||
| 626 | : null, | ||
| 627 | accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null, | ||
| 628 | createdByAccountId: payload.accountId ?? null, | ||
| 629 | purpose: | ||
| 630 | payload.purpose !== undefined && payload.purpose.trim().length > 0 | ||
| 631 | ? payload.purpose.trim() | ||
| 632 | : null, | ||
| 633 | sizeBytes: null, | ||
| 634 | fileCount: null, | ||
| 635 | createdAt: now, | ||
| 636 | updatedAt: now, | ||
| 637 | lastSeenAt: now, | ||
| 638 | closedAt: null, | ||
| 639 | expiresAt: | ||
| 640 | payload.durability === "ephemeral" | ||
| 641 | ? now + ttlHours * 3_600_000 | ||
| 642 | : null, | ||
| 643 | }); | ||
| 644 | |||
| 645 | await ctx.db.insert("storageInstances", nextStorageInstance); | ||
| 646 | |||
| 647 | return nextStorageInstance; | ||
| 648 | }, | ||
| 649 | }); | ||
| 650 | |||
| 651 | export const closeStorageInstance = mutation({ | ||
| 652 | args: { | ||
| 653 | workspaceId: v.string(), | ||
| 654 | storageInstanceId: v.string(), | ||
| 655 | }, | ||
| 656 | handler: async (ctx, args): Promise<StorageInstance> => { | ||
| 657 | const existingStorageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 658 | workspaceId: args.workspaceId, | ||
| 659 | storageInstanceId: args.storageInstanceId, | ||
| 660 | }); | ||
| 661 | |||
| 662 | if (!existingStorageInstance) { | ||
| 663 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 664 | } | ||
| 665 | |||
| 666 | const existingRow = await ctx.db | ||
| 667 | .query("storageInstances") | ||
| 668 | .withIndex("by_domainId", (q) => q.eq("id", args.storageInstanceId)) | ||
| 669 | .unique(); | ||
| 670 | |||
| 671 | if (!existingRow) { | ||
| 672 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 673 | } | ||
| 674 | |||
| 675 | const now = Date.now(); | ||
| 676 | const nextStorageInstance = decodeStorageInstance({ | ||
| 677 | ...existingStorageInstance, | ||
| 678 | status: "closed", | ||
| 679 | updatedAt: now, | ||
| 680 | lastSeenAt: now, | ||
| 681 | closedAt: existingStorageInstance.closedAt ?? now, | ||
| 682 | }); | ||
| 683 | |||
| 684 | await ctx.db.patch(existingRow._id, nextStorageInstance); | ||
| 685 | |||
| 686 | return nextStorageInstance; | ||
| 687 | }, | ||
| 688 | }); | ||
| 689 | |||
| 690 | export const removeStorageInstance = mutation({ | ||
| 691 | args: { | ||
| 692 | workspaceId: v.string(), | ||
| 693 | storageInstanceId: v.string(), | ||
| 694 | }, | ||
| 695 | handler: async ( | ||
| 696 | ctx, | ||
| 697 | args, | ||
| 698 | ): Promise<{ | ||
| 699 | removed: boolean; | ||
| 700 | }> => { | ||
| 701 | const existingStorageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 702 | workspaceId: args.workspaceId, | ||
| 703 | storageInstanceId: args.storageInstanceId, | ||
| 704 | }); | ||
| 705 | |||
| 706 | if (!existingStorageInstance) { | ||
| 707 | return { removed: false }; | ||
| 708 | } | ||
| 709 | |||
| 710 | const storageInstanceRow = await ctx.db | ||
| 711 | .query("storageInstances") | ||
| 712 | .withIndex("by_domainId", (q) => q.eq("id", args.storageInstanceId)) | ||
| 713 | .unique(); | ||
| 714 | |||
| 715 | if (!storageInstanceRow) { | ||
| 716 | return { removed: false }; | ||
| 717 | } | ||
| 718 | |||
| 719 | const storageFileRows = await ctx.db | ||
| 720 | .query("storageFileEntries") | ||
| 721 | .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | ||
| 722 | .collect(); | ||
| 723 | |||
| 724 | for (const storageFileRow of storageFileRows) { | ||
| 725 | await ctx.db.delete(storageFileRow._id); | ||
| 726 | } | ||
| 727 | |||
| 728 | const storageKvRows = await ctx.db | ||
| 729 | .query("storageKvEntries") | ||
| 730 | .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | ||
| 731 | .collect(); | ||
| 732 | |||
| 733 | for (const storageKvRow of storageKvRows) { | ||
| 734 | await ctx.db.delete(storageKvRow._id); | ||
| 735 | } | ||
| 736 | |||
| 737 | const storageSqlKvRows = await ctx.db | ||
| 738 | .query("storageSqlKvEntries") | ||
| 739 | .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | ||
| 740 | .collect(); | ||
| 741 | |||
| 742 | for (const storageSqlKvRow of storageSqlKvRows) { | ||
| 743 | await ctx.db.delete(storageSqlKvRow._id); | ||
| 744 | } | ||
| 745 | |||
| 746 | await ctx.db.delete(storageInstanceRow._id); | ||
| 747 | |||
| 748 | return { removed: true }; | ||
| 749 | }, | ||
| 750 | }); | ||
| 751 | |||
| 752 | export const listStorageDirectory = query({ | ||
| 753 | args: { | ||
| 754 | workspaceId: v.string(), | ||
| 755 | storageInstanceId: v.string(), | ||
| 756 | payload: v.object({ | ||
| 757 | path: v.string(), | ||
| 758 | }), | ||
| 759 | }, | ||
| 760 | handler: async (ctx, args): Promise<ListStorageDirectoryResult> => { | ||
| 761 | const storageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 762 | workspaceId: args.workspaceId, | ||
| 763 | storageInstanceId: args.storageInstanceId, | ||
| 764 | }); | ||
| 765 | |||
| 766 | if (!storageInstance) { | ||
| 767 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 768 | } | ||
| 769 | |||
| 770 | const normalizedPath = normalizeStoragePath(args.payload.path); | ||
| 771 | |||
| 772 | const storageFileRows = await ctx.db | ||
| 773 | .query("storageFileEntries") | ||
| 774 | .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | ||
| 775 | .collect(); | ||
| 776 | |||
| 777 | const storageFiles = storageFileRows.map((row) => | ||
| 778 | toStorageFileEntry(row as unknown as Record<string, unknown>), | ||
| 779 | ); | ||
| 780 | |||
| 781 | const exactFile = storageFiles.find((storageFile) => storageFile.path === normalizedPath); | ||
| 782 | if (exactFile) { | ||
| 783 | throw new Error(`Cannot list a file path as directory: ${normalizedPath}`); | ||
| 784 | } | ||
| 785 | |||
| 786 | const prefix = normalizedPath === "/" ? "/" : `${normalizedPath}/`; | ||
| 787 | const directoryByPath = new Map<string, { | ||
| 788 | name: string; | ||
| 789 | path: string; | ||
| 790 | kind: "directory"; | ||
| 791 | sizeBytes: null; | ||
| 792 | updatedAt: number; | ||
| 793 | }>(); | ||
| 794 | const fileEntries: Array<{ | ||
| 795 | name: string; | ||
| 796 | path: string; | ||
| 797 | kind: "file"; | ||
| 798 | sizeBytes: number; | ||
| 799 | updatedAt: number; | ||
| 800 | }> = []; | ||
| 801 | |||
| 802 | for (const storageFile of storageFiles) { | ||
| 803 | if (!storageFile.path.startsWith(prefix)) { | ||
| 804 | continue; | ||
| 805 | } | ||
| 806 | |||
| 807 | const remainder = storageFile.path.slice(prefix.length); | ||
| 808 | if (remainder.length === 0) { | ||
| 809 | continue; | ||
| 810 | } | ||
| 811 | |||
| 812 | const separatorIndex = remainder.indexOf("/"); | ||
| 813 | if (separatorIndex < 0) { | ||
| 814 | const fileName = remainder; | ||
| 815 | fileEntries.push({ | ||
| 816 | name: fileName, | ||
| 817 | path: storageFile.path, | ||
| 818 | kind: "file", | ||
| 819 | sizeBytes: storageFile.sizeBytes, | ||
| 820 | updatedAt: storageFile.updatedAt, | ||
| 821 | }); | ||
| 822 | continue; | ||
| 823 | } | ||
| 824 | |||
| 825 | const directoryName = remainder.slice(0, separatorIndex); | ||
| 826 | const directoryPath = normalizedPath === "/" | ||
| 827 | ? `/${directoryName}` | ||
| 828 | : `${normalizedPath}/${directoryName}`; | ||
| 829 | const existingDirectory = directoryByPath.get(directoryPath); | ||
| 830 | |||
| 831 | if (!existingDirectory) { | ||
| 832 | directoryByPath.set(directoryPath, { | ||
| 833 | name: directoryName, | ||
| 834 | path: directoryPath, | ||
| 835 | kind: "directory", | ||
| 836 | sizeBytes: null, | ||
| 837 | updatedAt: storageFile.updatedAt, | ||
| 838 | }); | ||
| 839 | continue; | ||
| 840 | } | ||
| 841 | |||
| 842 | if (storageFile.updatedAt > existingDirectory.updatedAt) { | ||
| 843 | directoryByPath.set(directoryPath, { | ||
| 844 | ...existingDirectory, | ||
| 845 | updatedAt: storageFile.updatedAt, | ||
| 846 | }); | ||
| 847 | } | ||
| 848 | } | ||
| 849 | |||
| 850 | if ( | ||
| 851 | normalizedPath !== "/" | ||
| 852 | && directoryByPath.size === 0 | ||
| 853 | && fileEntries.length === 0 | ||
| 854 | ) { | ||
| 855 | throw new Error(`Directory not found: ${normalizedPath}`); | ||
| 856 | } | ||
| 857 | |||
| 858 | const directoryEntries = [...directoryByPath.values()].sort((left, right) => | ||
| 859 | left.name.localeCompare(right.name) | ||
| 860 | ); | ||
| 861 | const sortedFileEntries = [...fileEntries].sort((left, right) => | ||
| 862 | left.name.localeCompare(right.name) | ||
| 863 | ); | ||
| 864 | |||
| 865 | return { | ||
| 866 | path: normalizedPath, | ||
| 867 | entries: [...directoryEntries, ...sortedFileEntries], | ||
| 868 | }; | ||
| 869 | }, | ||
| 870 | }); | ||
| 871 | |||
| 872 | export const readStorageFile = query({ | ||
| 873 | args: { | ||
| 874 | workspaceId: v.string(), | ||
| 875 | storageInstanceId: v.string(), | ||
| 876 | payload: v.object({ | ||
| 877 | path: v.string(), | ||
| 878 | encoding: v.optional(readStorageEncodingValidator), | ||
| 879 | }), | ||
| 880 | }, | ||
| 881 | handler: async (ctx, args): Promise<ReadStorageFileResult> => { | ||
| 882 | const storageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 883 | workspaceId: args.workspaceId, | ||
| 884 | storageInstanceId: args.storageInstanceId, | ||
| 885 | }); | ||
| 886 | |||
| 887 | if (!storageInstance) { | ||
| 888 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 889 | } | ||
| 890 | |||
| 891 | const normalizedPath = normalizeStoragePath(args.payload.path); | ||
| 892 | const storageFileRow = await ctx.db | ||
| 893 | .query("storageFileEntries") | ||
| 894 | .withIndex("by_storageInstanceId_path", (q) => | ||
| 895 | q.eq("storageInstanceId", args.storageInstanceId).eq("path", normalizedPath) | ||
| 896 | ) | ||
| 897 | .unique(); | ||
| 898 | |||
| 899 | if (!storageFileRow) { | ||
| 900 | throw new Error(`Storage file not found: ${normalizedPath}`); | ||
| 901 | } | ||
| 902 | |||
| 903 | const storageFile = toStorageFileEntry(storageFileRow as unknown as Record<string, unknown>); | ||
| 904 | const encoding = args.payload.encoding ?? "utf8"; | ||
| 905 | |||
| 906 | return { | ||
| 907 | path: normalizedPath, | ||
| 908 | encoding, | ||
| 909 | content: encoding === "base64" | ||
| 910 | ? storageFile.contentBase64 | ||
| 911 | : decodeBase64Utf8(storageFile.contentBase64), | ||
| 912 | bytes: storageFile.sizeBytes, | ||
| 913 | }; | ||
| 914 | }, | ||
| 915 | }); | ||
| 916 | |||
| 917 | export const listStorageKv = query({ | ||
| 918 | args: { | ||
| 919 | workspaceId: v.string(), | ||
| 920 | storageInstanceId: v.string(), | ||
| 921 | payload: v.object({ | ||
| 922 | prefix: v.optional(v.string()), | ||
| 923 | limit: v.optional(v.number()), | ||
| 924 | }), | ||
| 925 | }, | ||
| 926 | handler: async (ctx, args): Promise<ListStorageKvResult> => { | ||
| 927 | const storageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 928 | workspaceId: args.workspaceId, | ||
| 929 | storageInstanceId: args.storageInstanceId, | ||
| 930 | }); | ||
| 931 | |||
| 932 | if (!storageInstance) { | ||
| 933 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 934 | } | ||
| 935 | |||
| 936 | const prefix = args.payload.prefix ?? ""; | ||
| 937 | const requestedLimit = | ||
| 938 | args.payload.limit !== undefined && Number.isFinite(args.payload.limit) | ||
| 939 | ? Math.floor(args.payload.limit) | ||
| 940 | : DEFAULT_KV_LIMIT; | ||
| 941 | const limit = Math.max(1, Math.min(MAX_KV_LIMIT, requestedLimit)); | ||
| 942 | |||
| 943 | const storageKvRows = await ctx.db | ||
| 944 | .query("storageKvEntries") | ||
| 945 | .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | ||
| 946 | .collect(); | ||
| 947 | |||
| 948 | const items = storageKvRows | ||
| 949 | .map((row) => toStorageKvEntry(row as unknown as Record<string, unknown>)) | ||
| 950 | .filter((entry) => entry.key.startsWith(prefix)) | ||
| 951 | .sort((left, right) => left.key.localeCompare(right.key)) | ||
| 952 | .slice(0, limit) | ||
| 953 | .map((entry) => ({ | ||
| 954 | key: entry.key, | ||
| 955 | value: parseJsonOrString(entry.valueJson), | ||
| 956 | })); | ||
| 957 | |||
| 958 | return { | ||
| 959 | items, | ||
| 960 | }; | ||
| 961 | }, | ||
| 962 | }); | ||
| 963 | |||
| 964 | export const queryStorageSql = mutation({ | ||
| 965 | args: { | ||
| 966 | workspaceId: v.string(), | ||
| 967 | storageInstanceId: v.string(), | ||
| 968 | payload: v.object({ | ||
| 969 | sql: v.string(), | ||
| 970 | maxRows: v.optional(v.number()), | ||
| 971 | }), | ||
| 972 | }, | ||
| 973 | handler: async (ctx, args): Promise<QueryStorageSqlResult> => { | ||
| 974 | const storageInstance = await resolveAccessibleStorageInstance(ctx, { | ||
| 975 | workspaceId: args.workspaceId, | ||
| 976 | storageInstanceId: args.storageInstanceId, | ||
| 977 | }); | ||
| 978 | |||
| 979 | if (!storageInstance) { | ||
| 980 | throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | ||
| 981 | } | ||
| 982 | |||
| 983 | const sqlText = args.payload.sql.trim(); | ||
| 984 | if (sqlText.length === 0) { | ||
| 985 | throw new Error("SQL query is required"); | ||
| 986 | } | ||
| 987 | |||
| 988 | const requestedMaxRows = | ||
| 989 | args.payload.maxRows !== undefined && Number.isFinite(args.payload.maxRows) | ||
| 990 | ? Math.floor(args.payload.maxRows) | ||
| 991 | : DEFAULT_SQL_MAX_ROWS; | ||
| 992 | const maxRows = Math.max(1, Math.min(MAX_SQL_MAX_ROWS, requestedMaxRows)); | ||
| 993 | |||
| 994 | const sqlRowsRaw = await ctx.db | ||
| 995 | .query("storageSqlKvEntries") | ||
| 996 | .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | ||
| 997 | .collect(); | ||
| 998 | |||
| 999 | const sqlRows = sqlRowsRaw.map((row) => | ||
| 1000 | toStorageSqlKvEntry(row as unknown as Record<string, unknown>), | ||
| 1001 | ); | ||
| 1002 | const byKey = new Map<string, { | ||
| 1003 | row: StorageSqlKvEntry; | ||
| 1004 | docId: unknown; | ||
| 1005 | }>(); | ||
| 1006 | |||
| 1007 | for (let index = 0; index < sqlRows.length; index += 1) { | ||
| 1008 | const row = sqlRows[index]; | ||
| 1009 | const rawRow = sqlRowsRaw[index]; | ||
| 1010 | if (row && rawRow) { | ||
| 1011 | byKey.set(row.key, { | ||
| 1012 | row, | ||
| 1013 | docId: rawRow._id, | ||
| 1014 | }); | ||
| 1015 | } | ||
| 1016 | } | ||
| 1017 | |||
| 1018 | const execution = executeSqlForStorage(sqlText, sqlRows, maxRows); | ||
| 1019 | |||
| 1020 | for (const write of execution.writes) { | ||
| 1021 | if (write.type === "delete") { | ||
| 1022 | const existing = byKey.get(write.key); | ||
| 1023 | if (existing) { | ||
| 1024 | await ctx.db.delete(existing.docId as any); | ||
| 1025 | byKey.delete(write.key); | ||
| 1026 | } | ||
| 1027 | continue; | ||
| 1028 | } | ||
| 1029 | |||
| 1030 | const existing = byKey.get(write.key); | ||
| 1031 | if (existing && write.insertOnly) { | ||
| 1032 | throw new Error(`UNIQUE constraint failed: kv_store.key (${write.key})`); | ||
| 1033 | } | ||
| 1034 | |||
| 1035 | const now = Date.now(); | ||
| 1036 | |||
| 1037 | if (existing) { | ||
| 1038 | const nextRow = decodeStorageSqlKvEntry({ | ||
| 1039 | ...existing.row, | ||
| 1040 | value: write.value, | ||
| 1041 | updatedAt: now, | ||
| 1042 | }); | ||
| 1043 | |||
| 1044 | await ctx.db.patch(existing.docId as any, nextRow); | ||
| 1045 | byKey.set(write.key, { | ||
| 1046 | row: nextRow, | ||
| 1047 | docId: existing.docId, | ||
| 1048 | }); | ||
| 1049 | continue; | ||
| 1050 | } | ||
| 1051 | |||
| 1052 | const nextRow = decodeStorageSqlKvEntry({ | ||
| 1053 | id: `storage_sql_kv_${crypto.randomUUID()}`, | ||
| 1054 | storageInstanceId: args.storageInstanceId, | ||
| 1055 | key: write.key, | ||
| 1056 | value: write.value, | ||
| 1057 | updatedAt: now, | ||
| 1058 | }); | ||
| 1059 | |||
| 1060 | const insertedId = await ctx.db.insert("storageSqlKvEntries", nextRow); | ||
| 1061 | byKey.set(write.key, { | ||
| 1062 | row: nextRow, | ||
| 1063 | docId: insertedId, | ||
| 1064 | }); | ||
| 1065 | } | ||
| 1066 | |||
| 1067 | return execution.result; | ||
| 1068 | }, | ||
| 1069 | }); | ||
| @@ -0,0 +1,168 @@ | |||
| 1 | import { type SourceToolSummary } from "@executor-v2/management-api"; | ||
| 2 | import { | ||
| 3 | SourceSchema, | ||
| 4 | type OpenApiHttpMethod, | ||
| 5 | type Source, | ||
| 6 | } from "@executor-v2/schema"; | ||
| 7 | import { v } from "convex/values"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | |||
| 10 | import { query } from "../_generated/server"; | ||
| 11 | |||
| 12 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); | ||
| 13 | |||
| 14 | const OpenApiArtifactToolRowSchema = Schema.Struct({ | ||
| 15 | id: Schema.String, | ||
| 16 | artifactId: Schema.String, | ||
| 17 | toolId: Schema.String, | ||
| 18 | name: Schema.String, | ||
| 19 | description: Schema.NullOr(Schema.String), | ||
| 20 | method: Schema.String, | ||
| 21 | path: Schema.String, | ||
| 22 | operationHash: Schema.String, | ||
| 23 | invocationJson: Schema.String, | ||
| 24 | createdAt: Schema.Number, | ||
| 25 | updatedAt: Schema.Number, | ||
| 26 | }); | ||
| 27 | |||
| 28 | type OpenApiArtifactToolRow = typeof OpenApiArtifactToolRowSchema.Type; | ||
| 29 | |||
| 30 | const decodeOpenApiArtifactToolRow = Schema.decodeUnknownSync(OpenApiArtifactToolRowSchema); | ||
| 31 | |||
| 32 | const stripConvexSystemFields = ( | ||
| 33 | value: Record<string, unknown>, | ||
| 34 | ): Record<string, unknown> => { | ||
| 35 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 36 | return rest; | ||
| 37 | }; | ||
| 38 | |||
| 39 | const toSource = (document: Record<string, unknown>): Source => | ||
| 40 | decodeSource(stripConvexSystemFields(document)); | ||
| 41 | |||
| 42 | const toOpenApiArtifactToolRow = ( | ||
| 43 | document: Record<string, unknown>, | ||
| 44 | ): OpenApiArtifactToolRow => | ||
| 45 | decodeOpenApiArtifactToolRow(stripConvexSystemFields(document)); | ||
| 46 | |||
| 47 | const toSourceToolSummary = ( | ||
| 48 | source: Source, | ||
| 49 | tool: OpenApiArtifactToolRow, | ||
| 50 | ): SourceToolSummary => ({ | ||
| 51 | sourceId: source.id, | ||
| 52 | sourceName: source.name, | ||
| 53 | sourceKind: source.kind, | ||
| 54 | toolId: tool.toolId, | ||
| 55 | name: tool.name, | ||
| 56 | description: tool.description, | ||
| 57 | method: tool.method as OpenApiHttpMethod, | ||
| 58 | path: tool.path, | ||
| 59 | operationHash: tool.operationHash, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const sortTools = (tools: ReadonlyArray<SourceToolSummary>): Array<SourceToolSummary> => | ||
| 63 | [...tools].sort((left, right) => { | ||
| 64 | const leftSource = left.sourceName.toLowerCase(); | ||
| 65 | const rightSource = right.sourceName.toLowerCase(); | ||
| 66 | |||
| 67 | if (leftSource !== rightSource) { | ||
| 68 | return leftSource.localeCompare(rightSource); | ||
| 69 | } | ||
| 70 | |||
| 71 | const leftName = left.name.toLowerCase(); | ||
| 72 | const rightName = right.name.toLowerCase(); | ||
| 73 | |||
| 74 | if (leftName !== rightName) { | ||
| 75 | return leftName.localeCompare(rightName); | ||
| 76 | } | ||
| 77 | |||
| 78 | return left.toolId.localeCompare(right.toolId); | ||
| 79 | }); | ||
| 80 | |||
| 81 | export const listWorkspaceTools = query({ | ||
| 82 | args: { | ||
| 83 | workspaceId: v.string(), | ||
| 84 | }, | ||
| 85 | handler: async (ctx, args): Promise<Array<SourceToolSummary>> => { | ||
| 86 | const sourceRows = await ctx.db | ||
| 87 | .query("sources") | ||
| 88 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 89 | .collect(); | ||
| 90 | |||
| 91 | const sourcesById = new Map<string, Source>( | ||
| 92 | sourceRows.map((row) => { | ||
| 93 | const source = toSource(row as unknown as Record<string, unknown>); | ||
| 94 | return [source.id as string, source] as const; | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | const bindingRows = await ctx.db | ||
| 99 | .query("openApiSourceArtifactBindings") | ||
| 100 | .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 101 | .collect(); | ||
| 102 | |||
| 103 | const tools: Array<SourceToolSummary> = []; | ||
| 104 | |||
| 105 | for (const binding of bindingRows) { | ||
| 106 | const source = sourcesById.get(binding.sourceId); | ||
| 107 | if (!source) { | ||
| 108 | continue; | ||
| 109 | } | ||
| 110 | |||
| 111 | const toolRows = await ctx.db | ||
| 112 | .query("openApiArtifactTools") | ||
| 113 | .withIndex("by_artifactId", (q) => q.eq("artifactId", binding.artifactId)) | ||
| 114 | .collect(); | ||
| 115 | |||
| 116 | for (const toolRow of toolRows) { | ||
| 117 | const tool = toOpenApiArtifactToolRow(toolRow as unknown as Record<string, unknown>); | ||
| 118 | tools.push(toSourceToolSummary(source, tool)); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | return sortTools(tools); | ||
| 123 | }, | ||
| 124 | }); | ||
| 125 | |||
| 126 | export const listSourceTools = query({ | ||
| 127 | args: { | ||
| 128 | workspaceId: v.string(), | ||
| 129 | sourceId: v.string(), | ||
| 130 | }, | ||
| 131 | handler: async (ctx, args): Promise<Array<SourceToolSummary>> => { | ||
| 132 | const sourceRow = await ctx.db | ||
| 133 | .query("sources") | ||
| 134 | .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | ||
| 135 | .unique(); | ||
| 136 | |||
| 137 | if (!sourceRow || sourceRow.workspaceId !== args.workspaceId) { | ||
| 138 | return []; | ||
| 139 | } | ||
| 140 | |||
| 141 | const source = toSource(sourceRow as unknown as Record<string, unknown>); | ||
| 142 | |||
| 143 | const bindingRow = await ctx.db | ||
| 144 | .query("openApiSourceArtifactBindings") | ||
| 145 | .withIndex("by_workspaceId_sourceId", (q) => | ||
| 146 | q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId) | ||
| 147 | ) | ||
| 148 | .unique(); | ||
| 149 | |||
| 150 | if (!bindingRow) { | ||
| 151 | return []; | ||
| 152 | } | ||
| 153 | |||
| 154 | const toolRows = await ctx.db | ||
| 155 | .query("openApiArtifactTools") | ||
| 156 | .withIndex("by_artifactId", (q) => q.eq("artifactId", bindingRow.artifactId)) | ||
| 157 | .collect(); | ||
| 158 | |||
| 159 | return sortTools( | ||
| 160 | toolRows.map((toolRow) => | ||
| 161 | toSourceToolSummary( | ||
| 162 | source, | ||
| 163 | toOpenApiArtifactToolRow(toolRow as unknown as Record<string, unknown>), | ||
| 164 | ), | ||
| 165 | ), | ||
| 166 | ); | ||
| 167 | }, | ||
| 168 | }); | ||
| @@ -0,0 +1,81 @@ | |||
| 1 | import { type UpsertWorkspacePayload } from "@executor-v2/management-api"; | ||
| 2 | import { WorkspaceSchema, type Workspace } from "@executor-v2/schema"; | ||
| 3 | import { v } from "convex/values"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import { mutation, query } from "../_generated/server"; | ||
| 7 | |||
| 8 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); | ||
| 9 | |||
| 10 | const stripConvexSystemFields = ( | ||
| 11 | value: Record<string, unknown>, | ||
| 12 | ): Record<string, unknown> => { | ||
| 13 | const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | ||
| 14 | return rest; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const toWorkspace = (document: Record<string, unknown>): Workspace => | ||
| 18 | decodeWorkspace(stripConvexSystemFields(document)); | ||
| 19 | |||
| 20 | const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> => | ||
| 21 | [...workspaces].sort((left, right) => { | ||
| 22 | const leftName = left.name.toLowerCase(); | ||
| 23 | const rightName = right.name.toLowerCase(); | ||
| 24 | |||
| 25 | if (leftName === rightName) { | ||
| 26 | return left.id.localeCompare(right.id); | ||
| 27 | } | ||
| 28 | |||
| 29 | return leftName.localeCompare(rightName); | ||
| 30 | }); | ||
| 31 | |||
| 32 | export const listWorkspaces = query({ | ||
| 33 | args: {}, | ||
| 34 | handler: async (ctx): Promise<Array<Workspace>> => { | ||
| 35 | const rows = await ctx.db.query("workspaces").collect(); | ||
| 36 | |||
| 37 | return sortWorkspaces( | ||
| 38 | rows.map((row) => toWorkspace(row as unknown as Record<string, unknown>)), | ||
| 39 | ); | ||
| 40 | }, | ||
| 41 | }); | ||
| 42 | |||
| 43 | export const upsertWorkspace = mutation({ | ||
| 44 | args: { | ||
| 45 | payload: v.object({ | ||
| 46 | id: v.optional(v.string()), | ||
| 47 | organizationId: v.optional(v.union(v.string(), v.null())), | ||
| 48 | name: v.string(), | ||
| 49 | }), | ||
| 50 | }, | ||
| 51 | handler: async (ctx, args): Promise<Workspace> => { | ||
| 52 | const payload = args.payload as UpsertWorkspacePayload; | ||
| 53 | const now = Date.now(); | ||
| 54 | const workspaceId = payload.id ?? `ws_${crypto.randomUUID()}`; | ||
| 55 | |||
| 56 | const existing = await ctx.db | ||
| 57 | .query("workspaces") | ||
| 58 | .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | ||
| 59 | .unique(); | ||
| 60 | |||
| 61 | const nextWorkspace = decodeWorkspace({ | ||
| 62 | id: workspaceId, | ||
| 63 | organizationId: | ||
| 64 | payload.organizationId !== undefined | ||
| 65 | ? payload.organizationId | ||
| 66 | : existing?.organizationId ?? null, | ||
| 67 | name: payload.name, | ||
| 68 | createdByAccountId: existing?.createdByAccountId ?? null, | ||
| 69 | createdAt: existing?.createdAt ?? now, | ||
| 70 | updatedAt: now, | ||
| 71 | }); | ||
| 72 | |||
| 73 | if (existing) { | ||
| 74 | await ctx.db.patch(existing._id, nextWorkspace); | ||
| 75 | } else { | ||
| 76 | await ctx.db.insert("workspaces", nextWorkspace); | ||
| 77 | } | ||
| 78 | |||
| 79 | return nextWorkspace; | ||
| 80 | }, | ||
| 81 | }); | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { extractOpenApiManifest } from "@executor-v2/management-api"; | ||
| 2 | import { convexTest } from "convex-test"; | 3 | import { convexTest } from "convex-test"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | ||
| @@ -16,6 +17,10 @@ const setup = () => · 11 unmodified lines | |||
| 16 | "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), | 17 | "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), |
| 17 | "./source_tool_registry.ts": () => import("./source_tool_registry"), | 18 | "./source_tool_registry.ts": () => import("./source_tool_registry"), |
| 18 | "./task_runs.ts": () => import("./task_runs"), | 19 | "./task_runs.ts": () => import("./task_runs"), |
| 20 | "./control_plane/storage.ts": () => import("./control_plane/storage"), | ||
| 21 | "./control_plane/sources.ts": () => import("./control_plane/sources"), | ||
| 22 | "./control_plane/openapi_ingest_mvp.ts": () => import("./control_plane/openapi_ingest_mvp"), | ||
| 23 | "./control_plane/openapi_ingest.ts": () => import("./control_plane/openapi_ingest"), | ||
| 19 | "./controlPlane.ts": () => import("./controlPlane"), | 24 | "./controlPlane.ts": () => import("./controlPlane"), |
| 20 | "./_generated/api.js": () => import("./_generated/api.js"), | 25 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 21 | }); | 26 | }); |
| @@ -57,6 +62,8 @@ describe("Convex executor and control-plane", () => { · 35 unmodified lines | |||
| 57 | name: string; | 62 | name: string; |
| 58 | }; | 63 | }; |
| 59 | 64 | ||
| 65 | yield* Effect.tryPromise(() => t.finishInProgressScheduledFunctions()); | ||
| 66 | |||
| 60 | expect(added.id).toBe("src_1"); | 67 | expect(added.id).toBe("src_1"); |
| 61 | expect(added.workspaceId).toBe("ws_1"); | 68 | expect(added.workspaceId).toBe("ws_1"); |
| 62 | expect(added.name).toBe("Weather"); | 69 | expect(added.name).toBe("Weather"); |
| @@ -93,6 +100,455 @@ describe("Convex executor and control-plane", () => { · 30 unmodified lines | |||
| 93 | }), | 100 | }), |
| 94 | ); | 101 | ); |
| 95 | 102 | ||
| 103 | it.effect("upserts, lists, and removes credentials, policies, and storage", () => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const t = setup(); | ||
| 106 | |||
| 107 | const addedCredential = (yield* Effect.tryPromise(() => | ||
| 108 | t.mutation(api.controlPlane.upsertCredentialBinding, { | ||
| 109 | workspaceId: "ws_1", | ||
| 110 | payload: { | ||
| 111 | id: "credential_binding_1", | ||
| 112 | credentialId: "cred_1", | ||
| 113 | scopeType: "workspace", | ||
| 114 | sourceKey: "github", | ||
| 115 | provider: "bearer", | ||
| 116 | secretRef: "secret://github/token", | ||
| 117 | accountId: null, | ||
| 118 | additionalHeadersJson: null, | ||
| 119 | boundAuthFingerprint: null, | ||
| 120 | }, | ||
| 121 | }), | ||
| 122 | )) as { | ||
| 123 | id: string; | ||
| 124 | workspaceId: string | null; | ||
| 125 | }; | ||
| 126 | |||
| 127 | expect(addedCredential.id).toBe("credential_binding_1"); | ||
| 128 | expect(addedCredential.workspaceId).toBe("ws_1"); | ||
| 129 | |||
| 130 | const listedCredentials = (yield* Effect.tryPromise(() => | ||
| 131 | t.query(api.controlPlane.listCredentialBindings, { | ||
| 132 | workspaceId: "ws_1", | ||
| 133 | }), | ||
| 134 | )) as Array<{ | ||
| 135 | id: string; | ||
| 136 | }>; | ||
| 137 | |||
| 138 | expect(listedCredentials).toHaveLength(1); | ||
| 139 | expect(listedCredentials[0]?.id).toBe("credential_binding_1"); | ||
| 140 | |||
| 141 | const removedCredential = (yield* Effect.tryPromise(() => | ||
| 142 | t.mutation(api.controlPlane.removeCredentialBinding, { | ||
| 143 | workspaceId: "ws_1", | ||
| 144 | credentialBindingId: "credential_binding_1", | ||
| 145 | }), | ||
| 146 | )) as { | ||
| 147 | removed: boolean; | ||
| 148 | }; | ||
| 149 | |||
| 150 | expect(removedCredential.removed).toBe(true); | ||
| 151 | |||
| 152 | const listedCredentialsAfterRemove = (yield* Effect.tryPromise(() => | ||
| 153 | t.query(api.controlPlane.listCredentialBindings, { | ||
| 154 | workspaceId: "ws_1", | ||
| 155 | }), | ||
| 156 | )) as Array<unknown>; | ||
| 157 | |||
| 158 | expect(listedCredentialsAfterRemove).toHaveLength(0); | ||
| 159 | |||
| 160 | const addedPolicy = (yield* Effect.tryPromise(() => | ||
| 161 | t.mutation(api.controlPlane.upsertPolicy, { | ||
| 162 | workspaceId: "ws_1", | ||
| 163 | payload: { | ||
| 164 | id: "pol_1", | ||
| 165 | toolPathPattern: "github.repos.*", | ||
| 166 | decision: "require_approval", | ||
| 167 | }, | ||
| 168 | }), | ||
| 169 | )) as { | ||
| 170 | id: string; | ||
| 171 | workspaceId: string; | ||
| 172 | }; | ||
| 173 | |||
| 174 | expect(addedPolicy.id).toBe("pol_1"); | ||
| 175 | expect(addedPolicy.workspaceId).toBe("ws_1"); | ||
| 176 | |||
| 177 | const listedPolicies = (yield* Effect.tryPromise(() => | ||
| 178 | t.query(api.controlPlane.listPolicies, { | ||
| 179 | workspaceId: "ws_1", | ||
| 180 | }), | ||
| 181 | )) as Array<{ | ||
| 182 | id: string; | ||
| 183 | }>; | ||
| 184 | |||
| 185 | expect(listedPolicies).toHaveLength(1); | ||
| 186 | expect(listedPolicies[0]?.id).toBe("pol_1"); | ||
| 187 | |||
| 188 | const removedPolicy = (yield* Effect.tryPromise(() => | ||
| 189 | t.mutation(api.controlPlane.removePolicy, { | ||
| 190 | workspaceId: "ws_1", | ||
| 191 | policyId: "pol_1", | ||
| 192 | }), | ||
| 193 | )) as { | ||
| 194 | removed: boolean; | ||
| 195 | }; | ||
| 196 | |||
| 197 | expect(removedPolicy.removed).toBe(true); | ||
| 198 | |||
| 199 | const listedPoliciesAfterRemove = (yield* Effect.tryPromise(() => | ||
| 200 | t.query(api.controlPlane.listPolicies, { | ||
| 201 | workspaceId: "ws_1", | ||
| 202 | }), | ||
| 203 | )) as Array<unknown>; | ||
| 204 | |||
| 205 | expect(listedPoliciesAfterRemove).toHaveLength(0); | ||
| 206 | |||
| 207 | const openedStorage = (yield* Effect.tryPromise(() => | ||
| 208 | t.mutation(api.controlPlane.openStorageInstance, { | ||
| 209 | workspaceId: "ws_1", | ||
| 210 | payload: { | ||
| 211 | scopeType: "workspace", | ||
| 212 | durability: "ephemeral", | ||
| 213 | provider: "agentfs-local", | ||
| 214 | purpose: "test storage", | ||
| 215 | ttlHours: 1, | ||
| 216 | sessionId: "session_1", | ||
| 217 | }, | ||
| 218 | }), | ||
| 219 | )) as { | ||
| 220 | id: string; | ||
| 221 | status: string; | ||
| 222 | }; | ||
| 223 | |||
| 224 | expect(openedStorage.status).toBe("active"); | ||
| 225 | |||
| 226 | const listedStorage = (yield* Effect.tryPromise(() => | ||
| 227 | t.query(api.controlPlane.listStorageInstances, { | ||
| 228 | workspaceId: "ws_1", | ||
| 229 | }), | ||
| 230 | )) as Array<{ | ||
| 231 | id: string; | ||
| 232 | }>; | ||
| 233 | |||
| 234 | expect(listedStorage).toHaveLength(1); | ||
| 235 | expect(listedStorage[0]?.id).toBe(openedStorage.id); | ||
| 236 | |||
| 237 | yield* Effect.tryPromise(() => | ||
| 238 | t.mutation(runtimeInternal.control_plane.storage.upsertStorageFileEntry, { | ||
| 239 | workspaceId: "ws_1", | ||
| 240 | storageInstanceId: openedStorage.id, | ||
| 241 | payload: { | ||
| 242 | path: "/notes/today.txt", | ||
| 243 | content: "hello convex storage", | ||
| 244 | }, | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | |||
| 248 | const listedDirectory = (yield* Effect.tryPromise(() => | ||
| 249 | t.query(api.controlPlane.listStorageDirectory, { | ||
| 250 | workspaceId: "ws_1", | ||
| 251 | storageInstanceId: openedStorage.id, | ||
| 252 | payload: { | ||
| 253 | path: "/", | ||
| 254 | }, | ||
| 255 | }), | ||
| 256 | )) as { | ||
| 257 | path: string; | ||
| 258 | entries: ReadonlyArray<{ | ||
| 259 | name: string; | ||
| 260 | path: string; | ||
| 261 | kind: "file" | "directory"; | ||
| 262 | }>; | ||
| 263 | }; | ||
| 264 | |||
| 265 | expect(listedDirectory.path).toBe("/"); | ||
| 266 | expect(listedDirectory.entries).toHaveLength(1); | ||
| 267 | expect(listedDirectory.entries[0]?.name).toBe("notes"); | ||
| 268 | expect(listedDirectory.entries[0]?.kind).toBe("directory"); | ||
| 269 | |||
| 270 | const listedNestedDirectory = (yield* Effect.tryPromise(() => | ||
| 271 | t.query(api.controlPlane.listStorageDirectory, { | ||
| 272 | workspaceId: "ws_1", | ||
| 273 | storageInstanceId: openedStorage.id, | ||
| 274 | payload: { | ||
| 275 | path: "/notes", | ||
| 276 | }, | ||
| 277 | }), | ||
| 278 | )) as { | ||
| 279 | path: string; | ||
| 280 | entries: ReadonlyArray<{ | ||
| 281 | name: string; | ||
| 282 | path: string; | ||
| 283 | kind: "file" | "directory"; | ||
| 284 | }>; | ||
| 285 | }; | ||
| 286 | |||
| 287 | expect(listedNestedDirectory.path).toBe("/notes"); | ||
| 288 | expect(listedNestedDirectory.entries).toHaveLength(1); | ||
| 289 | expect(listedNestedDirectory.entries[0]?.name).toBe("today.txt"); | ||
| 290 | expect(listedNestedDirectory.entries[0]?.kind).toBe("file"); | ||
| 291 | |||
| 292 | const readStorageFile = (yield* Effect.tryPromise(() => | ||
| 293 | t.query(api.controlPlane.readStorageFile, { | ||
| 294 | workspaceId: "ws_1", | ||
| 295 | storageInstanceId: openedStorage.id, | ||
| 296 | payload: { | ||
| 297 | path: "/notes/today.txt", | ||
| 298 | encoding: "utf8", | ||
| 299 | }, | ||
| 300 | }), | ||
| 301 | )) as { | ||
| 302 | content: string; | ||
| 303 | }; | ||
| 304 | |||
| 305 | expect(readStorageFile.content).toBe("hello convex storage"); | ||
| 306 | |||
| 307 | yield* Effect.tryPromise(() => | ||
| 308 | t.mutation(runtimeInternal.control_plane.storage.upsertStorageKvEntry, { | ||
| 309 | workspaceId: "ws_1", | ||
| 310 | storageInstanceId: openedStorage.id, | ||
| 311 | payload: { | ||
| 312 | key: "feature.enabled", | ||
| 313 | valueJson: "true", | ||
| 314 | }, | ||
| 315 | }), | ||
| 316 | ); | ||
| 317 | |||
| 318 | const listedKv = (yield* Effect.tryPromise(() => | ||
| 319 | t.query(api.controlPlane.listStorageKv, { | ||
| 320 | workspaceId: "ws_1", | ||
| 321 | storageInstanceId: openedStorage.id, | ||
| 322 | payload: { | ||
| 323 | prefix: "feature.", | ||
| 324 | }, | ||
| 325 | }), | ||
| 326 | )) as { | ||
| 327 | items: Array<{ | ||
| 328 | key: string; | ||
| 329 | value: unknown; | ||
| 330 | }>; | ||
| 331 | }; | ||
| 332 | |||
| 333 | expect(listedKv.items).toHaveLength(1); | ||
| 334 | expect(listedKv.items[0]?.key).toBe("feature.enabled"); | ||
| 335 | expect(listedKv.items[0]?.value).toBe(true); | ||
| 336 | |||
| 337 | yield* Effect.tryPromise(() => | ||
| 338 | t.mutation(api.controlPlane.queryStorageSql, { | ||
| 339 | workspaceId: "ws_1", | ||
| 340 | storageInstanceId: openedStorage.id, | ||
| 341 | payload: { | ||
| 342 | sql: "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);", | ||
| 343 | }, | ||
| 344 | }), | ||
| 345 | ); | ||
| 346 | |||
| 347 | yield* Effect.tryPromise(() => | ||
| 348 | t.mutation(api.controlPlane.queryStorageSql, { | ||
| 349 | workspaceId: "ws_1", | ||
| 350 | storageInstanceId: openedStorage.id, | ||
| 351 | payload: { | ||
| 352 | sql: "INSERT INTO kv_store (key, value) VALUES ('hello', 'world');", | ||
| 353 | }, | ||
| 354 | }), | ||
| 355 | ); | ||
| 356 | |||
| 357 | const queriedSql = (yield* Effect.tryPromise(() => | ||
| 358 | t.mutation(api.controlPlane.queryStorageSql, { | ||
| 359 | workspaceId: "ws_1", | ||
| 360 | storageInstanceId: openedStorage.id, | ||
| 361 | payload: { | ||
| 362 | sql: "SELECT key, value FROM kv_store LIMIT 10;", | ||
| 363 | }, | ||
| 364 | }), | ||
| 365 | )) as { | ||
| 366 | rowCount: number; | ||
| 367 | columns: Array<string>; | ||
| 368 | rows: Array<Record<string, unknown>>; | ||
| 369 | }; | ||
| 370 | |||
| 371 | expect(queriedSql.rowCount).toBe(1); | ||
| 372 | expect(queriedSql.columns).toEqual(["key", "value"]); | ||
| 373 | expect(queriedSql.rows[0]).toEqual({ key: "hello", value: "world" }); | ||
| 374 | |||
| 375 | const closedStorage = (yield* Effect.tryPromise(() => | ||
| 376 | t.mutation(api.controlPlane.closeStorageInstance, { | ||
| 377 | workspaceId: "ws_1", | ||
| 378 | storageInstanceId: openedStorage.id, | ||
| 379 | }), | ||
| 380 | )) as { | ||
| 381 | id: string; | ||
| 382 | status: string; | ||
| 383 | }; | ||
| 384 | |||
| 385 | expect(closedStorage.id).toBe(openedStorage.id); | ||
| 386 | expect(closedStorage.status).toBe("closed"); | ||
| 387 | |||
| 388 | const removedStorage = (yield* Effect.tryPromise(() => | ||
| 389 | t.mutation(api.controlPlane.removeStorageInstance, { | ||
| 390 | workspaceId: "ws_1", | ||
| 391 | storageInstanceId: openedStorage.id, | ||
| 392 | }), | ||
| 393 | )) as { | ||
| 394 | removed: boolean; | ||
| 395 | }; | ||
| 396 | |||
| 397 | expect(removedStorage.removed).toBe(true); | ||
| 398 | |||
| 399 | const listedStorageAfterRemove = (yield* Effect.tryPromise(() => | ||
| 400 | t.query(api.controlPlane.listStorageInstances, { | ||
| 401 | workspaceId: "ws_1", | ||
| 402 | }), | ||
| 403 | )) as Array<unknown>; | ||
| 404 | |||
| 405 | expect(listedStorageAfterRemove).toHaveLength(0); | ||
| 406 | }), | ||
| 407 | ); | ||
| 408 | |||
| 409 | it.effect("upserts and lists organizations/workspaces and tool views", () => | ||
| 410 | Effect.gen(function* () { | ||
| 411 | const t = setup(); | ||
| 412 | |||
| 413 | const organization = (yield* Effect.tryPromise(() => | ||
| 414 | t.mutation(api.controlPlane.upsertOrganization, { | ||
| 415 | payload: { | ||
| 416 | id: "org_1", | ||
| 417 | slug: "acme", | ||
| 418 | name: "Acme Inc", | ||
| 419 | status: "active", | ||
| 420 | }, | ||
| 421 | }), | ||
| 422 | )) as { | ||
| 423 | id: string; | ||
| 424 | name: string; | ||
| 425 | }; | ||
| 426 | |||
| 427 | expect(organization.id).toBe("org_1"); | ||
| 428 | expect(organization.name).toBe("Acme Inc"); | ||
| 429 | |||
| 430 | const organizations = (yield* Effect.tryPromise(() => | ||
| 431 | t.query(api.controlPlane.listOrganizations, {}), | ||
| 432 | )) as Array<{ | ||
| 433 | id: string; | ||
| 434 | }>; | ||
| 435 | |||
| 436 | expect(organizations).toHaveLength(1); | ||
| 437 | expect(organizations[0]?.id).toBe("org_1"); | ||
| 438 | |||
| 439 | const workspace = (yield* Effect.tryPromise(() => | ||
| 440 | t.mutation(api.controlPlane.upsertWorkspace, { | ||
| 441 | payload: { | ||
| 442 | id: "ws_1", | ||
| 443 | organizationId: "org_1", | ||
| 444 | name: "Primary Workspace", | ||
| 445 | }, | ||
| 446 | }), | ||
| 447 | )) as { | ||
| 448 | id: string; | ||
| 449 | organizationId: string | null; | ||
| 450 | }; | ||
| 451 | |||
| 452 | expect(workspace.id).toBe("ws_1"); | ||
| 453 | expect(workspace.organizationId).toBe("org_1"); | ||
| 454 | |||
| 455 | const workspaces = (yield* Effect.tryPromise(() => | ||
| 456 | t.query(api.controlPlane.listWorkspaces, {}), | ||
| 457 | )) as Array<{ | ||
| 458 | id: string; | ||
| 459 | }>; | ||
| 460 | |||
| 461 | expect(workspaces).toHaveLength(1); | ||
| 462 | expect(workspaces[0]?.id).toBe("ws_1"); | ||
| 463 | |||
| 464 | yield* Effect.tryPromise(() => | ||
| 465 | t.mutation(api.controlPlane.upsertSource, { | ||
| 466 | workspaceId: "ws_1", | ||
| 467 | payload: { | ||
| 468 | id: "src_1", | ||
| 469 | name: "Weather API", | ||
| 470 | kind: "openapi", | ||
| 471 | endpoint: "https://example.com/openapi.json", | ||
| 472 | enabled: true, | ||
| 473 | configJson: "{}", | ||
| 474 | status: "draft", | ||
| 475 | sourceHash: null, | ||
| 476 | lastError: null, | ||
| 477 | }, | ||
| 478 | }), | ||
| 479 | ); | ||
| 480 | yield* Effect.tryPromise(() => t.finishInProgressScheduledFunctions()); | ||
| 481 | |||
| 482 | const manifest = yield* extractOpenApiManifest("Weather API", { | ||
| 483 | openapi: "3.0.0", | ||
| 484 | info: { | ||
| 485 | title: "Weather API", | ||
| 486 | version: "1.0.0", | ||
| 487 | }, | ||
| 488 | paths: { | ||
| 489 | "/weather": { | ||
| 490 | get: { | ||
| 491 | operationId: "getWeather", | ||
| 492 | responses: { | ||
| 493 | "200": { | ||
| 494 | description: "ok", | ||
| 495 | }, | ||
| 496 | }, | ||
| 497 | }, | ||
| 498 | }, | ||
| 499 | }, | ||
| 500 | }); | ||
| 501 | |||
| 502 | const now = Date.now(); | ||
| 503 | yield* Effect.tryPromise(() => | ||
| 504 | t.mutation(runtimeInternal.source_tool_registry.upsertToolArtifactForSource, { | ||
| 505 | artifact: { | ||
| 506 | id: "tool_artifact_src_1", | ||
| 507 | workspaceId: "ws_1", | ||
| 508 | sourceId: "src_1", | ||
| 509 | sourceHash: manifest.sourceHash, | ||
| 510 | toolCount: manifest.tools.length, | ||
| 511 | manifestJson: JSON.stringify(manifest), | ||
| 512 | createdAt: now, | ||
| 513 | updatedAt: now, | ||
| 514 | }, | ||
| 515 | }), | ||
| 516 | ); | ||
| 517 | |||
| 518 | const workspaceTools = (yield* Effect.tryPromise(() => | ||
| 519 | t.query(api.controlPlane.listWorkspaceTools, { | ||
| 520 | workspaceId: "ws_1", | ||
| 521 | }), | ||
| 522 | )) as Array<{ | ||
| 523 | sourceId: string; | ||
| 524 | }>; | ||
| 525 | |||
| 526 | expect(workspaceTools).toHaveLength(1); | ||
| 527 | expect(workspaceTools[0]?.sourceId).toBe("src_1"); | ||
| 528 | |||
| 529 | const sourceTools = (yield* Effect.tryPromise(() => | ||
| 530 | t.query(api.controlPlane.listSourceTools, { | ||
| 531 | workspaceId: "ws_1", | ||
| 532 | sourceId: "src_1", | ||
| 533 | }), | ||
| 534 | )) as Array<{ | ||
| 535 | sourceId: string; | ||
| 536 | }>; | ||
| 537 | |||
| 538 | expect(sourceTools).toHaveLength(1); | ||
| 539 | expect(sourceTools[0]?.sourceId).toBe("src_1"); | ||
| 540 | |||
| 541 | const sourceToolsFromWrongWorkspace = (yield* Effect.tryPromise(() => | ||
| 542 | t.query(api.controlPlane.listSourceTools, { | ||
| 543 | workspaceId: "ws_2", | ||
| 544 | sourceId: "src_1", | ||
| 545 | }), | ||
| 546 | )) as Array<unknown>; | ||
| 547 | |||
| 548 | expect(sourceToolsFromWrongWorkspace).toHaveLength(0); | ||
| 549 | }), | ||
| 550 | ); | ||
| 551 | |||
| 96 | it.effect("persists approval state for runtime tool calls", () => | 552 | it.effect("persists approval state for runtime tool calls", () => |
| 97 | Effect.gen(function* () { | 553 | Effect.gen(function* () { |
| 98 | const t = setup(); | 554 | const t = setup(); |
| @@ -9,11 +9,70 @@ import { · 8 unmodified lines | |||
| 9 | ProfileSchema, | 9 | ProfileSchema, |
| 10 | SourceCredentialBindingSchema, | 10 | SourceCredentialBindingSchema, |
| 11 | SourceSchema, | 11 | SourceSchema, |
| 12 | StorageInstanceSchema, | ||
| 12 | SyncStateSchema, | 13 | SyncStateSchema, |
| 13 | TaskRunSchema, | 14 | TaskRunSchema, |
| 14 | ToolArtifactSchema, | ||
| 15 | WorkspaceSchema, | 15 | WorkspaceSchema, |
| 16 | } from "@executor-v2/schema"; | 16 | } from "@executor-v2/schema"; |
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | |||
| 19 | const StorageFileEntrySchema = Schema.Struct({ | ||
| 20 | id: Schema.String, | ||
| 21 | storageInstanceId: Schema.String, | ||
| 22 | path: Schema.String, | ||
| 23 | contentBase64: Schema.String, | ||
| 24 | sizeBytes: Schema.Number, | ||
| 25 | updatedAt: Schema.Number, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const StorageKvEntrySchema = Schema.Struct({ | ||
| 29 | id: Schema.String, | ||
| 30 | storageInstanceId: Schema.String, | ||
| 31 | key: Schema.String, | ||
| 32 | valueJson: Schema.String, | ||
| 33 | updatedAt: Schema.Number, | ||
| 34 | }); | ||
| 35 | |||
| 36 | const StorageSqlKvEntrySchema = Schema.Struct({ | ||
| 37 | id: Schema.String, | ||
| 38 | storageInstanceId: Schema.String, | ||
| 39 | key: Schema.String, | ||
| 40 | value: Schema.String, | ||
| 41 | updatedAt: Schema.Number, | ||
| 42 | }); | ||
| 43 | |||
| 44 | const OpenApiArtifactSchema = Schema.Struct({ | ||
| 45 | id: Schema.String, | ||
| 46 | sourceHash: Schema.String, | ||
| 47 | extractorVersion: Schema.String, | ||
| 48 | toolCount: Schema.Number, | ||
| 49 | createdAt: Schema.Number, | ||
| 50 | updatedAt: Schema.Number, | ||
| 51 | }); | ||
| 52 | |||
| 53 | const OpenApiArtifactToolSchema = Schema.Struct({ | ||
| 54 | id: Schema.String, | ||
| 55 | artifactId: Schema.String, | ||
| 56 | toolId: Schema.String, | ||
| 57 | name: Schema.String, | ||
| 58 | description: Schema.NullOr(Schema.String), | ||
| 59 | method: Schema.String, | ||
| 60 | path: Schema.String, | ||
| 61 | operationHash: Schema.String, | ||
| 62 | invocationJson: Schema.String, | ||
| 63 | createdAt: Schema.Number, | ||
| 64 | updatedAt: Schema.Number, | ||
| 65 | }); | ||
| 66 | |||
| 67 | const OpenApiSourceArtifactBindingSchema = Schema.Struct({ | ||
| 68 | id: Schema.String, | ||
| 69 | workspaceId: Schema.String, | ||
| 70 | sourceId: Schema.String, | ||
| 71 | artifactId: Schema.String, | ||
| 72 | sourceHash: Schema.String, | ||
| 73 | extractorVersion: Schema.String, | ||
| 74 | updatedAt: Schema.Number, | ||
| 75 | }); | ||
| 17 | 76 | ||
| 18 | export const executorConfectSchema = defineSchema({ | 77 | export const executorConfectSchema = defineSchema({ |
| 19 | profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]), | 78 | profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]), |
| @@ -27,14 +86,23 @@ export const executorConfectSchema = defineSchema({ · 7 unmodified lines | |||
| 27 | .index("by_organizationId_accountId", ["organizationId", "accountId"]), | 86 | .index("by_organizationId_accountId", ["organizationId", "accountId"]), |
| 28 | workspaces: defineTable(WorkspaceSchema) | 87 | workspaces: defineTable(WorkspaceSchema) |
| 29 | .index("by_domainId", ["id"]) | 88 | .index("by_domainId", ["id"]) |
| 30 | .index("by_organizationId", ["organizationId"]), | 89 | .index("by_organizationId", ["organizationId"]) |
| 90 | .index("by_createdByAccountId", ["createdByAccountId"]), | ||
| 31 | sources: defineTable(SourceSchema) | 91 | sources: defineTable(SourceSchema) |
| 32 | .index("by_domainId", ["id"]) | 92 | .index("by_domainId", ["id"]) |
| 33 | .index("by_workspaceId", ["workspaceId"]), | 93 | .index("by_workspaceId", ["workspaceId"]), |
| 34 | toolArtifacts: defineTable(ToolArtifactSchema) | 94 | openApiArtifacts: defineTable(OpenApiArtifactSchema) |
| 95 | .index("by_domainId", ["id"]) | ||
| 96 | .index("by_sourceHash_extractorVersion", ["sourceHash", "extractorVersion"]), | ||
| 97 | openApiArtifactTools: defineTable(OpenApiArtifactToolSchema) | ||
| 98 | .index("by_domainId", ["id"]) | ||
| 99 | .index("by_artifactId", ["artifactId"]) | ||
| 100 | .index("by_artifactId_toolId", ["artifactId", "toolId"]), | ||
| 101 | openApiSourceArtifactBindings: defineTable(OpenApiSourceArtifactBindingSchema) | ||
| 35 | .index("by_domainId", ["id"]) | 102 | .index("by_domainId", ["id"]) |
| 36 | .index("by_workspaceId", ["workspaceId"]) | 103 | .index("by_workspaceId", ["workspaceId"]) |
| 37 | .index("by_sourceId", ["sourceId"]), | 104 | .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"]) |
| 105 | .index("by_artifactId", ["artifactId"]), | ||
| 38 | sourceCredentialBindings: defineTable(SourceCredentialBindingSchema) | 106 | sourceCredentialBindings: defineTable(SourceCredentialBindingSchema) |
| 39 | .index("by_domainId", ["id"]) | 107 | .index("by_domainId", ["id"]) |
| 40 | .index("by_credentialId", ["credentialId"]) | 108 | .index("by_credentialId", ["credentialId"]) |
| @@ -51,6 +119,22 @@ export const executorConfectSchema = defineSchema({ · 10 unmodified lines | |||
| 51 | policies: defineTable(PolicySchema) | 119 | policies: defineTable(PolicySchema) |
| 52 | .index("by_domainId", ["id"]) | 120 | .index("by_domainId", ["id"]) |
| 53 | .index("by_workspaceId", ["workspaceId"]), | 121 | .index("by_workspaceId", ["workspaceId"]), |
| 122 | storageInstances: defineTable(StorageInstanceSchema) | ||
| 123 | .index("by_domainId", ["id"]) | ||
| 124 | .index("by_workspaceId", ["workspaceId"]) | ||
| 125 | .index("by_organizationId", ["organizationId"]), | ||
| 126 | storageFileEntries: defineTable(StorageFileEntrySchema) | ||
| 127 | .index("by_domainId", ["id"]) | ||
| 128 | .index("by_storageInstanceId", ["storageInstanceId"]) | ||
| 129 | .index("by_storageInstanceId_path", ["storageInstanceId", "path"]), | ||
| 130 | storageKvEntries: defineTable(StorageKvEntrySchema) | ||
| 131 | .index("by_domainId", ["id"]) | ||
| 132 | .index("by_storageInstanceId", ["storageInstanceId"]) | ||
| 133 | .index("by_storageInstanceId_key", ["storageInstanceId", "key"]), | ||
| 134 | storageSqlKvEntries: defineTable(StorageSqlKvEntrySchema) | ||
| 135 | .index("by_domainId", ["id"]) | ||
| 136 | .index("by_storageInstanceId", ["storageInstanceId"]) | ||
| 137 | .index("by_storageInstanceId_key", ["storageInstanceId", "key"]), | ||
| 54 | approvals: defineTable(ApprovalSchema) | 138 | approvals: defineTable(ApprovalSchema) |
| 55 | .index("by_domainId", ["id"]) | 139 | .index("by_domainId", ["id"]) |
| 56 | .index("by_workspaceId", ["workspaceId"]) | 140 | .index("by_workspaceId", ["workspaceId"]) |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | |||
| 16 | } from "@executor-v2/persistence-ports"; | 16 | } from "@executor-v2/persistence-ports"; |
| 17 | import { | 17 | import { |
| 18 | ApprovalSchema, | 18 | ApprovalSchema, |
| 19 | OpenApiToolManifestSchema, | ||
| 19 | SourceSchema, | 20 | SourceSchema, |
| 20 | ToolArtifactSchema, | 21 | ToolArtifactSchema, |
| 21 | type Approval, | 22 | type Approval, |
| @@ -37,9 +38,14 @@ const runtimeInternal = internal as any; · 15 unmodified lines | |||
| 37 | 38 | ||
| 38 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); | 39 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); |
| 39 | const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema); | 40 | const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema); |
| 41 | const decodeOpenApiToolManifest = Schema.decodeUnknownSync(OpenApiToolManifestSchema); | ||
| 42 | const encodeOpenApiToolManifestJson = Schema.encodeSync( | ||
| 43 | Schema.parseJson(OpenApiToolManifestSchema), | ||
| 44 | ); | ||
| 40 | const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema); | 45 | const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema); |
| 41 | 46 | ||
| 42 | const defaultPendingRetryAfterMs = 1_000; | 47 | const defaultPendingRetryAfterMs = 1_000; |
| 48 | const openApiExtractorVersion = "openapi_v2"; | ||
| 43 | 49 | ||
| 44 | const readBooleanFlag = (value: string | undefined): boolean => { | 50 | const readBooleanFlag = (value: string | undefined): boolean => { |
| 45 | const normalized = value?.trim().toLowerCase(); | 51 | const normalized = value?.trim().toLowerCase(); |
| @@ -100,16 +106,6 @@ const unsupportedSourceStoreMutation = ( · 54 unmodified lines | |||
| 100 | details: null, | 106 | details: null, |
| 101 | }); | 107 | }); |
| 102 | 108 | ||
| 103 | const unsupportedToolArtifactMutation = (): ToolArtifactStoreError => | ||
| 104 | new ToolArtifactStoreError({ | ||
| 105 | operation: "upsert", | ||
| 106 | backend: "convex", | ||
| 107 | location: "source-tool-registry", | ||
| 108 | message: "ToolArtifactStore.upsert is not supported in source tool registry runtime", | ||
| 109 | reason: "unsupported_operation", | ||
| 110 | details: null, | ||
| 111 | }); | ||
| 112 | |||
| 113 | const sourceStoreQueryError = ( | 109 | const sourceStoreQueryError = ( |
| 114 | operation: string, | 110 | operation: string, |
| 115 | cause: unknown, | 111 | cause: unknown, |
| @@ -136,6 +132,19 @@ const toolArtifactStoreQueryError = ( · 20 unmodified lines | |||
| 136 | details: String(cause), | 132 | details: String(cause), |
| 137 | }); | 133 | }); |
| 138 | 134 | ||
| 135 | const toolArtifactStoreMutationError = ( | ||
| 136 | operation: string, | ||
| 137 | cause: unknown, | ||
| 138 | ): ToolArtifactStoreError => | ||
| 139 | new ToolArtifactStoreError({ | ||
| 140 | operation, | ||
| 141 | backend: "convex", | ||
| 142 | location: "source-tool-registry", | ||
| 143 | message: "ToolArtifactStore mutation failed", | ||
| 144 | reason: "convex_mutation_error", | ||
| 145 | details: String(cause), | ||
| 146 | }); | ||
| 147 | |||
| 139 | export const listSourcesForWorkspace = query({ | 148 | export const listSourcesForWorkspace = query({ |
| 140 | args: { | 149 | args: { |
| 141 | workspaceId: v.string(), | 150 | workspaceId: v.string(), |
| @@ -158,19 +167,130 @@ export const getToolArtifactBySource = query({ · 16 unmodified lines | |||
| 158 | sourceId: v.string(), | 167 | sourceId: v.string(), |
| 159 | }, | 168 | }, |
| 160 | handler: async (ctx, args): Promise<ToolArtifact | null> => { | 169 | handler: async (ctx, args): Promise<ToolArtifact | null> => { |
| 161 | const rows = await ctx.db | 170 | const binding = await ctx.db |
| 162 | .query("toolArtifacts") | 171 | .query("openApiSourceArtifactBindings") |
| 163 | .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | 172 | .withIndex("by_workspaceId_sourceId", (q) => |
| 164 | .collect(); | 173 | q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId) |
| 174 | ) | ||
| 175 | .unique(); | ||
| 165 | 176 | ||
| 166 | const record = rows.find((row) => row.workspaceId === args.workspaceId) ?? null; | 177 | if (!binding) { |
| 167 | if (!record) { | ||
| 168 | return null; | 178 | return null; |
| 169 | } | 179 | } |
| 170 | 180 | ||
| 171 | return decodeToolArtifact( | 181 | const toolRows = await ctx.db |
| 172 | stripConvexSystemFields(record as unknown as Record<string, unknown>), | 182 | .query("openApiArtifactTools") |
| 173 | ); | 183 | .withIndex("by_artifactId", (q) => q.eq("artifactId", binding.artifactId)) |
| 184 | .collect(); | ||
| 185 | |||
| 186 | const manifest = { | ||
| 187 | version: 1 as const, | ||
| 188 | sourceHash: binding.sourceHash, | ||
| 189 | tools: toolRows | ||
| 190 | .map((toolRow) => ({ | ||
| 191 | toolId: toolRow.toolId, | ||
| 192 | name: toolRow.name, | ||
| 193 | description: toolRow.description, | ||
| 194 | method: toolRow.method, | ||
| 195 | path: toolRow.path, | ||
| 196 | invocation: JSON.parse(toolRow.invocationJson) as unknown, | ||
| 197 | operationHash: toolRow.operationHash, | ||
| 198 | })) | ||
| 199 | .sort((left, right) => left.toolId.localeCompare(right.toolId)), | ||
| 200 | }; | ||
| 201 | |||
| 202 | const normalizedManifest = decodeOpenApiToolManifest(manifest); | ||
| 203 | |||
| 204 | return decodeToolArtifact({ | ||
| 205 | id: `tool_artifact_${args.sourceId}`, | ||
| 206 | workspaceId: args.workspaceId, | ||
| 207 | sourceId: args.sourceId, | ||
| 208 | sourceHash: binding.sourceHash, | ||
| 209 | toolCount: normalizedManifest.tools.length, | ||
| 210 | manifestJson: encodeOpenApiToolManifestJson(normalizedManifest), | ||
| 211 | createdAt: binding.updatedAt, | ||
| 212 | updatedAt: binding.updatedAt, | ||
| 213 | }); | ||
| 214 | }, | ||
| 215 | }); | ||
| 216 | |||
| 217 | export const upsertToolArtifactForSource = internalMutation({ | ||
| 218 | args: { | ||
| 219 | artifact: v.object({ | ||
| 220 | id: v.string(), | ||
| 221 | workspaceId: v.string(), | ||
| 222 | sourceId: v.string(), | ||
| 223 | sourceHash: v.string(), | ||
| 224 | toolCount: v.number(), | ||
| 225 | manifestJson: v.string(), | ||
| 226 | createdAt: v.number(), | ||
| 227 | updatedAt: v.number(), | ||
| 228 | }), | ||
| 229 | }, | ||
| 230 | handler: async (ctx, args): Promise<ToolArtifact> => { | ||
| 231 | const artifact = decodeToolArtifact(args.artifact as unknown as Record<string, unknown>); | ||
| 232 | const manifest = decodeOpenApiToolManifest(JSON.parse(artifact.manifestJson) as unknown); | ||
| 233 | |||
| 234 | const artifactId = `oa_${artifact.sourceHash}_${openApiExtractorVersion}`; | ||
| 235 | const existingArtifact = await ctx.db | ||
| 236 | .query("openApiArtifacts") | ||
| 237 | .withIndex("by_sourceHash_extractorVersion", (q) => | ||
| 238 | q.eq("sourceHash", artifact.sourceHash).eq("extractorVersion", openApiExtractorVersion) | ||
| 239 | ) | ||
| 240 | .unique(); | ||
| 241 | |||
| 242 | if (!existingArtifact) { | ||
| 243 | const now = Date.now(); | ||
| 244 | await ctx.db.insert("openApiArtifacts", { | ||
| 245 | id: artifactId, | ||
| 246 | sourceHash: artifact.sourceHash, | ||
| 247 | extractorVersion: openApiExtractorVersion, | ||
| 248 | toolCount: manifest.tools.length, | ||
| 249 | createdAt: now, | ||
| 250 | updatedAt: now, | ||
| 251 | }); | ||
| 252 | |||
| 253 | for (const tool of manifest.tools) { | ||
| 254 | await ctx.db.insert("openApiArtifactTools", { | ||
| 255 | id: `${artifactId}:${tool.toolId}`, | ||
| 256 | artifactId, | ||
| 257 | toolId: tool.toolId, | ||
| 258 | name: tool.name, | ||
| 259 | description: tool.description, | ||
| 260 | method: tool.method, | ||
| 261 | path: tool.path, | ||
| 262 | operationHash: tool.operationHash, | ||
| 263 | invocationJson: JSON.stringify(tool.invocation), | ||
| 264 | createdAt: now, | ||
| 265 | updatedAt: now, | ||
| 266 | }); | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | const existingBinding = await ctx.db | ||
| 271 | .query("openApiSourceArtifactBindings") | ||
| 272 | .withIndex("by_workspaceId_sourceId", (q) => | ||
| 273 | q.eq("workspaceId", artifact.workspaceId).eq("sourceId", artifact.sourceId) | ||
| 274 | ) | ||
| 275 | .unique(); | ||
| 276 | |||
| 277 | const bindingRow = { | ||
| 278 | id: existingBinding?.id ?? `oab_${artifact.workspaceId}_${artifact.sourceId}`, | ||
| 279 | workspaceId: artifact.workspaceId, | ||
| 280 | sourceId: artifact.sourceId, | ||
| 281 | artifactId: existingArtifact?.id ?? artifactId, | ||
| 282 | sourceHash: artifact.sourceHash, | ||
| 283 | extractorVersion: openApiExtractorVersion, | ||
| 284 | updatedAt: Date.now(), | ||
| 285 | }; | ||
| 286 | |||
| 287 | if (existingBinding) { | ||
| 288 | await ctx.db.patch(existingBinding._id, bindingRow); | ||
| 289 | } else { | ||
| 290 | await ctx.db.insert("openApiSourceArtifactBindings", bindingRow); | ||
| 291 | } | ||
| 292 | |||
| 293 | return artifact; | ||
| 174 | }, | 294 | }, |
| 175 | }); | 295 | }); |
| 176 | 296 | ||
| @@ -342,7 +462,14 @@ const createConvexToolArtifactStore = (ctx: ActionCtx): ToolArtifactStore => ({ · 165 unmodified lines | |||
| 342 | catch: (cause) => toolArtifactStoreQueryError("getBySource", cause), | 462 | catch: (cause) => toolArtifactStoreQueryError("getBySource", cause), |
| 343 | }), | 463 | }), |
| 344 | 464 | ||
| 345 | upsert: () => Effect.fail(unsupportedToolArtifactMutation()), | 465 | upsert: (artifact: ToolArtifact) => |
| 466 | Effect.tryPromise({ | ||
| 467 | try: () => | ||
| 468 | ctx.runMutation(runtimeInternal.source_tool_registry.upsertToolArtifactForSource, { | ||
| 469 | artifact, | ||
| 470 | }), | ||
| 471 | catch: (cause) => toolArtifactStoreMutationError("upsert", cause), | ||
| 472 | }).pipe(Effect.asVoid), | ||
| 346 | }); | 473 | }); |
| 347 | 474 | ||
| 348 | const createConvexPersistentToolApprovalPolicy = ( | 475 | const createConvexPersistentToolApprovalPolicy = ( |
| @@ -23,7 +23,7 @@ · 22 unmodified lines | |||
| 23 | }, | 23 | }, |
| 24 | "devDependencies": { | 24 | "devDependencies": { |
| 25 | "@effect/vitest": "catalog:", | 25 | "@effect/vitest": "catalog:", |
| 26 | "convex-test": "^0.0.41", | 26 | "convex-test": "workspace:*", |
| 27 | "vitest": "catalog:" | 27 | "vitest": "catalog:" |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
| @@ -3,15 +3,38 @@ import { · 2 unmodified lines | |||
| 3 | deriveWorkspaceMembershipsForPrincipal, | 3 | deriveWorkspaceMembershipsForPrincipal, |
| 4 | requirePrincipalFromHeaders, | 4 | requirePrincipalFromHeaders, |
| 5 | } from "@executor-v2/management-api"; | 5 | } from "@executor-v2/management-api"; |
| 6 | import { ActorUnauthenticatedError, makeActor } from "@executor-v2/domain"; | 6 | import { |
| 7 | ActorUnauthenticatedError, | ||
| 8 | makeActor, | ||
| 9 | makeAllowAllActor, | ||
| 10 | } from "@executor-v2/domain"; | ||
| 7 | import { | 11 | import { |
| 8 | type LocalStateSnapshot, | 12 | type LocalStateSnapshot, |
| 9 | type LocalStateStore, | 13 | type LocalStateStore, |
| 10 | } from "@executor-v2/persistence-local"; | 14 | } from "@executor-v2/persistence-local"; |
| 11 | import { type OrganizationMembership } from "@executor-v2/schema"; | 15 | import { |
| 16 | type OrganizationMembership, | ||
| 17 | type WorkspaceMembership, | ||
| 18 | } from "@executor-v2/schema"; | ||
| 19 | import * as PlatformHeaders from "@effect/platform/Headers"; | ||
| 12 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| 13 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 14 | 22 | ||
| 23 | const isTruthy = (value: string | undefined): boolean => { | ||
| 24 | const normalized = value?.trim().toLowerCase(); | ||
| 25 | return normalized === "1" || normalized === "true" || normalized === "yes"; | ||
| 26 | }; | ||
| 27 | |||
| 28 | const localAdminFallbackEnabled = (() => { | ||
| 29 | const configured = process.env.PM_ALLOW_LOCAL_ADMIN; | ||
| 30 | |||
| 31 | if (configured === undefined || configured.trim().length === 0) { | ||
| 32 | return process.env.NODE_ENV !== "production"; | ||
| 33 | } | ||
| 34 | |||
| 35 | return isTruthy(configured); | ||
| 36 | })(); | ||
| 37 | |||
| 15 | const organizationMembershipsForAccount = ( | 38 | const organizationMembershipsForAccount = ( |
| 16 | snapshot: LocalStateSnapshot, | 39 | snapshot: LocalStateSnapshot, |
| 17 | accountId: OrganizationMembership["accountId"], | 40 | accountId: OrganizationMembership["accountId"], |
| @@ -20,8 +43,61 @@ const organizationMembershipsForAccount = ( · 2 unmodified lines | |||
| 20 | (membership) => membership.accountId === accountId, | 43 | (membership) => membership.accountId === accountId, |
| 21 | ); | 44 | ); |
| 22 | 45 | ||
| 46 | const workspaceMembershipsForAccount = ( | ||
| 47 | snapshot: LocalStateSnapshot, | ||
| 48 | accountId: OrganizationMembership["accountId"], | ||
| 49 | organizationMemberships: ReadonlyArray<OrganizationMembership>, | ||
| 50 | ): ReadonlyArray<WorkspaceMembership> => | ||
| 51 | snapshot.workspaces.flatMap((workspace) => | ||
| 52 | deriveWorkspaceMembershipsForPrincipal({ | ||
| 53 | principalAccountId: accountId, | ||
| 54 | workspaceId: workspace.id, | ||
| 55 | workspace, | ||
| 56 | organizationMemberships, | ||
| 57 | }), | ||
| 58 | ); | ||
| 59 | |||
| 60 | const resolveActorFromSnapshot = ( | ||
| 61 | localStateStore: LocalStateStore, | ||
| 62 | headers: PlatformHeaders.Headers, | ||
| 63 | ) => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const principal = yield* requirePrincipalFromHeaders(headers); | ||
| 66 | |||
| 67 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 68 | Effect.mapError( | ||
| 69 | (error) => | ||
| 70 | new ActorUnauthenticatedError({ | ||
| 71 | message: `Unable to read local auth state (${error.operation})`, | ||
| 72 | }), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | |||
| 76 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 77 | |||
| 78 | if (snapshot === null && localAdminFallbackEnabled) { | ||
| 79 | return makeAllowAllActor(principal); | ||
| 80 | } | ||
| 81 | |||
| 82 | const organizationMemberships = | ||
| 83 | snapshot === null | ||
| 84 | ? [] | ||
| 85 | : organizationMembershipsForAccount(snapshot, principal.accountId); | ||
| 86 | const workspaceMemberships = | ||
| 87 | snapshot === null | ||
| 88 | ? [] | ||
| 89 | : workspaceMembershipsForAccount(snapshot, principal.accountId, organizationMemberships); | ||
| 90 | |||
| 91 | return yield* makeActor({ | ||
| 92 | principal, | ||
| 93 | workspaceMemberships, | ||
| 94 | organizationMemberships, | ||
| 95 | }); | ||
| 96 | }); | ||
| 97 | |||
| 23 | export const PmActorLive = (localStateStore: LocalStateStore) => | 98 | export const PmActorLive = (localStateStore: LocalStateStore) => |
| 24 | ControlPlaneActorResolverLive({ | 99 | ControlPlaneActorResolverLive({ |
| 100 | resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers), | ||
| 25 | resolveWorkspaceActor: (input) => | 101 | resolveWorkspaceActor: (input) => |
| 26 | Effect.gen(function* () { | 102 | Effect.gen(function* () { |
| 27 | const principal = yield* requirePrincipalFromHeaders(input.headers); | 103 | const principal = yield* requirePrincipalFromHeaders(input.headers); |
| @@ -37,6 +113,10 @@ export const PmActorLive = (localStateStore: LocalStateStore) => · 9 unmodified lines | |||
| 37 | 113 | ||
| 38 | const snapshot = Option.getOrNull(snapshotOption); | 114 | const snapshot = Option.getOrNull(snapshotOption); |
| 39 | 115 | ||
| 116 | if (snapshot === null && localAdminFallbackEnabled) { | ||
| 117 | return makeAllowAllActor(principal); | ||
| 118 | } | ||
| 119 | |||
| 40 | const organizationMemberships = | 120 | const organizationMemberships = |
| 41 | snapshot === null | 121 | snapshot === null |
| 42 | ? [] | 122 | ? [] |
| @@ -0,0 +1,260 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { | ||
| 3 | type LocalStateSnapshot, | ||
| 4 | type LocalStateStore, | ||
| 5 | type LocalStateStoreError, | ||
| 6 | } from "@executor-v2/persistence-local"; | ||
| 7 | import { | ||
| 8 | makeControlPlaneCredentialsService, | ||
| 9 | type ControlPlaneCredentialsServiceShape, | ||
| 10 | } from "@executor-v2/management-api"; | ||
| 11 | import { | ||
| 12 | type OrganizationId, | ||
| 13 | type SourceCredentialBinding, | ||
| 14 | type WorkspaceId, | ||
| 15 | } from "@executor-v2/schema"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Option from "effect/Option"; | ||
| 18 | |||
| 19 | const toSourceStoreError = ( | ||
| 20 | operation: string, | ||
| 21 | message: string, | ||
| 22 | details: string | null, | ||
| 23 | ): SourceStoreError => | ||
| 24 | new SourceStoreError({ | ||
| 25 | operation, | ||
| 26 | backend: "local-file", | ||
| 27 | location: "snapshot.json", | ||
| 28 | message, | ||
| 29 | reason: null, | ||
| 30 | details, | ||
| 31 | }); | ||
| 32 | |||
| 33 | const toSourceStoreErrorFromLocalState = ( | ||
| 34 | operation: string, | ||
| 35 | error: LocalStateStoreError, | ||
| 36 | ): SourceStoreError => | ||
| 37 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 38 | |||
| 39 | const resolveWorkspaceOrganizationId = ( | ||
| 40 | snapshot: LocalStateSnapshot, | ||
| 41 | workspaceId: WorkspaceId, | ||
| 42 | ): OrganizationId => { | ||
| 43 | const workspace = snapshot.workspaces.find((item) => item.id === workspaceId); | ||
| 44 | const organizationId = workspace?.organizationId; | ||
| 45 | |||
| 46 | if (organizationId !== null && organizationId !== undefined) { | ||
| 47 | return organizationId; | ||
| 48 | } | ||
| 49 | |||
| 50 | return (`org_${workspaceId}`) as OrganizationId; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const sortCredentialBindings = ( | ||
| 54 | bindings: ReadonlyArray<SourceCredentialBinding>, | ||
| 55 | ): Array<SourceCredentialBinding> => | ||
| 56 | [...bindings].sort((left, right) => { | ||
| 57 | const leftKey = `${left.sourceKey}:${left.provider}:${left.id}`.toLowerCase(); | ||
| 58 | const rightKey = `${right.sourceKey}:${right.provider}:${right.id}`.toLowerCase(); | ||
| 59 | return leftKey.localeCompare(rightKey); | ||
| 60 | }); | ||
| 61 | |||
| 62 | const replaceCredentialBindingAt = ( | ||
| 63 | snapshot: LocalStateSnapshot, | ||
| 64 | index: number, | ||
| 65 | binding: SourceCredentialBinding, | ||
| 66 | ): LocalStateSnapshot => { | ||
| 67 | const next = [...snapshot.credentialBindings]; | ||
| 68 | next[index] = binding; | ||
| 69 | |||
| 70 | return { | ||
| 71 | ...snapshot, | ||
| 72 | generatedAt: Date.now(), | ||
| 73 | credentialBindings: next, | ||
| 74 | }; | ||
| 75 | }; | ||
| 76 | |||
| 77 | const appendCredentialBinding = ( | ||
| 78 | snapshot: LocalStateSnapshot, | ||
| 79 | binding: SourceCredentialBinding, | ||
| 80 | ): LocalStateSnapshot => ({ | ||
| 81 | ...snapshot, | ||
| 82 | generatedAt: Date.now(), | ||
| 83 | credentialBindings: [...snapshot.credentialBindings, binding], | ||
| 84 | }); | ||
| 85 | |||
| 86 | const removeCredentialBindingForWorkspace = ( | ||
| 87 | snapshot: LocalStateSnapshot, | ||
| 88 | workspaceId: WorkspaceId, | ||
| 89 | credentialBindingId: string, | ||
| 90 | ): { snapshot: LocalStateSnapshot; removed: boolean } => { | ||
| 91 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | ||
| 92 | const removeIndex = snapshot.credentialBindings.findIndex( | ||
| 93 | (binding) => | ||
| 94 | binding.id === credentialBindingId | ||
| 95 | && ( | ||
| 96 | binding.workspaceId === workspaceId | ||
| 97 | || (binding.workspaceId === null && binding.organizationId === organizationId) | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | |||
| 101 | if (removeIndex < 0) { | ||
| 102 | return { | ||
| 103 | snapshot, | ||
| 104 | removed: false, | ||
| 105 | }; | ||
| 106 | } | ||
| 107 | |||
| 108 | const next = [...snapshot.credentialBindings]; | ||
| 109 | next.splice(removeIndex, 1); | ||
| 110 | |||
| 111 | return { | ||
| 112 | removed: true, | ||
| 113 | snapshot: { | ||
| 114 | ...snapshot, | ||
| 115 | generatedAt: Date.now(), | ||
| 116 | credentialBindings: next, | ||
| 117 | }, | ||
| 118 | }; | ||
| 119 | }; | ||
| 120 | |||
| 121 | export const createPmCredentialsService = ( | ||
| 122 | localStateStore: LocalStateStore, | ||
| 123 | ): ControlPlaneCredentialsServiceShape => | ||
| 124 | makeControlPlaneCredentialsService({ | ||
| 125 | listCredentialBindings: (workspaceId) => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 128 | Effect.mapError((error) => | ||
| 129 | toSourceStoreErrorFromLocalState("credentials.list", error), | ||
| 130 | ), | ||
| 131 | ); | ||
| 132 | |||
| 133 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 134 | if (snapshot === null) { | ||
| 135 | return []; | ||
| 136 | } | ||
| 137 | |||
| 138 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | ||
| 139 | |||
| 140 | return sortCredentialBindings( | ||
| 141 | snapshot.credentialBindings.filter( | ||
| 142 | (binding) => | ||
| 143 | binding.workspaceId === workspaceId | ||
| 144 | || (binding.workspaceId === null && binding.organizationId === organizationId), | ||
| 145 | ), | ||
| 146 | ); | ||
| 147 | }), | ||
| 148 | |||
| 149 | upsertCredentialBinding: (input) => | ||
| 150 | Effect.gen(function* () { | ||
| 151 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 152 | Effect.mapError((error) => | ||
| 153 | toSourceStoreErrorFromLocalState("credentials.upsert", error), | ||
| 154 | ), | ||
| 155 | ); | ||
| 156 | |||
| 157 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 158 | if (snapshot === null) { | ||
| 159 | return yield* toSourceStoreError( | ||
| 160 | "credentials.upsert", | ||
| 161 | "Credential snapshot not found", | ||
| 162 | `workspace=${input.workspaceId}`, | ||
| 163 | ); | ||
| 164 | } | ||
| 165 | |||
| 166 | if (input.payload.scopeType === "account" && input.payload.accountId === null) { | ||
| 167 | return yield* toSourceStoreError( | ||
| 168 | "credentials.upsert", | ||
| 169 | "Account scope credentials require accountId", | ||
| 170 | `workspace=${input.workspaceId}`, | ||
| 171 | ); | ||
| 172 | } | ||
| 173 | |||
| 174 | const now = Date.now(); | ||
| 175 | const requestedId = input.payload.id; | ||
| 176 | |||
| 177 | const existingIndex = requestedId | ||
| 178 | ? snapshot.credentialBindings.findIndex( | ||
| 179 | (binding) => | ||
| 180 | binding.workspaceId === input.workspaceId && binding.id === requestedId, | ||
| 181 | ) | ||
| 182 | : -1; | ||
| 183 | |||
| 184 | const existing = existingIndex >= 0 ? snapshot.credentialBindings[existingIndex] : null; | ||
| 185 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | ||
| 186 | |||
| 187 | const nextBinding: SourceCredentialBinding = { | ||
| 188 | id: | ||
| 189 | existing?.id | ||
| 190 | ?? (requestedId | ||
| 191 | ?? (`credential_binding_${crypto.randomUUID()}` as SourceCredentialBinding["id"])), | ||
| 192 | credentialId: input.payload.credentialId, | ||
| 193 | organizationId, | ||
| 194 | workspaceId: | ||
| 195 | input.payload.scopeType === "workspace" ? input.workspaceId : null, | ||
| 196 | accountId: | ||
| 197 | input.payload.scopeType === "account" | ||
| 198 | ? (input.payload.accountId ?? null) | ||
| 199 | : null, | ||
| 200 | scopeType: input.payload.scopeType, | ||
| 201 | sourceKey: input.payload.sourceKey, | ||
| 202 | provider: input.payload.provider, | ||
| 203 | secretRef: input.payload.secretRef, | ||
| 204 | additionalHeadersJson: input.payload.additionalHeadersJson ?? null, | ||
| 205 | boundAuthFingerprint: input.payload.boundAuthFingerprint ?? null, | ||
| 206 | createdAt: existing?.createdAt ?? now, | ||
| 207 | updatedAt: now, | ||
| 208 | }; | ||
| 209 | |||
| 210 | const nextSnapshot = existingIndex >= 0 | ||
| 211 | ? replaceCredentialBindingAt(snapshot, existingIndex, nextBinding) | ||
| 212 | : appendCredentialBinding(snapshot, nextBinding); | ||
| 213 | |||
| 214 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 215 | Effect.mapError((error) => | ||
| 216 | toSourceStoreErrorFromLocalState("credentials.upsert_write", error), | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | |||
| 220 | return nextBinding; | ||
| 221 | }), | ||
| 222 | |||
| 223 | removeCredentialBinding: (input) => | ||
| 224 | Effect.gen(function* () { | ||
| 225 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 226 | Effect.mapError((error) => | ||
| 227 | toSourceStoreErrorFromLocalState("credentials.remove", error), | ||
| 228 | ), | ||
| 229 | ); | ||
| 230 | |||
| 231 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 232 | if (snapshot === null) { | ||
| 233 | return { | ||
| 234 | removed: false, | ||
| 235 | }; | ||
| 236 | } | ||
| 237 | |||
| 238 | const next = removeCredentialBindingForWorkspace( | ||
| 239 | snapshot, | ||
| 240 | input.workspaceId, | ||
| 241 | input.credentialBindingId, | ||
| 242 | ); | ||
| 243 | |||
| 244 | if (!next.removed) { | ||
| 245 | return { | ||
| 246 | removed: false, | ||
| 247 | }; | ||
| 248 | } | ||
| 249 | |||
| 250 | yield* localStateStore.writeSnapshot(next.snapshot).pipe( | ||
| 251 | Effect.mapError((error) => | ||
| 252 | toSourceStoreErrorFromLocalState("credentials.remove_write", error), | ||
| 253 | ), | ||
| 254 | ); | ||
| 255 | |||
| 256 | return { | ||
| 257 | removed: true, | ||
| 258 | }; | ||
| 259 | }), | ||
| 260 | }); | ||
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | |||
| 3 | ControlPlaneService, | 3 | ControlPlaneService, |
| 4 | makeControlPlaneService, | 4 | makeControlPlaneService, |
| 5 | makeControlPlaneSourcesService, | 5 | makeControlPlaneSourcesService, |
| 6 | fetchOpenApiDocument, | ||
| 6 | makeControlPlaneWebHandler, | 7 | makeControlPlaneWebHandler, |
| 7 | makeSourceCatalogService, | 8 | makeSourceCatalogService, |
| 8 | makeSourceManagerService, | 9 | makeSourceManagerService, |
| @@ -34,6 +35,12 @@ import { · 25 unmodified lines | |||
| 34 | createPmPersistentToolApprovalPolicy, | 35 | createPmPersistentToolApprovalPolicy, |
| 35 | } from "./approvals-service"; | 36 | } from "./approvals-service"; |
| 36 | import { startPmHttpServer } from "./http-server"; | 37 | import { startPmHttpServer } from "./http-server"; |
| 38 | import { createPmPoliciesService } from "./policies-service"; | ||
| 39 | import { createPmCredentialsService } from "./credentials-service"; | ||
| 40 | import { createPmOrganizationsService } from "./organizations-service"; | ||
| 41 | import { createPmStorageService } from "./storage-service"; | ||
| 42 | import { createPmToolsService } from "./tools-service"; | ||
| 43 | import { createPmWorkspacesService } from "./workspaces-service"; | ||
| 37 | import { createPmMcpHandler } from "./mcp-handler"; | 44 | import { createPmMcpHandler } from "./mcp-handler"; |
| 38 | import { createPmExecuteRuntimeRun } from "./runtime-execution-port"; | 45 | import { createPmExecuteRuntimeRun } from "./runtime-execution-port"; |
| 39 | import { createPmToolCallHttpHandler } from "./tool-call-handler"; | 46 | import { createPmToolCallHttpHandler } from "./tool-call-handler"; |
| @@ -113,14 +120,7 @@ const sourcesService = { · 73 unmodified lines | |||
| 113 | } | 120 | } |
| 114 | 121 | ||
| 115 | const openApiSpecResult = yield* Effect.tryPromise({ | 122 | const openApiSpecResult = yield* Effect.tryPromise({ |
| 116 | try: async () => { | 123 | try: () => fetchOpenApiDocument(source.endpoint), |
| 117 | const response = await fetch(source.endpoint); | ||
| 118 | if (!response.ok) { | ||
| 119 | throw new Error(`Failed fetching OpenAPI spec (${response.status})`); | ||
| 120 | } | ||
| 121 | |||
| 122 | return await response.json(); | ||
| 123 | }, | ||
| 124 | catch: (cause) => String(cause), | 124 | catch: (cause) => String(cause), |
| 125 | }).pipe(Effect.either); | 125 | }).pipe(Effect.either); |
| 126 | 126 | ||
| @@ -139,9 +139,23 @@ const sourcesService = { · 12 unmodified lines | |||
| 139 | }), | 139 | }), |
| 140 | }; | 140 | }; |
| 141 | 141 | ||
| 142 | const credentialsService = createPmCredentialsService(localStateStore); | ||
| 143 | const policiesService = createPmPoliciesService(localStateStore); | ||
| 144 | const organizationsService = createPmOrganizationsService(localStateStore); | ||
| 145 | const workspacesService = createPmWorkspacesService(localStateStore); | ||
| 146 | const toolsService = createPmToolsService(sourceStore, toolArtifactStore); | ||
| 147 | const storageService = createPmStorageService(localStateStore, { | ||
| 148 | stateRootDir: pmStateRootDir, | ||
| 149 | }); | ||
| 142 | const approvalsService = createPmApprovalsService(localStateStore); | 150 | const approvalsService = createPmApprovalsService(localStateStore); |
| 143 | const controlPlaneService = makeControlPlaneService({ | 151 | const controlPlaneService = makeControlPlaneService({ |
| 144 | sources: sourcesService, | 152 | sources: sourcesService, |
| 153 | credentials: credentialsService, | ||
| 154 | policies: policiesService, | ||
| 155 | organizations: organizationsService, | ||
| 156 | workspaces: workspacesService, | ||
| 157 | tools: toolsService, | ||
| 158 | storage: storageService, | ||
| 145 | approvals: approvalsService, | 159 | approvals: approvalsService, |
| 146 | }); | 160 | }); |
| 147 | 161 | ||
| @@ -0,0 +1,142 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { | ||
| 3 | makeControlPlaneOrganizationsService, | ||
| 4 | type ControlPlaneOrganizationsServiceShape, | ||
| 5 | } from "@executor-v2/management-api"; | ||
| 6 | import { | ||
| 7 | type LocalStateSnapshot, | ||
| 8 | type LocalStateStore, | ||
| 9 | type LocalStateStoreError, | ||
| 10 | } from "@executor-v2/persistence-local"; | ||
| 11 | import { type Organization } from "@executor-v2/schema"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | |||
| 15 | const toSourceStoreError = ( | ||
| 16 | operation: string, | ||
| 17 | message: string, | ||
| 18 | details: string | null, | ||
| 19 | ): SourceStoreError => | ||
| 20 | new SourceStoreError({ | ||
| 21 | operation, | ||
| 22 | backend: "local-file", | ||
| 23 | location: "snapshot.json", | ||
| 24 | message, | ||
| 25 | reason: null, | ||
| 26 | details, | ||
| 27 | }); | ||
| 28 | |||
| 29 | const toSourceStoreErrorFromLocalState = ( | ||
| 30 | operation: string, | ||
| 31 | error: LocalStateStoreError, | ||
| 32 | ): SourceStoreError => | ||
| 33 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 34 | |||
| 35 | const sortOrganizations = ( | ||
| 36 | organizations: ReadonlyArray<Organization>, | ||
| 37 | ): Array<Organization> => | ||
| 38 | [...organizations].sort((left, right) => { | ||
| 39 | const leftName = left.name.toLowerCase(); | ||
| 40 | const rightName = right.name.toLowerCase(); | ||
| 41 | |||
| 42 | if (leftName === rightName) { | ||
| 43 | return left.id.localeCompare(right.id); | ||
| 44 | } | ||
| 45 | |||
| 46 | return leftName.localeCompare(rightName); | ||
| 47 | }); | ||
| 48 | |||
| 49 | const replaceOrganizationAt = ( | ||
| 50 | snapshot: LocalStateSnapshot, | ||
| 51 | index: number, | ||
| 52 | organization: Organization, | ||
| 53 | ): LocalStateSnapshot => { | ||
| 54 | const next = [...snapshot.organizations]; | ||
| 55 | next[index] = organization; | ||
| 56 | |||
| 57 | return { | ||
| 58 | ...snapshot, | ||
| 59 | generatedAt: Date.now(), | ||
| 60 | organizations: next, | ||
| 61 | }; | ||
| 62 | }; | ||
| 63 | |||
| 64 | const appendOrganization = ( | ||
| 65 | snapshot: LocalStateSnapshot, | ||
| 66 | organization: Organization, | ||
| 67 | ): LocalStateSnapshot => ({ | ||
| 68 | ...snapshot, | ||
| 69 | generatedAt: Date.now(), | ||
| 70 | organizations: [...snapshot.organizations, organization], | ||
| 71 | }); | ||
| 72 | |||
| 73 | export const createPmOrganizationsService = ( | ||
| 74 | localStateStore: LocalStateStore, | ||
| 75 | ): ControlPlaneOrganizationsServiceShape => | ||
| 76 | makeControlPlaneOrganizationsService({ | ||
| 77 | listOrganizations: () => | ||
| 78 | Effect.gen(function* () { | ||
| 79 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 80 | Effect.mapError((error) => | ||
| 81 | toSourceStoreErrorFromLocalState("organizations.list", error), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | |||
| 85 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 86 | if (snapshot === null) { | ||
| 87 | return []; | ||
| 88 | } | ||
| 89 | |||
| 90 | return sortOrganizations(snapshot.organizations); | ||
| 91 | }), | ||
| 92 | |||
| 93 | upsertOrganization: (input) => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 96 | Effect.mapError((error) => | ||
| 97 | toSourceStoreErrorFromLocalState("organizations.upsert", error), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | |||
| 101 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 102 | if (snapshot === null) { | ||
| 103 | return yield* toSourceStoreError( | ||
| 104 | "organizations.upsert", | ||
| 105 | "Organization snapshot not found", | ||
| 106 | null, | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | const now = Date.now(); | ||
| 111 | const existingIndex = input.payload.id | ||
| 112 | ? snapshot.organizations.findIndex((organization) => organization.id === input.payload.id) | ||
| 113 | : snapshot.organizations.findIndex( | ||
| 114 | (organization) => organization.slug === input.payload.slug, | ||
| 115 | ); | ||
| 116 | const existing = existingIndex >= 0 ? snapshot.organizations[existingIndex] : null; | ||
| 117 | |||
| 118 | const nextOrganization: Organization = { | ||
| 119 | id: | ||
| 120 | existing?.id | ||
| 121 | ?? (input.payload.id ?? (`org_${crypto.randomUUID()}` as Organization["id"])), | ||
| 122 | slug: input.payload.slug, | ||
| 123 | name: input.payload.name, | ||
| 124 | status: input.payload.status ?? existing?.status ?? "active", | ||
| 125 | createdByAccountId: existing?.createdByAccountId ?? null, | ||
| 126 | createdAt: existing?.createdAt ?? now, | ||
| 127 | updatedAt: now, | ||
| 128 | }; | ||
| 129 | |||
| 130 | const nextSnapshot = existingIndex >= 0 | ||
| 131 | ? replaceOrganizationAt(snapshot, existingIndex, nextOrganization) | ||
| 132 | : appendOrganization(snapshot, nextOrganization); | ||
| 133 | |||
| 134 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 135 | Effect.mapError((error) => | ||
| 136 | toSourceStoreErrorFromLocalState("organizations.upsert_write", error), | ||
| 137 | ), | ||
| 138 | ); | ||
| 139 | |||
| 140 | return nextOrganization; | ||
| 141 | }), | ||
| 142 | }); | ||
| @@ -33,6 +33,7 @@ const createTestSnapshot = (): LocalStateSnapshot => · 32 unmodified lines | |||
| 33 | policies: [], | 33 | policies: [], |
| 34 | approvals: [], | 34 | approvals: [], |
| 35 | taskRuns: [], | 35 | taskRuns: [], |
| 36 | storageInstances: [], | ||
| 36 | syncStates: [], | 37 | syncStates: [], |
| 37 | }) as unknown as LocalStateSnapshot; | 38 | }) as unknown as LocalStateSnapshot; |
| 38 | 39 | ||
| @@ -0,0 +1,208 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { | ||
| 3 | type LocalStateSnapshot, | ||
| 4 | type LocalStateStore, | ||
| 5 | type LocalStateStoreError, | ||
| 6 | } from "@executor-v2/persistence-local"; | ||
| 7 | import { | ||
| 8 | makeControlPlanePoliciesService, | ||
| 9 | type ControlPlanePoliciesServiceShape, | ||
| 10 | } from "@executor-v2/management-api"; | ||
| 11 | import { type Policy } from "@executor-v2/schema"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | |||
| 15 | const toSourceStoreError = ( | ||
| 16 | operation: string, | ||
| 17 | message: string, | ||
| 18 | details: string | null, | ||
| 19 | ): SourceStoreError => | ||
| 20 | new SourceStoreError({ | ||
| 21 | operation, | ||
| 22 | backend: "local-file", | ||
| 23 | location: "snapshot.json", | ||
| 24 | message, | ||
| 25 | reason: null, | ||
| 26 | details, | ||
| 27 | }); | ||
| 28 | |||
| 29 | const toSourceStoreErrorFromLocalState = ( | ||
| 30 | operation: string, | ||
| 31 | error: LocalStateStoreError, | ||
| 32 | ): SourceStoreError => | ||
| 33 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 34 | |||
| 35 | const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> => | ||
| 36 | [...policies].sort((left, right) => { | ||
| 37 | const leftPattern = left.toolPathPattern.toLowerCase(); | ||
| 38 | const rightPattern = right.toolPathPattern.toLowerCase(); | ||
| 39 | if (leftPattern === rightPattern) { | ||
| 40 | return right.updatedAt - left.updatedAt; | ||
| 41 | } | ||
| 42 | |||
| 43 | return leftPattern.localeCompare(rightPattern); | ||
| 44 | }); | ||
| 45 | |||
| 46 | const replacePolicyAt = ( | ||
| 47 | snapshot: LocalStateSnapshot, | ||
| 48 | index: number, | ||
| 49 | policy: Policy, | ||
| 50 | ): LocalStateSnapshot => { | ||
| 51 | const next = [...snapshot.policies]; | ||
| 52 | next[index] = policy; | ||
| 53 | |||
| 54 | return { | ||
| 55 | ...snapshot, | ||
| 56 | generatedAt: Date.now(), | ||
| 57 | policies: next, | ||
| 58 | }; | ||
| 59 | }; | ||
| 60 | |||
| 61 | const appendPolicy = ( | ||
| 62 | snapshot: LocalStateSnapshot, | ||
| 63 | policy: Policy, | ||
| 64 | ): LocalStateSnapshot => ({ | ||
| 65 | ...snapshot, | ||
| 66 | generatedAt: Date.now(), | ||
| 67 | policies: [...snapshot.policies, policy], | ||
| 68 | }); | ||
| 69 | |||
| 70 | const removePolicyForWorkspace = ( | ||
| 71 | snapshot: LocalStateSnapshot, | ||
| 72 | workspaceId: Policy["workspaceId"], | ||
| 73 | policyId: string, | ||
| 74 | ): { snapshot: LocalStateSnapshot; removed: boolean } => { | ||
| 75 | const removeIndex = snapshot.policies.findIndex( | ||
| 76 | (policy) => policy.workspaceId === workspaceId && policy.id === policyId, | ||
| 77 | ); | ||
| 78 | |||
| 79 | if (removeIndex < 0) { | ||
| 80 | return { | ||
| 81 | snapshot, | ||
| 82 | removed: false, | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | |||
| 86 | const next = [...snapshot.policies]; | ||
| 87 | next.splice(removeIndex, 1); | ||
| 88 | |||
| 89 | return { | ||
| 90 | removed: true, | ||
| 91 | snapshot: { | ||
| 92 | ...snapshot, | ||
| 93 | generatedAt: Date.now(), | ||
| 94 | policies: next, | ||
| 95 | }, | ||
| 96 | }; | ||
| 97 | }; | ||
| 98 | |||
| 99 | export const createPmPoliciesService = ( | ||
| 100 | localStateStore: LocalStateStore, | ||
| 101 | ): ControlPlanePoliciesServiceShape => | ||
| 102 | makeControlPlanePoliciesService({ | ||
| 103 | listPolicies: (workspaceId) => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 106 | Effect.mapError((error) => | ||
| 107 | toSourceStoreErrorFromLocalState("policies.list", error), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | |||
| 111 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 112 | if (snapshot === null) { | ||
| 113 | return []; | ||
| 114 | } | ||
| 115 | |||
| 116 | return sortPolicies( | ||
| 117 | snapshot.policies.filter((policy) => policy.workspaceId === workspaceId), | ||
| 118 | ); | ||
| 119 | }), | ||
| 120 | |||
| 121 | upsertPolicy: (input) => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 124 | Effect.mapError((error) => | ||
| 125 | toSourceStoreErrorFromLocalState("policies.upsert", error), | ||
| 126 | ), | ||
| 127 | ); | ||
| 128 | |||
| 129 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 130 | if (snapshot === null) { | ||
| 131 | return yield* toSourceStoreError( | ||
| 132 | "policies.upsert", | ||
| 133 | "Policy snapshot not found", | ||
| 134 | `workspace=${input.workspaceId}`, | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | const now = Date.now(); | ||
| 139 | const requestedId = input.payload.id; | ||
| 140 | |||
| 141 | const existingIndex = requestedId | ||
| 142 | ? snapshot.policies.findIndex( | ||
| 143 | (policy) => | ||
| 144 | policy.workspaceId === input.workspaceId && policy.id === requestedId, | ||
| 145 | ) | ||
| 146 | : -1; | ||
| 147 | |||
| 148 | const existing = existingIndex >= 0 ? snapshot.policies[existingIndex] : null; | ||
| 149 | |||
| 150 | const nextPolicy: Policy = { | ||
| 151 | id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])), | ||
| 152 | workspaceId: input.workspaceId, | ||
| 153 | toolPathPattern: input.payload.toolPathPattern, | ||
| 154 | decision: input.payload.decision, | ||
| 155 | createdAt: existing?.createdAt ?? now, | ||
| 156 | updatedAt: now, | ||
| 157 | }; | ||
| 158 | |||
| 159 | const nextSnapshot = existingIndex >= 0 | ||
| 160 | ? replacePolicyAt(snapshot, existingIndex, nextPolicy) | ||
| 161 | : appendPolicy(snapshot, nextPolicy); | ||
| 162 | |||
| 163 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 164 | Effect.mapError((error) => | ||
| 165 | toSourceStoreErrorFromLocalState("policies.upsert_write", error), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | |||
| 169 | return nextPolicy; | ||
| 170 | }), | ||
| 171 | |||
| 172 | removePolicy: (input) => | ||
| 173 | Effect.gen(function* () { | ||
| 174 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 175 | Effect.mapError((error) => | ||
| 176 | toSourceStoreErrorFromLocalState("policies.remove", error), | ||
| 177 | ), | ||
| 178 | ); | ||
| 179 | |||
| 180 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 181 | if (snapshot === null) { | ||
| 182 | return { | ||
| 183 | removed: false, | ||
| 184 | }; | ||
| 185 | } | ||
| 186 | |||
| 187 | const next = removePolicyForWorkspace( | ||
| 188 | snapshot, | ||
| 189 | input.workspaceId, | ||
| 190 | input.policyId, | ||
| 191 | ); | ||
| 192 | if (!next.removed) { | ||
| 193 | return { | ||
| 194 | removed: false, | ||
| 195 | }; | ||
| 196 | } | ||
| 197 | |||
| 198 | yield* localStateStore.writeSnapshot(next.snapshot).pipe( | ||
| 199 | Effect.mapError((error) => | ||
| 200 | toSourceStoreErrorFromLocalState("policies.remove_write", error), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | |||
| 204 | return { | ||
| 205 | removed: true, | ||
| 206 | }; | ||
| 207 | }), | ||
| 208 | }); | ||
| @@ -0,0 +1,161 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import type { | ||
| 3 | LocalStateSnapshot, | ||
| 4 | LocalStateStore, | ||
| 5 | } from "@executor-v2/persistence-local"; | ||
| 6 | import type { StorageInstance, WorkspaceId } from "@executor-v2/schema"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | ||
| 10 | import { tmpdir } from "node:os"; | ||
| 11 | import * as path from "node:path"; | ||
| 12 | |||
| 13 | import { createPmStorageService } from "./storage-service"; | ||
| 14 | |||
| 15 | const createTestSnapshot = (): LocalStateSnapshot => | ||
| 16 | ({ | ||
| 17 | schemaVersion: 1, | ||
| 18 | generatedAt: Date.now(), | ||
| 19 | profile: { | ||
| 20 | id: "profile_local", | ||
| 21 | defaultWorkspaceId: "ws_local", | ||
| 22 | displayName: "Local", | ||
| 23 | runtimeMode: "local", | ||
| 24 | createdAt: Date.now(), | ||
| 25 | updatedAt: Date.now(), | ||
| 26 | }, | ||
| 27 | organizations: [], | ||
| 28 | organizationMemberships: [], | ||
| 29 | workspaces: [ | ||
| 30 | { | ||
| 31 | id: "ws_local", | ||
| 32 | organizationId: null, | ||
| 33 | name: "Local Workspace", | ||
| 34 | createdByAccountId: null, | ||
| 35 | createdAt: Date.now(), | ||
| 36 | updatedAt: Date.now(), | ||
| 37 | }, | ||
| 38 | ], | ||
| 39 | sources: [], | ||
| 40 | toolArtifacts: [], | ||
| 41 | credentialBindings: [], | ||
| 42 | oauthTokens: [], | ||
| 43 | policies: [], | ||
| 44 | approvals: [], | ||
| 45 | taskRuns: [], | ||
| 46 | storageInstances: [], | ||
| 47 | syncStates: [], | ||
| 48 | }) as unknown as LocalStateSnapshot; | ||
| 49 | |||
| 50 | describe("PM storage service", () => { | ||
| 51 | it.effect("opens, inspects, queries, and removes storage instances", () => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const stateRootDir = yield* Effect.promise(() => | ||
| 54 | mkdtemp(path.join(tmpdir(), "executor-v2-pm-storage-")), | ||
| 55 | ); | ||
| 56 | |||
| 57 | let snapshot: LocalStateSnapshot = createTestSnapshot(); | ||
| 58 | |||
| 59 | const localStateStore: LocalStateStore = { | ||
| 60 | getSnapshot: () => Effect.succeed(Option.some(snapshot)), | ||
| 61 | writeSnapshot: (nextSnapshot) => | ||
| 62 | Effect.sync(() => { | ||
| 63 | snapshot = nextSnapshot; | ||
| 64 | }), | ||
| 65 | readEvents: () => Effect.succeed([]), | ||
| 66 | appendEvents: () => Effect.void, | ||
| 67 | }; | ||
| 68 | |||
| 69 | const service = createPmStorageService(localStateStore, { | ||
| 70 | stateRootDir, | ||
| 71 | }); | ||
| 72 | |||
| 73 | const workspaceId = "ws_local" as WorkspaceId; | ||
| 74 | |||
| 75 | const storageInstance = yield* service.openStorageInstance({ | ||
| 76 | workspaceId, | ||
| 77 | payload: { | ||
| 78 | scopeType: "workspace", | ||
| 79 | durability: "ephemeral", | ||
| 80 | ttlHours: 24, | ||
| 81 | }, | ||
| 82 | }); | ||
| 83 | |||
| 84 | expect(storageInstance.status).toBe("active"); | ||
| 85 | |||
| 86 | const listed = yield* service.listStorageInstances(workspaceId); | ||
| 87 | expect(listed).toHaveLength(1); | ||
| 88 | |||
| 89 | const directoryBeforeWrite = yield* service.listStorageDirectory({ | ||
| 90 | workspaceId, | ||
| 91 | storageInstanceId: storageInstance.id, | ||
| 92 | payload: { | ||
| 93 | path: "/", | ||
| 94 | }, | ||
| 95 | }); | ||
| 96 | expect(directoryBeforeWrite.entries).toHaveLength(0); | ||
| 97 | |||
| 98 | const storageFsPath = path.resolve( | ||
| 99 | stateRootDir, | ||
| 100 | "storage", | ||
| 101 | storageInstance.id, | ||
| 102 | "fs", | ||
| 103 | "hello.txt", | ||
| 104 | ); | ||
| 105 | |||
| 106 | yield* Effect.promise(() => | ||
| 107 | writeFile(storageFsPath, "hello from storage", "utf8"), | ||
| 108 | ); | ||
| 109 | |||
| 110 | const directoryAfterWrite = yield* service.listStorageDirectory({ | ||
| 111 | workspaceId, | ||
| 112 | storageInstanceId: storageInstance.id, | ||
| 113 | payload: { | ||
| 114 | path: "/", | ||
| 115 | }, | ||
| 116 | }); | ||
| 117 | expect(directoryAfterWrite.entries.some((entry) => entry.name === "hello.txt")).toBe( | ||
| 118 | true, | ||
| 119 | ); | ||
| 120 | |||
| 121 | const filePreview = yield* service.readStorageFile({ | ||
| 122 | workspaceId, | ||
| 123 | storageInstanceId: storageInstance.id, | ||
| 124 | payload: { | ||
| 125 | path: "/hello.txt", | ||
| 126 | encoding: "utf8", | ||
| 127 | }, | ||
| 128 | }); | ||
| 129 | expect(filePreview.content).toBe("hello from storage"); | ||
| 130 | |||
| 131 | const kvResult = yield* service.listStorageKv({ | ||
| 132 | workspaceId, | ||
| 133 | storageInstanceId: storageInstance.id, | ||
| 134 | payload: { | ||
| 135 | prefix: "", | ||
| 136 | limit: 20, | ||
| 137 | }, | ||
| 138 | }); | ||
| 139 | expect(kvResult.items).toHaveLength(0); | ||
| 140 | |||
| 141 | const closedStorage = yield* service.closeStorageInstance({ | ||
| 142 | workspaceId, | ||
| 143 | storageInstanceId: storageInstance.id, | ||
| 144 | }); | ||
| 145 | expect(closedStorage.status).toBe("closed"); | ||
| 146 | |||
| 147 | const removed = yield* service.removeStorageInstance({ | ||
| 148 | workspaceId, | ||
| 149 | storageInstanceId: storageInstance.id, | ||
| 150 | }); | ||
| 151 | expect(removed.removed).toBe(true); | ||
| 152 | |||
| 153 | const listedAfterRemove = yield* service.listStorageInstances(workspaceId); | ||
| 154 | expect(listedAfterRemove).toHaveLength(0); | ||
| 155 | |||
| 156 | yield* Effect.promise(() => | ||
| 157 | rm(stateRootDir, { recursive: true, force: true }), | ||
| 158 | ); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | }); | ||
| @@ -0,0 +1,940 @@ | |||
| 1 | import { | ||
| 2 | makeControlPlaneStorageService, | ||
| 3 | type ControlPlaneStorageServiceShape, | ||
| 4 | } from "@executor-v2/management-api"; | ||
| 5 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 6 | import { | ||
| 7 | type LocalStateSnapshot, | ||
| 8 | type LocalStateStore, | ||
| 9 | type LocalStateStoreError, | ||
| 10 | } from "@executor-v2/persistence-local"; | ||
| 11 | import { | ||
| 12 | type OrganizationId, | ||
| 13 | type StorageInstance, | ||
| 14 | type WorkspaceId, | ||
| 15 | } from "@executor-v2/schema"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Option from "effect/Option"; | ||
| 18 | import { | ||
| 19 | mkdir, | ||
| 20 | readdir, | ||
| 21 | readFile, | ||
| 22 | rm, | ||
| 23 | stat, | ||
| 24 | writeFile, | ||
| 25 | } from "node:fs/promises"; | ||
| 26 | import * as path from "node:path"; | ||
| 27 | |||
| 28 | const DEFAULT_EPHEMERAL_TTL_HOURS = 24; | ||
| 29 | const DEFAULT_KV_LIMIT = 100; | ||
| 30 | const MAX_KV_LIMIT = 1000; | ||
| 31 | const DEFAULT_SQL_MAX_ROWS = 200; | ||
| 32 | const MAX_SQL_MAX_ROWS = 5000; | ||
| 33 | const MILLIS_PER_HOUR = 3_600_000; | ||
| 34 | const STORAGE_ROOT_DIRECTORY = "storage"; | ||
| 35 | const STORAGE_FS_DIRECTORY = "fs"; | ||
| 36 | const STORAGE_KV_FILE = "kv-store.json"; | ||
| 37 | const STORAGE_SQLITE_FILE = "storage.sqlite"; | ||
| 38 | |||
| 39 | type SqliteDatabaseInstance = { | ||
| 40 | exec: (sql: string) => void; | ||
| 41 | query: (sql: string) => { | ||
| 42 | all: () => Array<Record<string, unknown>>; | ||
| 43 | }; | ||
| 44 | run: (sql: string) => unknown; | ||
| 45 | close: () => void; | ||
| 46 | }; | ||
| 47 | |||
| 48 | type SqliteDatabaseConstructor = new ( | ||
| 49 | filename: string, | ||
| 50 | options?: { | ||
| 51 | create?: boolean; | ||
| 52 | }, | ||
| 53 | ) => SqliteDatabaseInstance; | ||
| 54 | |||
| 55 | const loadSqliteDatabase = async (): Promise<SqliteDatabaseConstructor | null> => { | ||
| 56 | try { | ||
| 57 | const sqliteModule = await import("bun:sqlite"); | ||
| 58 | return sqliteModule.Database as SqliteDatabaseConstructor; | ||
| 59 | } catch { | ||
| 60 | return null; | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | |||
| 64 | const toSourceStoreError = ( | ||
| 65 | operation: string, | ||
| 66 | message: string, | ||
| 67 | details: string | null, | ||
| 68 | ): SourceStoreError => | ||
| 69 | new SourceStoreError({ | ||
| 70 | operation, | ||
| 71 | backend: "local-file", | ||
| 72 | location: "snapshot.json", | ||
| 73 | message, | ||
| 74 | reason: null, | ||
| 75 | details, | ||
| 76 | }); | ||
| 77 | |||
| 78 | const toSourceStoreErrorFromLocalState = ( | ||
| 79 | operation: string, | ||
| 80 | error: LocalStateStoreError, | ||
| 81 | ): SourceStoreError => | ||
| 82 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 83 | |||
| 84 | const toSourceStoreErrorFromCause = ( | ||
| 85 | operation: string, | ||
| 86 | cause: unknown, | ||
| 87 | details?: string, | ||
| 88 | ): SourceStoreError => | ||
| 89 | toSourceStoreError( | ||
| 90 | operation, | ||
| 91 | cause instanceof Error ? cause.message : String(cause), | ||
| 92 | details ?? null, | ||
| 93 | ); | ||
| 94 | |||
| 95 | const resolveWorkspaceOrganizationId = ( | ||
| 96 | snapshot: LocalStateSnapshot, | ||
| 97 | workspaceId: WorkspaceId, | ||
| 98 | ): OrganizationId => { | ||
| 99 | const workspace = snapshot.workspaces.find((item) => item.id === workspaceId); | ||
| 100 | const organizationId = workspace?.organizationId; | ||
| 101 | |||
| 102 | if (organizationId !== null && organizationId !== undefined) { | ||
| 103 | return organizationId; | ||
| 104 | } | ||
| 105 | |||
| 106 | return (`org_${workspaceId}`) as OrganizationId; | ||
| 107 | }; | ||
| 108 | |||
| 109 | const canAccessStorageInstance = ( | ||
| 110 | instance: StorageInstance, | ||
| 111 | workspaceId: WorkspaceId, | ||
| 112 | organizationId: OrganizationId, | ||
| 113 | ): boolean => | ||
| 114 | instance.workspaceId === workspaceId | ||
| 115 | || (instance.workspaceId === null && instance.organizationId === organizationId); | ||
| 116 | |||
| 117 | const sortStorageInstances = ( | ||
| 118 | storageInstances: ReadonlyArray<StorageInstance>, | ||
| 119 | ): Array<StorageInstance> => | ||
| 120 | [...storageInstances].sort((left, right) => { | ||
| 121 | if (left.updatedAt !== right.updatedAt) { | ||
| 122 | return right.updatedAt - left.updatedAt; | ||
| 123 | } | ||
| 124 | |||
| 125 | return left.id.localeCompare(right.id); | ||
| 126 | }); | ||
| 127 | |||
| 128 | const replaceStorageInstanceAt = ( | ||
| 129 | snapshot: LocalStateSnapshot, | ||
| 130 | index: number, | ||
| 131 | storageInstance: StorageInstance, | ||
| 132 | ): LocalStateSnapshot => { | ||
| 133 | const next = [...snapshot.storageInstances]; | ||
| 134 | next[index] = storageInstance; | ||
| 135 | |||
| 136 | return { | ||
| 137 | ...snapshot, | ||
| 138 | generatedAt: Date.now(), | ||
| 139 | storageInstances: next, | ||
| 140 | }; | ||
| 141 | }; | ||
| 142 | |||
| 143 | const appendStorageInstance = ( | ||
| 144 | snapshot: LocalStateSnapshot, | ||
| 145 | storageInstance: StorageInstance, | ||
| 146 | ): LocalStateSnapshot => ({ | ||
| 147 | ...snapshot, | ||
| 148 | generatedAt: Date.now(), | ||
| 149 | storageInstances: [...snapshot.storageInstances, storageInstance], | ||
| 150 | }); | ||
| 151 | |||
| 152 | const removeStorageInstanceById = ( | ||
| 153 | snapshot: LocalStateSnapshot, | ||
| 154 | input: { | ||
| 155 | workspaceId: WorkspaceId; | ||
| 156 | organizationId: OrganizationId; | ||
| 157 | storageInstanceId: StorageInstance["id"]; | ||
| 158 | }, | ||
| 159 | ): { snapshot: LocalStateSnapshot; removed: boolean } => { | ||
| 160 | let removed = false; | ||
| 161 | |||
| 162 | const next = snapshot.storageInstances.filter((instance) => { | ||
| 163 | const matches = | ||
| 164 | instance.id === input.storageInstanceId | ||
| 165 | && canAccessStorageInstance(instance, input.workspaceId, input.organizationId); | ||
| 166 | |||
| 167 | if (matches) { | ||
| 168 | removed = true; | ||
| 169 | return false; | ||
| 170 | } | ||
| 171 | |||
| 172 | return true; | ||
| 173 | }); | ||
| 174 | |||
| 175 | if (!removed) { | ||
| 176 | return { | ||
| 177 | snapshot, | ||
| 178 | removed: false, | ||
| 179 | }; | ||
| 180 | } | ||
| 181 | |||
| 182 | return { | ||
| 183 | removed: true, | ||
| 184 | snapshot: { | ||
| 185 | ...snapshot, | ||
| 186 | generatedAt: Date.now(), | ||
| 187 | storageInstances: next, | ||
| 188 | }, | ||
| 189 | }; | ||
| 190 | }; | ||
| 191 | |||
| 192 | const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string => | ||
| 193 | path.resolve(stateRootDir, STORAGE_ROOT_DIRECTORY, storageInstanceId); | ||
| 194 | |||
| 195 | const storageInstanceFsRootPath = ( | ||
| 196 | stateRootDir: string, | ||
| 197 | storageInstanceId: string, | ||
| 198 | ): string => | ||
| 199 | path.resolve( | ||
| 200 | storageInstanceRootPath(stateRootDir, storageInstanceId), | ||
| 201 | STORAGE_FS_DIRECTORY, | ||
| 202 | ); | ||
| 203 | |||
| 204 | const storageInstanceKvPath = (stateRootDir: string, storageInstanceId: string): string => | ||
| 205 | path.resolve( | ||
| 206 | storageInstanceRootPath(stateRootDir, storageInstanceId), | ||
| 207 | STORAGE_KV_FILE, | ||
| 208 | ); | ||
| 209 | |||
| 210 | const storageInstanceSqlitePath = ( | ||
| 211 | stateRootDir: string, | ||
| 212 | storageInstanceId: string, | ||
| 213 | ): string => | ||
| 214 | path.resolve( | ||
| 215 | storageInstanceRootPath(stateRootDir, storageInstanceId), | ||
| 216 | STORAGE_SQLITE_FILE, | ||
| 217 | ); | ||
| 218 | |||
| 219 | const initializeStorageInstanceFiles = async ( | ||
| 220 | stateRootDir: string, | ||
| 221 | storageInstanceId: string, | ||
| 222 | ): Promise<void> => { | ||
| 223 | const rootPath = storageInstanceRootPath(stateRootDir, storageInstanceId); | ||
| 224 | const fsRootPath = storageInstanceFsRootPath(stateRootDir, storageInstanceId); | ||
| 225 | const kvPath = storageInstanceKvPath(stateRootDir, storageInstanceId); | ||
| 226 | const sqlitePath = storageInstanceSqlitePath(stateRootDir, storageInstanceId); | ||
| 227 | |||
| 228 | await mkdir(rootPath, { recursive: true }); | ||
| 229 | await mkdir(fsRootPath, { recursive: true }); | ||
| 230 | |||
| 231 | const kvJson = JSON.stringify({}, null, 2); | ||
| 232 | await writeFile(kvPath, kvJson, { encoding: "utf8" }); | ||
| 233 | |||
| 234 | const SqliteDatabase = await loadSqliteDatabase(); | ||
| 235 | if (SqliteDatabase === null) { | ||
| 236 | await writeFile(sqlitePath, "", { encoding: "utf8" }); | ||
| 237 | return; | ||
| 238 | } | ||
| 239 | |||
| 240 | const db = new SqliteDatabase(sqlitePath, { create: true }); | ||
| 241 | try { | ||
| 242 | db.exec( | ||
| 243 | "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);", | ||
| 244 | ); | ||
| 245 | } finally { | ||
| 246 | db.close(); | ||
| 247 | } | ||
| 248 | }; | ||
| 249 | |||
| 250 | const resolvePathWithinRoot = ( | ||
| 251 | rootPath: string, | ||
| 252 | requestedPath: string, | ||
| 253 | ): { | ||
| 254 | normalizedPath: string; | ||
| 255 | absolutePath: string; | ||
| 256 | } => { | ||
| 257 | const trimmed = requestedPath.trim(); | ||
| 258 | const normalizedPath = path.posix.normalize( | ||
| 259 | trimmed.length > 0 | ||
| 260 | ? (trimmed.startsWith("/") ? trimmed : `/${trimmed}`) | ||
| 261 | : "/", | ||
| 262 | ); | ||
| 263 | |||
| 264 | const relativePath = normalizedPath.replace(/^\/+/, ""); | ||
| 265 | const absolutePath = path.resolve(rootPath, relativePath); | ||
| 266 | |||
| 267 | if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) { | ||
| 268 | throw new Error("Path escapes storage root"); | ||
| 269 | } | ||
| 270 | |||
| 271 | return { | ||
| 272 | normalizedPath, | ||
| 273 | absolutePath, | ||
| 274 | }; | ||
| 275 | }; | ||
| 276 | |||
| 277 | const toStorageEntryPath = (absolutePath: string, fsRootPath: string): string => { | ||
| 278 | const relativePath = path.relative(fsRootPath, absolutePath); | ||
| 279 | return `/${relativePath.split(path.sep).join("/")}`; | ||
| 280 | }; | ||
| 281 | |||
| 282 | const readKvStore = async ( | ||
| 283 | kvPath: string, | ||
| 284 | ): Promise<Record<string, unknown>> => { | ||
| 285 | const raw = await readFile(kvPath, "utf8"); | ||
| 286 | const parsed = JSON.parse(raw) as unknown; | ||
| 287 | |||
| 288 | if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { | ||
| 289 | return parsed as Record<string, unknown>; | ||
| 290 | } | ||
| 291 | |||
| 292 | return {}; | ||
| 293 | }; | ||
| 294 | |||
| 295 | const findStorageInstance = ( | ||
| 296 | snapshot: LocalStateSnapshot, | ||
| 297 | input: { | ||
| 298 | workspaceId: WorkspaceId; | ||
| 299 | storageInstanceId: StorageInstance["id"]; | ||
| 300 | }, | ||
| 301 | ): { | ||
| 302 | storageInstance: StorageInstance; | ||
| 303 | storageInstanceIndex: number; | ||
| 304 | } | null => { | ||
| 305 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | ||
| 306 | const storageInstanceIndex = snapshot.storageInstances.findIndex((instance) => | ||
| 307 | instance.id === input.storageInstanceId | ||
| 308 | && canAccessStorageInstance(instance, input.workspaceId, organizationId) | ||
| 309 | ); | ||
| 310 | |||
| 311 | if (storageInstanceIndex < 0) { | ||
| 312 | return null; | ||
| 313 | } | ||
| 314 | |||
| 315 | const storageInstance = snapshot.storageInstances[storageInstanceIndex]; | ||
| 316 | if (!storageInstance) { | ||
| 317 | return null; | ||
| 318 | } | ||
| 319 | |||
| 320 | return { | ||
| 321 | storageInstance, | ||
| 322 | storageInstanceIndex, | ||
| 323 | }; | ||
| 324 | }; | ||
| 325 | |||
| 326 | const touchStorageInstance = ( | ||
| 327 | snapshot: LocalStateSnapshot, | ||
| 328 | storageInstanceIndex: number, | ||
| 329 | ): { | ||
| 330 | snapshot: LocalStateSnapshot; | ||
| 331 | storageInstance: StorageInstance; | ||
| 332 | } => { | ||
| 333 | const existing = snapshot.storageInstances[storageInstanceIndex]; | ||
| 334 | const now = Date.now(); | ||
| 335 | |||
| 336 | const touchedStorageInstance: StorageInstance = { | ||
| 337 | ...existing, | ||
| 338 | updatedAt: now, | ||
| 339 | lastSeenAt: now, | ||
| 340 | }; | ||
| 341 | |||
| 342 | return { | ||
| 343 | storageInstance: touchedStorageInstance, | ||
| 344 | snapshot: replaceStorageInstanceAt(snapshot, storageInstanceIndex, touchedStorageInstance), | ||
| 345 | }; | ||
| 346 | }; | ||
| 347 | |||
| 348 | export const createPmStorageService = ( | ||
| 349 | localStateStore: LocalStateStore, | ||
| 350 | options: { | ||
| 351 | stateRootDir: string; | ||
| 352 | }, | ||
| 353 | ): ControlPlaneStorageServiceShape => | ||
| 354 | makeControlPlaneStorageService({ | ||
| 355 | listStorageInstances: (workspaceId) => | ||
| 356 | Effect.gen(function* () { | ||
| 357 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 358 | Effect.mapError((error) => | ||
| 359 | toSourceStoreErrorFromLocalState("storage.list", error), | ||
| 360 | ), | ||
| 361 | ); | ||
| 362 | |||
| 363 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 364 | if (snapshot === null) { | ||
| 365 | return []; | ||
| 366 | } | ||
| 367 | |||
| 368 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | ||
| 369 | |||
| 370 | return sortStorageInstances( | ||
| 371 | snapshot.storageInstances.filter((instance) => | ||
| 372 | canAccessStorageInstance(instance, workspaceId, organizationId) | ||
| 373 | ), | ||
| 374 | ); | ||
| 375 | }), | ||
| 376 | |||
| 377 | openStorageInstance: (input) => | ||
| 378 | Effect.gen(function* () { | ||
| 379 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 380 | Effect.mapError((error) => | ||
| 381 | toSourceStoreErrorFromLocalState("storage.open", error), | ||
| 382 | ), | ||
| 383 | ); | ||
| 384 | |||
| 385 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 386 | if (snapshot === null) { | ||
| 387 | return yield* toSourceStoreError( | ||
| 388 | "storage.open", | ||
| 389 | "Storage snapshot not found", | ||
| 390 | `workspace=${input.workspaceId}`, | ||
| 391 | ); | ||
| 392 | } | ||
| 393 | |||
| 394 | if (input.payload.scopeType === "account" && input.payload.accountId === undefined) { | ||
| 395 | return yield* toSourceStoreError( | ||
| 396 | "storage.open", | ||
| 397 | "Account scope storage requires accountId", | ||
| 398 | `workspace=${input.workspaceId}`, | ||
| 399 | ); | ||
| 400 | } | ||
| 401 | |||
| 402 | const now = Date.now(); | ||
| 403 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | ||
| 404 | const storageInstanceId = | ||
| 405 | `storage_${crypto.randomUUID()}` as StorageInstance["id"]; | ||
| 406 | const ttlHours = | ||
| 407 | input.payload.ttlHours !== undefined && Number.isFinite(input.payload.ttlHours) | ||
| 408 | ? Math.max(1, Math.floor(input.payload.ttlHours)) | ||
| 409 | : DEFAULT_EPHEMERAL_TTL_HOURS; | ||
| 410 | |||
| 411 | yield* Effect.tryPromise({ | ||
| 412 | try: () => initializeStorageInstanceFiles(options.stateRootDir, storageInstanceId), | ||
| 413 | catch: (cause) => | ||
| 414 | toSourceStoreErrorFromCause( | ||
| 415 | "storage.open_initialize", | ||
| 416 | cause, | ||
| 417 | `storageInstance=${storageInstanceId}`, | ||
| 418 | ), | ||
| 419 | }); | ||
| 420 | |||
| 421 | const nextStorageInstance: StorageInstance = { | ||
| 422 | id: storageInstanceId, | ||
| 423 | scopeType: input.payload.scopeType, | ||
| 424 | durability: input.payload.durability, | ||
| 425 | status: "active", | ||
| 426 | provider: input.payload.provider ?? "agentfs-local", | ||
| 427 | backendKey: `local:${storageInstanceId}`, | ||
| 428 | organizationId, | ||
| 429 | workspaceId: | ||
| 430 | input.payload.scopeType === "workspace" || input.payload.scopeType === "scratch" | ||
| 431 | ? input.workspaceId | ||
| 432 | : null, | ||
| 433 | accountId: | ||
| 434 | input.payload.scopeType === "account" | ||
| 435 | ? (input.payload.accountId ?? null) | ||
| 436 | : null, | ||
| 437 | createdByAccountId: input.payload.accountId ?? null, | ||
| 438 | purpose: | ||
| 439 | input.payload.purpose !== undefined && input.payload.purpose.trim().length > 0 | ||
| 440 | ? input.payload.purpose.trim() | ||
| 441 | : null, | ||
| 442 | sizeBytes: null, | ||
| 443 | fileCount: null, | ||
| 444 | createdAt: now, | ||
| 445 | updatedAt: now, | ||
| 446 | lastSeenAt: now, | ||
| 447 | closedAt: null, | ||
| 448 | expiresAt: | ||
| 449 | input.payload.durability === "ephemeral" | ||
| 450 | ? now + ttlHours * MILLIS_PER_HOUR | ||
| 451 | : null, | ||
| 452 | }; | ||
| 453 | |||
| 454 | const nextSnapshot = appendStorageInstance(snapshot, nextStorageInstance); | ||
| 455 | |||
| 456 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 457 | Effect.mapError((error) => | ||
| 458 | toSourceStoreErrorFromLocalState("storage.open_write", error), | ||
| 459 | ), | ||
| 460 | ); | ||
| 461 | |||
| 462 | return nextStorageInstance; | ||
| 463 | }), | ||
| 464 | |||
| 465 | closeStorageInstance: (input) => | ||
| 466 | Effect.gen(function* () { | ||
| 467 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 468 | Effect.mapError((error) => | ||
| 469 | toSourceStoreErrorFromLocalState("storage.close", error), | ||
| 470 | ), | ||
| 471 | ); | ||
| 472 | |||
| 473 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 474 | if (snapshot === null) { | ||
| 475 | return yield* toSourceStoreError( | ||
| 476 | "storage.close", | ||
| 477 | "Storage snapshot not found", | ||
| 478 | `workspace=${input.workspaceId}`, | ||
| 479 | ); | ||
| 480 | } | ||
| 481 | |||
| 482 | const found = findStorageInstance(snapshot, { | ||
| 483 | workspaceId: input.workspaceId, | ||
| 484 | storageInstanceId: input.storageInstanceId, | ||
| 485 | }); | ||
| 486 | |||
| 487 | if (found === null) { | ||
| 488 | return yield* toSourceStoreError( | ||
| 489 | "storage.close", | ||
| 490 | "Storage instance not found", | ||
| 491 | `workspace=${input.workspaceId} id=${input.storageInstanceId}`, | ||
| 492 | ); | ||
| 493 | } | ||
| 494 | |||
| 495 | const now = Date.now(); | ||
| 496 | |||
| 497 | const nextStorageInstance: StorageInstance = { | ||
| 498 | ...found.storageInstance, | ||
| 499 | status: "closed", | ||
| 500 | updatedAt: now, | ||
| 501 | lastSeenAt: now, | ||
| 502 | closedAt: found.storageInstance.closedAt ?? now, | ||
| 503 | }; | ||
| 504 | |||
| 505 | const nextSnapshot = replaceStorageInstanceAt( | ||
| 506 | snapshot, | ||
| 507 | found.storageInstanceIndex, | ||
| 508 | nextStorageInstance, | ||
| 509 | ); | ||
| 510 | |||
| 511 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 512 | Effect.mapError((error) => | ||
| 513 | toSourceStoreErrorFromLocalState("storage.close_write", error), | ||
| 514 | ), | ||
| 515 | ); | ||
| 516 | |||
| 517 | return nextStorageInstance; | ||
| 518 | }), | ||
| 519 | |||
| 520 | removeStorageInstance: (input) => | ||
| 521 | Effect.gen(function* () { | ||
| 522 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 523 | Effect.mapError((error) => | ||
| 524 | toSourceStoreErrorFromLocalState("storage.remove", error), | ||
| 525 | ), | ||
| 526 | ); | ||
| 527 | |||
| 528 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 529 | if (snapshot === null) { | ||
| 530 | return { | ||
| 531 | removed: false, | ||
| 532 | }; | ||
| 533 | } | ||
| 534 | |||
| 535 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | ||
| 536 | |||
| 537 | const next = removeStorageInstanceById(snapshot, { | ||
| 538 | workspaceId: input.workspaceId, | ||
| 539 | organizationId, | ||
| 540 | storageInstanceId: input.storageInstanceId, | ||
| 541 | }); | ||
| 542 | |||
| 543 | if (!next.removed) { | ||
| 544 | return { | ||
| 545 | removed: false, | ||
| 546 | }; | ||
| 547 | } | ||
| 548 | |||
| 549 | yield* Effect.tryPromise({ | ||
| 550 | try: () => | ||
| 551 | rm( | ||
| 552 | storageInstanceRootPath(options.stateRootDir, input.storageInstanceId), | ||
| 553 | { | ||
| 554 | recursive: true, | ||
| 555 | force: true, | ||
| 556 | }, | ||
| 557 | ), | ||
| 558 | catch: (cause) => | ||
| 559 | toSourceStoreErrorFromCause( | ||
| 560 | "storage.remove_files", | ||
| 561 | cause, | ||
| 562 | `storageInstance=${input.storageInstanceId}`, | ||
| 563 | ), | ||
| 564 | }); | ||
| 565 | |||
| 566 | yield* localStateStore.writeSnapshot(next.snapshot).pipe( | ||
| 567 | Effect.mapError((error) => | ||
| 568 | toSourceStoreErrorFromLocalState("storage.remove_write", error), | ||
| 569 | ), | ||
| 570 | ); | ||
| 571 | |||
| 572 | return { | ||
| 573 | removed: true, | ||
| 574 | }; | ||
| 575 | }), | ||
| 576 | |||
| 577 | listStorageDirectory: (input) => | ||
| 578 | Effect.gen(function* () { | ||
| 579 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 580 | Effect.mapError((error) => | ||
| 581 | toSourceStoreErrorFromLocalState("storage.listDirectory", error), | ||
| 582 | ), | ||
| 583 | ); | ||
| 584 | |||
| 585 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 586 | if (snapshot === null) { | ||
| 587 | return yield* toSourceStoreError( | ||
| 588 | "storage.listDirectory", | ||
| 589 | "Storage snapshot not found", | ||
| 590 | `workspace=${input.workspaceId}`, | ||
| 591 | ); | ||
| 592 | } | ||
| 593 | |||
| 594 | const found = findStorageInstance(snapshot, { | ||
| 595 | workspaceId: input.workspaceId, | ||
| 596 | storageInstanceId: input.storageInstanceId, | ||
| 597 | }); | ||
| 598 | |||
| 599 | if (found === null) { | ||
| 600 | return yield* toSourceStoreError( | ||
| 601 | "storage.listDirectory", | ||
| 602 | "Storage instance not found", | ||
| 603 | `workspace=${input.workspaceId} id=${input.storageInstanceId}`, | ||
| 604 | ); | ||
| 605 | } | ||
| 606 | |||
| 607 | const fsRootPath = storageInstanceFsRootPath( | ||
| 608 | options.stateRootDir, | ||
| 609 | found.storageInstance.id, | ||
| 610 | ); | ||
| 611 | |||
| 612 | const result = yield* Effect.tryPromise({ | ||
| 613 | try: async () => { | ||
| 614 | await mkdir(fsRootPath, { recursive: true }); | ||
| 615 | |||
| 616 | const resolved = resolvePathWithinRoot( | ||
| 617 | fsRootPath, | ||
| 618 | input.payload.path, | ||
| 619 | ); | ||
| 620 | |||
| 621 | const entries = await readdir(resolved.absolutePath, { | ||
| 622 | withFileTypes: true, | ||
| 623 | }); | ||
| 624 | |||
| 625 | const mapped = await Promise.all( | ||
| 626 | entries.map(async (entry) => { | ||
| 627 | const entryAbsolutePath = path.resolve( | ||
| 628 | resolved.absolutePath, | ||
| 629 | entry.name, | ||
| 630 | ); | ||
| 631 | const entryStat = await stat(entryAbsolutePath); | ||
| 632 | |||
| 633 | return { | ||
| 634 | name: entry.name, | ||
| 635 | path: toStorageEntryPath(entryAbsolutePath, fsRootPath), | ||
| 636 | kind: entry.isDirectory() ? "directory" : "file", | ||
| 637 | sizeBytes: entry.isDirectory() ? null : entryStat.size, | ||
| 638 | updatedAt: entryStat.mtimeMs, | ||
| 639 | } as const; | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | |||
| 643 | mapped.sort((left, right) => { | ||
| 644 | if (left.kind !== right.kind) { | ||
| 645 | return left.kind === "directory" ? -1 : 1; | ||
| 646 | } | ||
| 647 | |||
| 648 | return left.name.localeCompare(right.name); | ||
| 649 | }); | ||
| 650 | |||
| 651 | return { | ||
| 652 | path: resolved.normalizedPath, | ||
| 653 | entries: mapped, | ||
| 654 | }; | ||
| 655 | }, | ||
| 656 | catch: (cause) => | ||
| 657 | toSourceStoreErrorFromCause( | ||
| 658 | "storage.listDirectory_read", | ||
| 659 | cause, | ||
| 660 | `storageInstance=${found.storageInstance.id} path=${input.payload.path}`, | ||
| 661 | ), | ||
| 662 | }); | ||
| 663 | |||
| 664 | const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | ||
| 665 | |||
| 666 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 667 | Effect.mapError((error) => | ||
| 668 | toSourceStoreErrorFromLocalState( | ||
| 669 | "storage.listDirectory_touch", | ||
| 670 | error, | ||
| 671 | ), | ||
| 672 | ), | ||
| 673 | ); | ||
| 674 | |||
| 675 | return result; | ||
| 676 | }), | ||
| 677 | |||
| 678 | readStorageFile: (input) => | ||
| 679 | Effect.gen(function* () { | ||
| 680 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 681 | Effect.mapError((error) => | ||
| 682 | toSourceStoreErrorFromLocalState("storage.readFile", error), | ||
| 683 | ), | ||
| 684 | ); | ||
| 685 | |||
| 686 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 687 | if (snapshot === null) { | ||
| 688 | return yield* toSourceStoreError( | ||
| 689 | "storage.readFile", | ||
| 690 | "Storage snapshot not found", | ||
| 691 | `workspace=${input.workspaceId}`, | ||
| 692 | ); | ||
| 693 | } | ||
| 694 | |||
| 695 | const found = findStorageInstance(snapshot, { | ||
| 696 | workspaceId: input.workspaceId, | ||
| 697 | storageInstanceId: input.storageInstanceId, | ||
| 698 | }); | ||
| 699 | |||
| 700 | if (found === null) { | ||
| 701 | return yield* toSourceStoreError( | ||
| 702 | "storage.readFile", | ||
| 703 | "Storage instance not found", | ||
| 704 | `workspace=${input.workspaceId} id=${input.storageInstanceId}`, | ||
| 705 | ); | ||
| 706 | } | ||
| 707 | |||
| 708 | const fsRootPath = storageInstanceFsRootPath( | ||
| 709 | options.stateRootDir, | ||
| 710 | found.storageInstance.id, | ||
| 711 | ); | ||
| 712 | const encoding = input.payload.encoding ?? "utf8"; | ||
| 713 | |||
| 714 | const result = yield* Effect.tryPromise({ | ||
| 715 | try: async () => { | ||
| 716 | await mkdir(fsRootPath, { recursive: true }); | ||
| 717 | |||
| 718 | const resolved = resolvePathWithinRoot(fsRootPath, input.payload.path); | ||
| 719 | const entryStat = await stat(resolved.absolutePath); | ||
| 720 | |||
| 721 | if (entryStat.isDirectory()) { | ||
| 722 | throw new Error("Cannot read a directory"); | ||
| 723 | } | ||
| 724 | |||
| 725 | const contentBuffer = await readFile(resolved.absolutePath); | ||
| 726 | |||
| 727 | return { | ||
| 728 | path: resolved.normalizedPath, | ||
| 729 | encoding, | ||
| 730 | content: | ||
| 731 | encoding === "base64" | ||
| 732 | ? contentBuffer.toString("base64") | ||
| 733 | : contentBuffer.toString("utf8"), | ||
| 734 | bytes: contentBuffer.byteLength, | ||
| 735 | }; | ||
| 736 | }, | ||
| 737 | catch: (cause) => | ||
| 738 | toSourceStoreErrorFromCause( | ||
| 739 | "storage.readFile_read", | ||
| 740 | cause, | ||
| 741 | `storageInstance=${found.storageInstance.id} path=${input.payload.path}`, | ||
| 742 | ), | ||
| 743 | }); | ||
| 744 | |||
| 745 | const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | ||
| 746 | |||
| 747 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 748 | Effect.mapError((error) => | ||
| 749 | toSourceStoreErrorFromLocalState("storage.readFile_touch", error), | ||
| 750 | ), | ||
| 751 | ); | ||
| 752 | |||
| 753 | return result; | ||
| 754 | }), | ||
| 755 | |||
| 756 | listStorageKv: (input) => | ||
| 757 | Effect.gen(function* () { | ||
| 758 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 759 | Effect.mapError((error) => | ||
| 760 | toSourceStoreErrorFromLocalState("storage.listKv", error), | ||
| 761 | ), | ||
| 762 | ); | ||
| 763 | |||
| 764 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 765 | if (snapshot === null) { | ||
| 766 | return yield* toSourceStoreError( | ||
| 767 | "storage.listKv", | ||
| 768 | "Storage snapshot not found", | ||
| 769 | `workspace=${input.workspaceId}`, | ||
| 770 | ); | ||
| 771 | } | ||
| 772 | |||
| 773 | const found = findStorageInstance(snapshot, { | ||
| 774 | workspaceId: input.workspaceId, | ||
| 775 | storageInstanceId: input.storageInstanceId, | ||
| 776 | }); | ||
| 777 | |||
| 778 | if (found === null) { | ||
| 779 | return yield* toSourceStoreError( | ||
| 780 | "storage.listKv", | ||
| 781 | "Storage instance not found", | ||
| 782 | `workspace=${input.workspaceId} id=${input.storageInstanceId}`, | ||
| 783 | ); | ||
| 784 | } | ||
| 785 | |||
| 786 | const kvPath = storageInstanceKvPath( | ||
| 787 | options.stateRootDir, | ||
| 788 | found.storageInstance.id, | ||
| 789 | ); | ||
| 790 | |||
| 791 | const result = yield* Effect.tryPromise({ | ||
| 792 | try: async () => { | ||
| 793 | const kvStore = await readKvStore(kvPath); | ||
| 794 | const prefix = input.payload.prefix ?? ""; | ||
| 795 | const requestedLimit = | ||
| 796 | input.payload.limit !== undefined && Number.isFinite(input.payload.limit) | ||
| 797 | ? Math.floor(input.payload.limit) | ||
| 798 | : DEFAULT_KV_LIMIT; | ||
| 799 | const limit = Math.max(1, Math.min(MAX_KV_LIMIT, requestedLimit)); | ||
| 800 | |||
| 801 | const items = Object.entries(kvStore) | ||
| 802 | .filter(([key]) => key.startsWith(prefix)) | ||
| 803 | .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) | ||
| 804 | .slice(0, limit) | ||
| 805 | .map(([key, value]) => ({ key, value })); | ||
| 806 | |||
| 807 | return { | ||
| 808 | items, | ||
| 809 | }; | ||
| 810 | }, | ||
| 811 | catch: (cause) => | ||
| 812 | toSourceStoreErrorFromCause( | ||
| 813 | "storage.listKv_read", | ||
| 814 | cause, | ||
| 815 | `storageInstance=${found.storageInstance.id}`, | ||
| 816 | ), | ||
| 817 | }); | ||
| 818 | |||
| 819 | const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | ||
| 820 | |||
| 821 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 822 | Effect.mapError((error) => | ||
| 823 | toSourceStoreErrorFromLocalState("storage.listKv_touch", error), | ||
| 824 | ), | ||
| 825 | ); | ||
| 826 | |||
| 827 | return result; | ||
| 828 | }), | ||
| 829 | |||
| 830 | queryStorageSql: (input) => | ||
| 831 | Effect.gen(function* () { | ||
| 832 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 833 | Effect.mapError((error) => | ||
| 834 | toSourceStoreErrorFromLocalState("storage.querySql", error), | ||
| 835 | ), | ||
| 836 | ); | ||
| 837 | |||
| 838 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 839 | if (snapshot === null) { | ||
| 840 | return yield* toSourceStoreError( | ||
| 841 | "storage.querySql", | ||
| 842 | "Storage snapshot not found", | ||
| 843 | `workspace=${input.workspaceId}`, | ||
| 844 | ); | ||
| 845 | } | ||
| 846 | |||
| 847 | const found = findStorageInstance(snapshot, { | ||
| 848 | workspaceId: input.workspaceId, | ||
| 849 | storageInstanceId: input.storageInstanceId, | ||
| 850 | }); | ||
| 851 | |||
| 852 | if (found === null) { | ||
| 853 | return yield* toSourceStoreError( | ||
| 854 | "storage.querySql", | ||
| 855 | "Storage instance not found", | ||
| 856 | `workspace=${input.workspaceId} id=${input.storageInstanceId}`, | ||
| 857 | ); | ||
| 858 | } | ||
| 859 | |||
| 860 | const sqlText = input.payload.sql.trim(); | ||
| 861 | if (sqlText.length === 0) { | ||
| 862 | return yield* toSourceStoreError( | ||
| 863 | "storage.querySql", | ||
| 864 | "SQL query is required", | ||
| 865 | `workspace=${input.workspaceId} id=${input.storageInstanceId}`, | ||
| 866 | ); | ||
| 867 | } | ||
| 868 | |||
| 869 | const sqlitePath = storageInstanceSqlitePath( | ||
| 870 | options.stateRootDir, | ||
| 871 | found.storageInstance.id, | ||
| 872 | ); | ||
| 873 | |||
| 874 | const result = yield* Effect.tryPromise({ | ||
| 875 | try: async () => { | ||
| 876 | await mkdir(path.dirname(sqlitePath), { recursive: true }); | ||
| 877 | |||
| 878 | const SqliteDatabase = await loadSqliteDatabase(); | ||
| 879 | if (SqliteDatabase === null) { | ||
| 880 | throw new Error("SQLite runtime is unavailable in this environment"); | ||
| 881 | } | ||
| 882 | |||
| 883 | const db = new SqliteDatabase(sqlitePath, { create: true }); | ||
| 884 | |||
| 885 | try { | ||
| 886 | db.exec( | ||
| 887 | "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);", | ||
| 888 | ); | ||
| 889 | |||
| 890 | const maxRows = | ||
| 891 | input.payload.maxRows !== undefined && Number.isFinite(input.payload.maxRows) | ||
| 892 | ? Math.max(1, Math.min(MAX_SQL_MAX_ROWS, Math.floor(input.payload.maxRows))) | ||
| 893 | : DEFAULT_SQL_MAX_ROWS; | ||
| 894 | |||
| 895 | try { | ||
| 896 | const statement = db.query(sqlText); | ||
| 897 | const rawRows = statement.all() as Array<Record<string, unknown>>; | ||
| 898 | const rows = rawRows.slice(0, maxRows); | ||
| 899 | const columns = | ||
| 900 | rows.length > 0 | ||
| 901 | ? Array.from(new Set(rows.flatMap((row) => Object.keys(row)))) | ||
| 902 | : []; | ||
| 903 | |||
| 904 | return { | ||
| 905 | rows, | ||
| 906 | columns, | ||
| 907 | rowCount: rows.length, | ||
| 908 | }; | ||
| 909 | } catch { | ||
| 910 | db.run(sqlText); | ||
| 911 | |||
| 912 | return { | ||
| 913 | rows: [], | ||
| 914 | columns: [], | ||
| 915 | rowCount: 0, | ||
| 916 | }; | ||
| 917 | } | ||
| 918 | } finally { | ||
| 919 | db.close(); | ||
| 920 | } | ||
| 921 | }, | ||
| 922 | catch: (cause) => | ||
| 923 | toSourceStoreErrorFromCause( | ||
| 924 | "storage.querySql_run", | ||
| 925 | cause, | ||
| 926 | `storageInstance=${found.storageInstance.id}`, | ||
| 927 | ), | ||
| 928 | }); | ||
| 929 | |||
| 930 | const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | ||
| 931 | |||
| 932 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 933 | Effect.mapError((error) => | ||
| 934 | toSourceStoreErrorFromLocalState("storage.querySql_touch", error), | ||
| 935 | ), | ||
| 936 | ); | ||
| 937 | |||
| 938 | return result; | ||
| 939 | }), | ||
| 940 | }); | ||
| @@ -0,0 +1,179 @@ | |||
| 1 | import { | ||
| 2 | SourceStoreError, | ||
| 3 | ToolArtifactStoreError, | ||
| 4 | type SourceStore, | ||
| 5 | type ToolArtifactStore, | ||
| 6 | } from "@executor-v2/persistence-ports"; | ||
| 7 | import { | ||
| 8 | makeControlPlaneToolsService, | ||
| 9 | type ControlPlaneToolsServiceShape, | ||
| 10 | type SourceToolSummary, | ||
| 11 | } from "@executor-v2/management-api"; | ||
| 12 | import { | ||
| 13 | OpenApiToolManifestSchema, | ||
| 14 | type Source, | ||
| 15 | type SourceId, | ||
| 16 | type ToolArtifact, | ||
| 17 | type WorkspaceId, | ||
| 18 | } from "@executor-v2/schema"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as ParseResult from "effect/ParseResult"; | ||
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | |||
| 24 | const decodeOpenApiToolManifest = Schema.decodeUnknown(OpenApiToolManifestSchema); | ||
| 25 | |||
| 26 | const toSourceStoreError = ( | ||
| 27 | operation: string, | ||
| 28 | message: string, | ||
| 29 | details: string | null, | ||
| 30 | ): SourceStoreError => | ||
| 31 | new SourceStoreError({ | ||
| 32 | operation, | ||
| 33 | backend: "local-file", | ||
| 34 | location: "tool-artifacts.json", | ||
| 35 | message, | ||
| 36 | reason: null, | ||
| 37 | details, | ||
| 38 | }); | ||
| 39 | |||
| 40 | const toSourceStoreErrorFromSourceStore = ( | ||
| 41 | operation: string, | ||
| 42 | error: SourceStoreError, | ||
| 43 | ): SourceStoreError => | ||
| 44 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 45 | |||
| 46 | const toSourceStoreErrorFromToolArtifactStore = ( | ||
| 47 | operation: string, | ||
| 48 | error: ToolArtifactStoreError, | ||
| 49 | ): SourceStoreError => | ||
| 50 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 51 | |||
| 52 | const parseArtifactManifest = ( | ||
| 53 | source: Source, | ||
| 54 | artifact: ToolArtifact, | ||
| 55 | ): Effect.Effect<ReadonlyArray<SourceToolSummary>, SourceStoreError> => | ||
| 56 | Effect.try({ | ||
| 57 | try: () => JSON.parse(artifact.manifestJson) as unknown, | ||
| 58 | catch: (cause) => | ||
| 59 | toSourceStoreError( | ||
| 60 | "tools.decode_manifest_json", | ||
| 61 | cause instanceof Error ? cause.message : String(cause), | ||
| 62 | `sourceId=${source.id}`, | ||
| 63 | ), | ||
| 64 | }).pipe( | ||
| 65 | Effect.flatMap((manifestJson) => | ||
| 66 | decodeOpenApiToolManifest(manifestJson).pipe( | ||
| 67 | Effect.mapError((cause) => | ||
| 68 | toSourceStoreError( | ||
| 69 | "tools.decode_manifest", | ||
| 70 | "Unable to decode OpenAPI manifest", | ||
| 71 | ParseResult.TreeFormatter.formatErrorSync(cause), | ||
| 72 | ), | ||
| 73 | ), | ||
| 74 | ) | ||
| 75 | ), | ||
| 76 | Effect.map((manifest) => | ||
| 77 | manifest.tools.map((tool) => ({ | ||
| 78 | sourceId: source.id, | ||
| 79 | sourceName: source.name, | ||
| 80 | sourceKind: source.kind, | ||
| 81 | toolId: tool.toolId, | ||
| 82 | name: tool.name, | ||
| 83 | description: tool.description, | ||
| 84 | method: tool.method, | ||
| 85 | path: tool.path, | ||
| 86 | operationHash: tool.operationHash, | ||
| 87 | })), | ||
| 88 | ), | ||
| 89 | ); | ||
| 90 | |||
| 91 | const sortTools = (tools: ReadonlyArray<SourceToolSummary>): Array<SourceToolSummary> => | ||
| 92 | [...tools].sort((left, right) => { | ||
| 93 | const leftSource = left.sourceName.toLowerCase(); | ||
| 94 | const rightSource = right.sourceName.toLowerCase(); | ||
| 95 | |||
| 96 | if (leftSource !== rightSource) { | ||
| 97 | return leftSource.localeCompare(rightSource); | ||
| 98 | } | ||
| 99 | |||
| 100 | const leftName = left.name.toLowerCase(); | ||
| 101 | const rightName = right.name.toLowerCase(); | ||
| 102 | |||
| 103 | if (leftName !== rightName) { | ||
| 104 | return leftName.localeCompare(rightName); | ||
| 105 | } | ||
| 106 | |||
| 107 | return left.toolId.localeCompare(right.toolId); | ||
| 108 | }); | ||
| 109 | |||
| 110 | const listSourceTools = ( | ||
| 111 | sourceStore: SourceStore, | ||
| 112 | toolArtifactStore: ToolArtifactStore, | ||
| 113 | input: { | ||
| 114 | workspaceId: WorkspaceId; | ||
| 115 | sourceId: SourceId; | ||
| 116 | }, | ||
| 117 | ): Effect.Effect<ReadonlyArray<SourceToolSummary>, SourceStoreError> => | ||
| 118 | Effect.gen(function* () { | ||
| 119 | const sourceOption = yield* sourceStore.getById(input.workspaceId, input.sourceId).pipe( | ||
| 120 | Effect.mapError((error) => | ||
| 121 | toSourceStoreErrorFromSourceStore("tools.get_source", error), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | |||
| 125 | const source = Option.getOrNull(sourceOption); | ||
| 126 | if (source === null) { | ||
| 127 | return []; | ||
| 128 | } | ||
| 129 | |||
| 130 | const artifactOption = yield* toolArtifactStore | ||
| 131 | .getBySource(input.workspaceId, input.sourceId) | ||
| 132 | .pipe( | ||
| 133 | Effect.mapError((error) => | ||
| 134 | toSourceStoreErrorFromToolArtifactStore("tools.get_artifact", error), | ||
| 135 | ), | ||
| 136 | ); | ||
| 137 | |||
| 138 | const artifact = Option.getOrNull(artifactOption); | ||
| 139 | if (artifact === null) { | ||
| 140 | return []; | ||
| 141 | } | ||
| 142 | |||
| 143 | return yield* parseArtifactManifest(source, artifact); | ||
| 144 | }); | ||
| 145 | |||
| 146 | export const createPmToolsService = ( | ||
| 147 | sourceStore: SourceStore, | ||
| 148 | toolArtifactStore: ToolArtifactStore, | ||
| 149 | ): ControlPlaneToolsServiceShape => | ||
| 150 | makeControlPlaneToolsService({ | ||
| 151 | listWorkspaceTools: (workspaceId) => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const sources = yield* sourceStore.listByWorkspace(workspaceId).pipe( | ||
| 154 | Effect.mapError((error) => | ||
| 155 | toSourceStoreErrorFromSourceStore("tools.list_sources", error), | ||
| 156 | ), | ||
| 157 | ); | ||
| 158 | |||
| 159 | const toolGroups = yield* Effect.forEach( | ||
| 160 | sources, | ||
| 161 | (source) => | ||
| 162 | listSourceTools(sourceStore, toolArtifactStore, { | ||
| 163 | workspaceId, | ||
| 164 | sourceId: source.id, | ||
| 165 | }), | ||
| 166 | { | ||
| 167 | concurrency: "unbounded", | ||
| 168 | }, | ||
| 169 | ); | ||
| 170 | |||
| 171 | return sortTools(toolGroups.flat()); | ||
| 172 | }), | ||
| 173 | |||
| 174 | listSourceTools: (input) => | ||
| 175 | listSourceTools(sourceStore, toolArtifactStore, { | ||
| 176 | workspaceId: input.workspaceId, | ||
| 177 | sourceId: input.sourceId, | ||
| 178 | }), | ||
| 179 | }); | ||
| @@ -0,0 +1,138 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { | ||
| 3 | makeControlPlaneWorkspacesService, | ||
| 4 | type ControlPlaneWorkspacesServiceShape, | ||
| 5 | } from "@executor-v2/management-api"; | ||
| 6 | import { | ||
| 7 | type LocalStateSnapshot, | ||
| 8 | type LocalStateStore, | ||
| 9 | type LocalStateStoreError, | ||
| 10 | } from "@executor-v2/persistence-local"; | ||
| 11 | import { type Workspace } from "@executor-v2/schema"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | |||
| 15 | const toSourceStoreError = ( | ||
| 16 | operation: string, | ||
| 17 | message: string, | ||
| 18 | details: string | null, | ||
| 19 | ): SourceStoreError => | ||
| 20 | new SourceStoreError({ | ||
| 21 | operation, | ||
| 22 | backend: "local-file", | ||
| 23 | location: "snapshot.json", | ||
| 24 | message, | ||
| 25 | reason: null, | ||
| 26 | details, | ||
| 27 | }); | ||
| 28 | |||
| 29 | const toSourceStoreErrorFromLocalState = ( | ||
| 30 | operation: string, | ||
| 31 | error: LocalStateStoreError, | ||
| 32 | ): SourceStoreError => | ||
| 33 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | ||
| 34 | |||
| 35 | const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> => | ||
| 36 | [...workspaces].sort((left, right) => { | ||
| 37 | const leftName = left.name.toLowerCase(); | ||
| 38 | const rightName = right.name.toLowerCase(); | ||
| 39 | |||
| 40 | if (leftName === rightName) { | ||
| 41 | return left.id.localeCompare(right.id); | ||
| 42 | } | ||
| 43 | |||
| 44 | return leftName.localeCompare(rightName); | ||
| 45 | }); | ||
| 46 | |||
| 47 | const replaceWorkspaceAt = ( | ||
| 48 | snapshot: LocalStateSnapshot, | ||
| 49 | index: number, | ||
| 50 | workspace: Workspace, | ||
| 51 | ): LocalStateSnapshot => { | ||
| 52 | const next = [...snapshot.workspaces]; | ||
| 53 | next[index] = workspace; | ||
| 54 | |||
| 55 | return { | ||
| 56 | ...snapshot, | ||
| 57 | generatedAt: Date.now(), | ||
| 58 | workspaces: next, | ||
| 59 | }; | ||
| 60 | }; | ||
| 61 | |||
| 62 | const appendWorkspace = ( | ||
| 63 | snapshot: LocalStateSnapshot, | ||
| 64 | workspace: Workspace, | ||
| 65 | ): LocalStateSnapshot => ({ | ||
| 66 | ...snapshot, | ||
| 67 | generatedAt: Date.now(), | ||
| 68 | workspaces: [...snapshot.workspaces, workspace], | ||
| 69 | }); | ||
| 70 | |||
| 71 | export const createPmWorkspacesService = ( | ||
| 72 | localStateStore: LocalStateStore, | ||
| 73 | ): ControlPlaneWorkspacesServiceShape => | ||
| 74 | makeControlPlaneWorkspacesService({ | ||
| 75 | listWorkspaces: () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 78 | Effect.mapError((error) => | ||
| 79 | toSourceStoreErrorFromLocalState("workspaces.list", error), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | |||
| 83 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 84 | if (snapshot === null) { | ||
| 85 | return []; | ||
| 86 | } | ||
| 87 | |||
| 88 | return sortWorkspaces(snapshot.workspaces); | ||
| 89 | }), | ||
| 90 | |||
| 91 | upsertWorkspace: (input) => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | ||
| 94 | Effect.mapError((error) => | ||
| 95 | toSourceStoreErrorFromLocalState("workspaces.upsert", error), | ||
| 96 | ), | ||
| 97 | ); | ||
| 98 | |||
| 99 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 100 | if (snapshot === null) { | ||
| 101 | return yield* toSourceStoreError( | ||
| 102 | "workspaces.upsert", | ||
| 103 | "Workspace snapshot not found", | ||
| 104 | null, | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | const now = Date.now(); | ||
| 109 | const existingIndex = input.payload.id | ||
| 110 | ? snapshot.workspaces.findIndex((workspace) => workspace.id === input.payload.id) | ||
| 111 | : -1; | ||
| 112 | const existing = existingIndex >= 0 ? snapshot.workspaces[existingIndex] : null; | ||
| 113 | |||
| 114 | const nextWorkspace: Workspace = { | ||
| 115 | id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])), | ||
| 116 | organizationId: | ||
| 117 | input.payload.organizationId !== undefined | ||
| 118 | ? input.payload.organizationId | ||
| 119 | : existing?.organizationId ?? null, | ||
| 120 | name: input.payload.name, | ||
| 121 | createdByAccountId: existing?.createdByAccountId ?? null, | ||
| 122 | createdAt: existing?.createdAt ?? now, | ||
| 123 | updatedAt: now, | ||
| 124 | }; | ||
| 125 | |||
| 126 | const nextSnapshot = existingIndex >= 0 | ||
| 127 | ? replaceWorkspaceAt(snapshot, existingIndex, nextWorkspace) | ||
| 128 | : appendWorkspace(snapshot, nextWorkspace); | ||
| 129 | |||
| 130 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 131 | Effect.mapError((error) => | ||
| 132 | toSourceStoreErrorFromLocalState("workspaces.upsert_write", error), | ||
| 133 | ), | ||
| 134 | ); | ||
| 135 | |||
| 136 | return nextWorkspace; | ||
| 137 | }), | ||
| 138 | }); | ||
| @@ -2,7 +2,7 @@ import { ControlPlaneAuthHeaders } from "@executor-v2/management-api"; · 1 unmodified line | |||
| 2 | import { withAuth } from "@workos-inc/authkit-nextjs"; | 2 | import { withAuth } from "@workos-inc/authkit-nextjs"; |
| 3 | import { type NextRequest } from "next/server"; | 3 | import { type NextRequest } from "next/server"; |
| 4 | 4 | ||
| 5 | import { isWorkosEnabled } from "../../../../lib/workos"; | 5 | import { isLocalControlPlaneUpstream, isWorkosEnabled } from "../../../../lib/workos"; |
| 6 | 6 | ||
| 7 | const controlPlaneUpstream = | 7 | const controlPlaneUpstream = |
| 8 | process.env.CONTROL_PLANE_UPSTREAM_URL ?? "http://127.0.0.1:8787"; | 8 | process.env.CONTROL_PLANE_UPSTREAM_URL ?? "http://127.0.0.1:8787"; |
| @@ -217,12 +217,6 @@ const normalizeSuccessResponse = async ( · 208 unmodified lines | |||
| 217 | upstreamResponse: Response, | 217 | upstreamResponse: Response, |
| 218 | ): Promise<Response> => { | 218 | ): Promise<Response> => { |
| 219 | const contentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; | 219 | const contentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; |
| 220 | |||
| 221 | if (contentType.includes("application/json")) { | ||
| 222 | const payload = await upstreamResponse.json(); | ||
| 223 | return Response.json(payload, { status: upstreamResponse.status }); | ||
| 224 | } | ||
| 225 | |||
| 226 | const payload = await upstreamResponse.arrayBuffer(); | 220 | const payload = await upstreamResponse.arrayBuffer(); |
| 227 | 221 | ||
| 228 | const headers = new Headers(); | 222 | const headers = new Headers(); |
| @@ -241,14 +235,22 @@ const proxyControlPlaneRequest = async ( · 12 unmodified lines | |||
| 241 | path: ReadonlyArray<string>, | 235 | path: ReadonlyArray<string>, |
| 242 | ): Promise<Response> => { | 236 | ): Promise<Response> => { |
| 243 | try { | 237 | try { |
| 244 | const { user } = await withAuth(); | 238 | const workosEnabled = isWorkosEnabled(); |
| 239 | const user = workosEnabled ? (await withAuth()).user : null; | ||
| 245 | 240 | ||
| 246 | const principalAccountId = resolvePrincipalAccountId(request, user?.id ?? null); | 241 | let principalAccountId = resolvePrincipalAccountId(request, user?.id ?? null); |
| 247 | 242 | ||
| 248 | if (isWorkosEnabled() && user === null) { | 243 | if (workosEnabled && user === null) { |
| 249 | return unauthorizedResponse(); | 244 | return unauthorizedResponse(); |
| 250 | } | 245 | } |
| 251 | 246 | ||
| 247 | if (principalAccountId === null && !workosEnabled && isLocalControlPlaneUpstream()) { | ||
| 248 | const localAccountId = process.env.CONTROL_PLANE_LOCAL_ACCOUNT_ID?.trim(); | ||
| 249 | principalAccountId = localAccountId && localAccountId.length > 0 | ||
| 250 | ? localAccountId | ||
| 251 | : "local-dev"; | ||
| 252 | } | ||
| 253 | |||
| 252 | if (principalAccountId === null) { | 254 | if (principalAccountId === null) { |
| 253 | return unauthorizedResponse(); | 255 | return unauthorizedResponse(); |
| 254 | } | 256 | } |
| @@ -1,216 +1,61 @@ | |||
| 1 | :root { | 1 | @import "tailwindcss"; |
| 2 | --bg: #f2f0ea; | 2 | @import "tw-animate-css"; |
| 3 | --panel: #fffdf8; | 3 | |
| 4 | --ink: #1b1a16; | 4 | @theme inline { |
| 5 | --muted: #6b675d; | 5 | --font-sans: var(--font-body), "IBM Plex Sans", sans-serif; |
| 6 | --accent: #0a7f64; | 6 | --font-display: var(--font-display), "Fraunces", serif; |
| 7 | --accent-ink: #f4fffc; | 7 | --color-background: var(--background); |
| 8 | --border: rgba(27, 26, 22, 0.14); | 8 | --color-foreground: var(--foreground); |
| 9 | --danger: #a22a2a; | 9 | --color-card: var(--card); |
| 10 | } | 10 | --color-card-foreground: var(--card-foreground); |
| 11 | 11 | --color-primary: var(--primary); | |
| 12 | * { | 12 | --color-primary-foreground: var(--primary-foreground); |
| 13 | box-sizing: border-box; | 13 | --color-secondary: var(--secondary); |
| 14 | } | 14 | --color-secondary-foreground: var(--secondary-foreground); |
| 15 | 15 | --color-muted: var(--muted); | |
| 16 | html, | 16 | --color-muted-foreground: var(--muted-foreground); |
| 17 | body { | 17 | --color-accent: var(--accent); |
| 18 | margin: 0; | 18 | --color-accent-foreground: var(--accent-foreground); |
| 19 | padding: 0; | 19 | --color-destructive: var(--destructive); |
| 20 | min-height: 100%; | 20 | --color-border: var(--border); |
| 21 | } | 21 | --color-input: var(--input); |
| 22 | 22 | --color-ring: var(--ring); | |
| 23 | body { | ||
| 24 | font-family: var(--font-body), "IBM Plex Sans", sans-serif; | ||
| 25 | background: | ||
| 26 | radial-gradient(circle at 15% 20%, rgba(10, 127, 100, 0.22), transparent 45%), | ||
| 27 | radial-gradient(circle at 80% 10%, rgba(203, 166, 70, 0.17), transparent 36%), | ||
| 28 | linear-gradient(170deg, #ece9de 0%, #f6f4ee 52%, #e8e3d7 100%); | ||
| 29 | color: var(--ink); | ||
| 30 | } | ||
| 31 | |||
| 32 | a { | ||
| 33 | color: inherit; | ||
| 34 | } | ||
| 35 | |||
| 36 | main { | ||
| 37 | width: min(980px, calc(100% - 2.5rem)); | ||
| 38 | margin: 2.5rem auto 3.5rem; | ||
| 39 | } | ||
| 40 | |||
| 41 | .shell { | ||
| 42 | border: 1px solid var(--border); | ||
| 43 | border-radius: 18px; | ||
| 44 | background: var(--panel); | ||
| 45 | box-shadow: 0 20px 60px rgba(18, 16, 10, 0.08); | ||
| 46 | overflow: hidden; | ||
| 47 | } | ||
| 48 | |||
| 49 | .hero { | ||
| 50 | padding: 2rem 2rem 1rem; | ||
| 51 | border-bottom: 1px dashed var(--border); | ||
| 52 | } | ||
| 53 | |||
| 54 | .hero-top-row { | ||
| 55 | display: flex; | ||
| 56 | align-items: center; | ||
| 57 | justify-content: space-between; | ||
| 58 | gap: 1rem; | ||
| 59 | } | ||
| 60 | |||
| 61 | .hero h1 { | ||
| 62 | margin: 0; | ||
| 63 | font-family: var(--font-display), "Fraunces", serif; | ||
| 64 | font-size: clamp(1.75rem, 3.1vw, 2.4rem); | ||
| 65 | line-height: 1.1; | ||
| 66 | } | ||
| 67 | |||
| 68 | .sign-out-link { | ||
| 69 | display: inline-flex; | ||
| 70 | align-items: center; | ||
| 71 | justify-content: center; | ||
| 72 | border: 1px solid var(--border); | ||
| 73 | border-radius: 999px; | ||
| 74 | padding: 0.45rem 0.8rem; | ||
| 75 | text-decoration: none; | ||
| 76 | font-size: 0.86rem; | ||
| 77 | color: var(--muted); | ||
| 78 | background: rgba(255, 255, 255, 0.6); | ||
| 79 | } | ||
| 80 | |||
| 81 | .hero p { | ||
| 82 | margin: 0.75rem 0 0; | ||
| 83 | color: var(--muted); | ||
| 84 | max-width: 60ch; | ||
| 85 | } | ||
| 86 | |||
| 87 | .grid { | ||
| 88 | display: grid; | ||
| 89 | grid-template-columns: 1fr 1fr; | ||
| 90 | } | ||
| 91 | |||
| 92 | .card { | ||
| 93 | padding: 1.5rem 2rem 2rem; | ||
| 94 | } | ||
| 95 | |||
| 96 | .card + .card { | ||
| 97 | border-left: 1px solid var(--border); | ||
| 98 | } | ||
| 99 | |||
| 100 | h2 { | ||
| 101 | margin: 0 0 1rem; | ||
| 102 | font-family: var(--font-display), "Fraunces", serif; | ||
| 103 | font-size: 1.3rem; | ||
| 104 | } | 23 | } |
| 105 | 24 | ||
| 106 | .field { | 25 | :root { |
| 107 | margin-bottom: 0.95rem; | 26 | --background: oklch(0.986 0.01 92); |
| 108 | } | 27 | --foreground: oklch(0.22 0.01 96); |
| 109 | 28 | --card: oklch(0.995 0.004 95); | |
| 110 | label { | 29 | --card-foreground: oklch(0.22 0.01 96); |
| 111 | display: block; | 30 | --primary: oklch(0.51 0.13 170); |
| 112 | margin-bottom: 0.4rem; | 31 | --primary-foreground: oklch(0.98 0.004 120); |
| 113 | color: var(--muted); | 32 | --secondary: oklch(0.95 0.008 85); |
| 114 | font-size: 0.9rem; | 33 | --secondary-foreground: oklch(0.32 0.01 92); |
| 115 | } | 34 | --muted: oklch(0.95 0.008 90); |
| 116 | 35 | --muted-foreground: oklch(0.48 0.015 92); | |
| 117 | input, | 36 | --accent: oklch(0.93 0.01 160); |
| 118 | select, | 37 | --accent-foreground: oklch(0.26 0.02 165); |
| 119 | button { | 38 | --destructive: oklch(0.56 0.2 25); |
| 120 | width: 100%; | 39 | --border: oklch(0.88 0.01 92); |
| 121 | border-radius: 10px; | 40 | --input: oklch(0.88 0.01 92); |
| 122 | border: 1px solid var(--border); | 41 | --ring: oklch(0.51 0.13 170); |
| 123 | font: inherit; | 42 | } |
| 124 | } | 43 | |
| 125 | 44 | @layer base { | |
| 126 | input, | 45 | * { |
| 127 | select { | 46 | @apply border-border; |
| 128 | padding: 0.62rem 0.7rem; | ||
| 129 | background: #fff; | ||
| 130 | } | ||
| 131 | |||
| 132 | button { | ||
| 133 | padding: 0.7rem 0.8rem; | ||
| 134 | font-weight: 600; | ||
| 135 | cursor: pointer; | ||
| 136 | } | ||
| 137 | |||
| 138 | button.primary { | ||
| 139 | background: var(--accent); | ||
| 140 | color: var(--accent-ink); | ||
| 141 | border-color: transparent; | ||
| 142 | } | ||
| 143 | |||
| 144 | button.secondary { | ||
| 145 | background: transparent; | ||
| 146 | } | ||
| 147 | |||
| 148 | button.danger { | ||
| 149 | background: transparent; | ||
| 150 | color: var(--danger); | ||
| 151 | border-color: rgba(162, 42, 42, 0.35); | ||
| 152 | width: auto; | ||
| 153 | padding: 0.45rem 0.7rem; | ||
| 154 | } | ||
| 155 | |||
| 156 | .status { | ||
| 157 | margin-top: 0.7rem; | ||
| 158 | padding: 0.65rem 0.75rem; | ||
| 159 | border: 1px solid var(--border); | ||
| 160 | border-radius: 10px; | ||
| 161 | color: var(--muted); | ||
| 162 | font-size: 0.9rem; | ||
| 163 | } | ||
| 164 | |||
| 165 | .error { | ||
| 166 | color: var(--danger); | ||
| 167 | } | ||
| 168 | |||
| 169 | .list { | ||
| 170 | display: grid; | ||
| 171 | gap: 0.75rem; | ||
| 172 | } | ||
| 173 | |||
| 174 | .list-item { | ||
| 175 | border: 1px solid var(--border); | ||
| 176 | border-radius: 12px; | ||
| 177 | padding: 0.8rem; | ||
| 178 | background: rgba(255, 255, 255, 0.65); | ||
| 179 | } | ||
| 180 | |||
| 181 | .list-item header { | ||
| 182 | display: flex; | ||
| 183 | align-items: flex-start; | ||
| 184 | justify-content: space-between; | ||
| 185 | gap: 0.75rem; | ||
| 186 | } | ||
| 187 | |||
| 188 | .list-item strong { | ||
| 189 | display: block; | ||
| 190 | font-size: 1rem; | ||
| 191 | } | ||
| 192 | |||
| 193 | .meta { | ||
| 194 | margin-top: 0.2rem; | ||
| 195 | color: var(--muted); | ||
| 196 | font-size: 0.82rem; | ||
| 197 | word-break: break-all; | ||
| 198 | } | ||
| 199 | |||
| 200 | @media (max-width: 860px) { | ||
| 201 | .grid { | ||
| 202 | grid-template-columns: 1fr; | ||
| 203 | } | 47 | } |
| 204 | 48 | ||
| 205 | .card + .card { | 49 | html, |
| 206 | border-left: 0; | 50 | body { |
| 207 | border-top: 1px solid var(--border); | 51 | min-height: 100%; |
| 208 | } | 52 | } |
| 209 | } | ||
| 210 | 53 | ||
| 211 | @media (max-width: 640px) { | 54 | body { |
| 212 | .hero-top-row { | 55 | @apply font-sans text-foreground antialiased; |
| 213 | flex-direction: column; | 56 | background: |
| 214 | align-items: flex-start; | 57 | radial-gradient(circle at 12% 18%, color-mix(in oklch, var(--primary) 22%, transparent), transparent 42%), |
| 58 | radial-gradient(circle at 84% 6%, oklch(0.8 0.07 84 / 0.35), transparent 38%), | ||
| 59 | linear-gradient(170deg, #f3f0e6 0%, #f7f4ec 58%, #ece6d9 100%); | ||
| 215 | } | 60 | } |
| 216 | } | 61 | } |
| @@ -1,39 +1,307 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; |
| 4 | import type { UpsertSourcePayload } from "@executor-v2/management-api"; | 4 | import type { |
| 5 | import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema"; | 5 | ListStorageKvResult, |
| 6 | import { useState, type FormEvent } from "react"; | 6 | QueryStorageSqlResult, |
| 7 | StorageDirectoryEntry, | ||
| 8 | } from "@executor-v2/management-api/storage/api"; | ||
| 9 | import type { | ||
| 10 | Approval, | ||
| 11 | ApprovalId, | ||
| 12 | CredentialProvider, | ||
| 13 | CredentialScopeType, | ||
| 14 | Organization, | ||
| 15 | Policy, | ||
| 16 | PolicyDecision, | ||
| 17 | PolicyId, | ||
| 18 | SourceCredentialBinding, | ||
| 19 | SourceId, | ||
| 20 | StorageDurability, | ||
| 21 | StorageInstance, | ||
| 22 | StorageScopeType, | ||
| 23 | Workspace, | ||
| 24 | WorkspaceId, | ||
| 25 | } from "@executor-v2/schema"; | ||
| 26 | import { useEffect, useMemo, useState, type FormEvent } from "react"; | ||
| 7 | 27 | ||
| 8 | import { | 28 | import { |
| 29 | approvalsByWorkspace, | ||
| 30 | approvalsResultByWorkspace, | ||
| 31 | closeStorageInstance, | ||
| 32 | credentialBindingsByWorkspace, | ||
| 33 | credentialBindingsResultByWorkspace, | ||
| 34 | listStorageDirectory, | ||
| 35 | listStorageKv, | ||
| 36 | openStorageInstance, | ||
| 37 | optimisticRemovePolicy, | ||
| 9 | optimisticRemoveSources, | 38 | optimisticRemoveSources, |
| 39 | optimisticResolveApproval, | ||
| 10 | optimisticSourcesByWorkspace, | 40 | optimisticSourcesByWorkspace, |
| 41 | optimisticUpsertPolicy, | ||
| 11 | optimisticUpsertSources, | 42 | optimisticUpsertSources, |
| 43 | organizationsResult, | ||
| 44 | organizationsState, | ||
| 45 | policiesByWorkspace, | ||
| 46 | policiesResultByWorkspace, | ||
| 47 | queryStorageSql, | ||
| 48 | readStorageFile, | ||
| 49 | removeCredentialBinding, | ||
| 50 | removePolicy, | ||
| 12 | removeSource, | 51 | removeSource, |
| 52 | removeStorageInstance, | ||
| 53 | resolveApproval, | ||
| 13 | sourcesByWorkspace, | 54 | sourcesByWorkspace, |
| 14 | sourcesPendingByWorkspace, | 55 | sourcesPendingByWorkspace, |
| 15 | sourcesResultByWorkspace, | 56 | sourcesResultByWorkspace, |
| 57 | storageByWorkspace, | ||
| 58 | storageResultByWorkspace, | ||
| 59 | toCredentialBindingRemoveResult, | ||
| 60 | toCredentialBindingUpsertPayload, | ||
| 61 | toListStorageDirectoryPayload, | ||
| 62 | toListStorageKvPayload, | ||
| 63 | toOpenStoragePayload, | ||
| 64 | toOrganizationUpsertPayload, | ||
| 65 | toPolicyRemoveResult, | ||
| 66 | toPolicyUpsertPayload, | ||
| 67 | toQueryStorageSqlPayload, | ||
| 68 | toReadStorageFilePayload, | ||
| 69 | toStorageDirectoryResult, | ||
| 70 | toStorageKvResult, | ||
| 71 | toStorageReadFileResult, | ||
| 72 | toStorageRemoveResult, | ||
| 73 | toStorageSqlResult, | ||
| 74 | toWorkspaceUpsertPayload, | ||
| 75 | upsertCredentialBinding, | ||
| 76 | upsertOrganization, | ||
| 77 | upsertPolicy, | ||
| 16 | upsertSource, | 78 | upsertSource, |
| 79 | upsertWorkspace, | ||
| 80 | workspaceToolsByWorkspace, | ||
| 81 | workspaceToolsResultByWorkspace, | ||
| 82 | workspacesResult, | ||
| 83 | workspacesState, | ||
| 17 | } from "../lib/control-plane/atoms"; | 84 | } from "../lib/control-plane/atoms"; |
| 85 | import { | ||
| 86 | formStateFromSource, | ||
| 87 | sourceToLegacyRecord, | ||
| 88 | upsertPayloadFromForm, | ||
| 89 | type LegacySourceFormState, | ||
| 90 | type LegacySourceType, | ||
| 91 | } from "../lib/control-plane/legacy-source"; | ||
| 92 | import { Badge } from "../components/ui/badge"; | ||
| 93 | import { Button } from "../components/ui/button"; | ||
| 94 | import { | ||
| 95 | Card, | ||
| 96 | CardContent, | ||
| 97 | CardDescription, | ||
| 98 | CardHeader, | ||
| 99 | CardTitle, | ||
| 100 | } from "../components/ui/card"; | ||
| 101 | import { Input } from "../components/ui/input"; | ||
| 102 | import { Select } from "../components/ui/select"; | ||
| 103 | import { cn } from "../lib/utils"; | ||
| 104 | |||
| 105 | const kindOptions: ReadonlyArray<LegacySourceType> = ["openapi", "mcp", "graphql"]; | ||
| 106 | |||
| 107 | const catalogTemplates = [ | ||
| 108 | { | ||
| 109 | name: "GitHub", | ||
| 110 | type: "openapi" as const, | ||
| 111 | endpoint: | ||
| 112 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", | ||
| 113 | }, | ||
| 114 | { | ||
| 115 | name: "OpenAI", | ||
| 116 | type: "openapi" as const, | ||
| 117 | endpoint: | ||
| 118 | "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", | ||
| 119 | }, | ||
| 120 | { | ||
| 121 | name: "Linear", | ||
| 122 | type: "graphql" as const, | ||
| 123 | endpoint: "https://api.linear.app/graphql", | ||
| 124 | }, | ||
| 125 | { | ||
| 126 | name: "Generic MCP", | ||
| 127 | type: "mcp" as const, | ||
| 128 | endpoint: "https://example.com/mcp", | ||
| 129 | }, | ||
| 130 | ] as const; | ||
| 18 | 131 | ||
| 19 | const kindOptions: ReadonlyArray<SourceKind> = [ | 132 | const defaultFormState = (): LegacySourceFormState => ({ |
| 20 | "openapi", | 133 | name: "", |
| 21 | "mcp", | 134 | type: "openapi", |
| 22 | "graphql", | 135 | endpoint: "", |
| 23 | "internal", | 136 | baseUrl: "", |
| 24 | ]; | 137 | mcpTransport: "auto", |
| 138 | authType: "none", | ||
| 139 | authMode: "workspace", | ||
| 140 | apiKeyHeader: "Authorization", | ||
| 141 | enabled: true, | ||
| 142 | }); | ||
| 143 | |||
| 144 | const createLocalId = (prefix: string): string => { | ||
| 145 | const randomPart = | ||
| 146 | typeof globalThis.crypto !== "undefined" | ||
| 147 | && typeof globalThis.crypto.randomUUID === "function" | ||
| 148 | ? globalThis.crypto.randomUUID() | ||
| 149 | : `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; | ||
| 150 | |||
| 151 | return `${prefix}${randomPart}`; | ||
| 152 | }; | ||
| 25 | 153 | ||
| 26 | type PageProps = { | 154 | type PageProps = { |
| 27 | authEnabled: boolean; | 155 | authEnabled: boolean; |
| 28 | initialWorkspaceId: string; | 156 | initialWorkspaceId: string; |
| 29 | }; | 157 | }; |
| 30 | 158 | ||
| 159 | type ConsoleTab = | ||
| 160 | | "sources" | ||
| 161 | | "tools" | ||
| 162 | | "credentials" | ||
| 163 | | "policies" | ||
| 164 | | "organizations" | ||
| 165 | | "workspaces" | ||
| 166 | | "storage" | ||
| 167 | | "approvals"; | ||
| 168 | type ApprovalFilter = "pending" | "resolved" | "all"; | ||
| 169 | |||
| 170 | const formatTimestamp = (value: number | null): string => { | ||
| 171 | if (value === null) { | ||
| 172 | return "-"; | ||
| 173 | } | ||
| 174 | |||
| 175 | return new Intl.DateTimeFormat("en", { | ||
| 176 | month: "short", | ||
| 177 | day: "2-digit", | ||
| 178 | hour: "2-digit", | ||
| 179 | minute: "2-digit", | ||
| 180 | }).format(new Date(value)); | ||
| 181 | }; | ||
| 182 | |||
| 183 | const previewFromInputJson = (inputPreviewJson: string): string => { | ||
| 184 | const trimmed = inputPreviewJson.trim(); | ||
| 185 | if (trimmed.length === 0) { | ||
| 186 | return "{}"; | ||
| 187 | } | ||
| 188 | |||
| 189 | try { | ||
| 190 | const parsed = JSON.parse(trimmed) as unknown; | ||
| 191 | const pretty = JSON.stringify(parsed, null, 2); | ||
| 192 | return pretty.length > 640 ? `${pretty.slice(0, 640)}\n...` : pretty; | ||
| 193 | } catch { | ||
| 194 | return trimmed.length > 640 ? `${trimmed.slice(0, 640)}...` : trimmed; | ||
| 195 | } | ||
| 196 | }; | ||
| 197 | |||
| 198 | const statusBadgeVariant = ( | ||
| 199 | status: Approval["status"], | ||
| 200 | ): "pending" | "approved" | "denied" | "outline" => { | ||
| 201 | if (status === "pending") return "pending"; | ||
| 202 | if (status === "approved") return "approved"; | ||
| 203 | if (status === "denied") return "denied"; | ||
| 204 | return "outline"; | ||
| 205 | }; | ||
| 206 | |||
| 31 | const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { | 207 | const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { |
| 208 | const [activeTab, setActiveTab] = useState<ConsoleTab>("sources"); | ||
| 32 | const [workspaceIdInput, setWorkspaceIdInput] = useState(initialWorkspaceId); | 209 | const [workspaceIdInput, setWorkspaceIdInput] = useState(initialWorkspaceId); |
| 33 | const [name, setName] = useState("Weather API"); | 210 | const [formState, setFormState] = useState<LegacySourceFormState>(() => |
| 34 | const [kind, setKind] = useState<SourceKind>("openapi"); | 211 | defaultFormState(), |
| 35 | const [endpoint, setEndpoint] = useState("https://example.com/openapi.json"); | 212 | ); |
| 213 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 36 | const [statusText, setStatusText] = useState<string | null>(null); | 214 | const [statusText, setStatusText] = useState<string | null>(null); |
| 215 | const [toolsRefreshPending, setToolsRefreshPending] = useState(false); | ||
| 216 | const [toolSearchQuery, setToolSearchQuery] = useState(""); | ||
| 217 | const [selectedToolSourceId, setSelectedToolSourceId] = useState<SourceId | "all">("all"); | ||
| 218 | |||
| 219 | const [organizationIdInput, setOrganizationIdInput] = useState(""); | ||
| 220 | const [organizationSlugInput, setOrganizationSlugInput] = useState(""); | ||
| 221 | const [organizationNameInput, setOrganizationNameInput] = useState(""); | ||
| 222 | const [organizationStatusInput, setOrganizationStatusInput] = | ||
| 223 | useState<Organization["status"]>("active"); | ||
| 224 | const [organizationStatusText, setOrganizationStatusText] = useState<string | null>(null); | ||
| 225 | |||
| 226 | const [workspaceEditIdInput, setWorkspaceEditIdInput] = useState(""); | ||
| 227 | const [workspaceNameInput, setWorkspaceNameInput] = useState(""); | ||
| 228 | const [workspaceOrganizationIdInput, setWorkspaceOrganizationIdInput] = useState(""); | ||
| 229 | const [workspaceStatusText, setWorkspaceStatusText] = useState<string | null>(null); | ||
| 230 | |||
| 231 | const [credentialSourceKey, setCredentialSourceKey] = useState(""); | ||
| 232 | const [credentialProvider, setCredentialProvider] = | ||
| 233 | useState<CredentialProvider>("api_key"); | ||
| 234 | const [credentialScopeType, setCredentialScopeType] = | ||
| 235 | useState<CredentialScopeType>("workspace"); | ||
| 236 | const [credentialIdInput, setCredentialIdInput] = useState(""); | ||
| 237 | const [credentialSecretRef, setCredentialSecretRef] = useState(""); | ||
| 238 | const [credentialAccountId, setCredentialAccountId] = useState(""); | ||
| 239 | const [credentialAdditionalHeadersJson, setCredentialAdditionalHeadersJson] = | ||
| 240 | useState(""); | ||
| 241 | const [credentialBoundAuthFingerprint, setCredentialBoundAuthFingerprint] = | ||
| 242 | useState(""); | ||
| 243 | const [credentialEditingId, setCredentialEditingId] = | ||
| 244 | useState<SourceCredentialBinding["id"] | null>(null); | ||
| 245 | const [credentialSearchQuery, setCredentialSearchQuery] = useState(""); | ||
| 246 | const [credentialStatusText, setCredentialStatusText] = useState<string | null>( | ||
| 247 | null, | ||
| 248 | ); | ||
| 249 | const [credentialBusyId, setCredentialBusyId] = | ||
| 250 | useState<SourceCredentialBinding["id"] | null>(null); | ||
| 251 | |||
| 252 | const [policyPattern, setPolicyPattern] = useState(""); | ||
| 253 | const [policyDecision, setPolicyDecision] = useState<PolicyDecision>("require_approval"); | ||
| 254 | const [policyEditingId, setPolicyEditingId] = useState<PolicyId | null>(null); | ||
| 255 | const [policySearchQuery, setPolicySearchQuery] = useState(""); | ||
| 256 | const [policyStatusText, setPolicyStatusText] = useState<string | null>(null); | ||
| 257 | const [policyBusyId, setPolicyBusyId] = useState<PolicyId | null>(null); | ||
| 258 | const [optimisticPolicies, setOptimisticPolicies] = useState< | ||
| 259 | ReadonlyArray<Policy> | null | ||
| 260 | >(null); | ||
| 261 | |||
| 262 | const [storageScopeType, setStorageScopeType] = | ||
| 263 | useState<StorageScopeType>("scratch"); | ||
| 264 | const [storageDurability, setStorageDurability] = | ||
| 265 | useState<StorageDurability>("ephemeral"); | ||
| 266 | const [storageProvider, setStorageProvider] = | ||
| 267 | useState<StorageInstance["provider"]>("agentfs-local"); | ||
| 268 | const [storagePurposeInput, setStoragePurposeInput] = useState(""); | ||
| 269 | const [storageTtlHoursInput, setStorageTtlHoursInput] = useState("24"); | ||
| 270 | const [storageAccountIdInput, setStorageAccountIdInput] = useState(""); | ||
| 271 | const [storageSearchQuery, setStorageSearchQuery] = useState(""); | ||
| 272 | const [storageStatusText, setStorageStatusText] = useState<string | null>(null); | ||
| 273 | const [storageBusyId, setStorageBusyId] = useState<string | null>(null); | ||
| 274 | const [storageSelectedId, setStorageSelectedId] = useState<StorageInstance["id"] | null>( | ||
| 275 | null, | ||
| 276 | ); | ||
| 277 | const [storageDirectoryPath, setStorageDirectoryPath] = useState("/"); | ||
| 278 | const [storageDirectoryEntries, setStorageDirectoryEntries] = useState< | ||
| 279 | ReadonlyArray<StorageDirectoryEntry> | ||
| 280 | >([]); | ||
| 281 | const [storageDirectoryBusy, setStorageDirectoryBusy] = useState(false); | ||
| 282 | const [storageFilePreviewPath, setStorageFilePreviewPath] = useState<string | null>(null); | ||
| 283 | const [storageFilePreviewContent, setStorageFilePreviewContent] = useState(""); | ||
| 284 | const [storageFilePreviewBusy, setStorageFilePreviewBusy] = useState(false); | ||
| 285 | const [storageKvPrefix, setStorageKvPrefix] = useState(""); | ||
| 286 | const [storageKvLimit, setStorageKvLimit] = useState("100"); | ||
| 287 | const [storageKvItems, setStorageKvItems] = useState< | ||
| 288 | ReadonlyArray<ListStorageKvResult["items"][number]> | ||
| 289 | >([]); | ||
| 290 | const [storageKvBusy, setStorageKvBusy] = useState(false); | ||
| 291 | const [storageSqlText, setStorageSqlText] = useState("SELECT name FROM sqlite_master LIMIT 50"); | ||
| 292 | const [storageSqlMaxRows, setStorageSqlMaxRows] = useState("200"); | ||
| 293 | const [storageSqlResult, setStorageSqlResult] = useState<QueryStorageSqlResult | null>( | ||
| 294 | null, | ||
| 295 | ); | ||
| 296 | const [storageSqlBusy, setStorageSqlBusy] = useState(false); | ||
| 297 | |||
| 298 | const [approvalFilter, setApprovalFilter] = useState<ApprovalFilter>("pending"); | ||
| 299 | const [approvalSearchQuery, setApprovalSearchQuery] = useState(""); | ||
| 300 | const [approvalStatusText, setApprovalStatusText] = useState<string | null>(null); | ||
| 301 | const [approvalBusyId, setApprovalBusyId] = useState<ApprovalId | null>(null); | ||
| 302 | const [optimisticApprovals, setOptimisticApprovals] = useState< | ||
| 303 | ReadonlyArray<Approval> | null | ||
| 304 | >(null); | ||
| 37 | 305 | ||
| 38 | const workspaceId = workspaceIdInput as WorkspaceId; | 306 | const workspaceId = workspaceIdInput as WorkspaceId; |
| 39 | 307 | ||
| @@ -41,35 +309,478 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 1 unmodified line | |||
| 41 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); | 309 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); |
| 42 | const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId)); | 310 | const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId)); |
| 43 | 311 | ||
| 312 | const credentialBindingsState = useAtomValue( | ||
| 313 | credentialBindingsByWorkspace(workspaceId), | ||
| 314 | ); | ||
| 315 | const refreshCredentialBindings = useAtomRefresh( | ||
| 316 | credentialBindingsResultByWorkspace(workspaceId), | ||
| 317 | ); | ||
| 318 | |||
| 319 | const policiesState = useAtomValue(policiesByWorkspace(workspaceId)); | ||
| 320 | const refreshPolicies = useAtomRefresh(policiesResultByWorkspace(workspaceId)); | ||
| 321 | |||
| 322 | const storageState = useAtomValue(storageByWorkspace(workspaceId)); | ||
| 323 | const refreshStorage = useAtomRefresh(storageResultByWorkspace(workspaceId)); | ||
| 324 | |||
| 325 | const approvalsState = useAtomValue(approvalsByWorkspace(workspaceId)); | ||
| 326 | const refreshApprovals = useAtomRefresh(approvalsResultByWorkspace(workspaceId)); | ||
| 327 | |||
| 328 | const organizations = useAtomValue(organizationsState); | ||
| 329 | const refreshOrganizations = useAtomRefresh(organizationsResult); | ||
| 330 | |||
| 331 | const workspaces = useAtomValue(workspacesState); | ||
| 332 | const refreshWorkspaces = useAtomRefresh(workspacesResult); | ||
| 333 | |||
| 334 | const workspaceTools = useAtomValue(workspaceToolsByWorkspace(workspaceId)); | ||
| 335 | const refreshWorkspaceTools = useAtomRefresh( | ||
| 336 | workspaceToolsResultByWorkspace(workspaceId), | ||
| 337 | ); | ||
| 44 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); | 338 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); |
| 45 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); | 339 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); |
| 46 | const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId)); | 340 | const runUpsertCredentialBinding = useAtomSet(upsertCredentialBinding, { |
| 341 | mode: "promise", | ||
| 342 | }); | ||
| 343 | const runRemoveCredentialBinding = useAtomSet(removeCredentialBinding, { | ||
| 344 | mode: "promise", | ||
| 345 | }); | ||
| 346 | const runUpsertPolicy = useAtomSet(upsertPolicy, { mode: "promise" }); | ||
| 347 | const runRemovePolicy = useAtomSet(removePolicy, { mode: "promise" }); | ||
| 348 | const runUpsertOrganization = useAtomSet(upsertOrganization, { mode: "promise" }); | ||
| 349 | const runUpsertWorkspace = useAtomSet(upsertWorkspace, { mode: "promise" }); | ||
| 350 | const runOpenStorageInstance = useAtomSet(openStorageInstance, { mode: "promise" }); | ||
| 351 | const runCloseStorageInstance = useAtomSet(closeStorageInstance, { mode: "promise" }); | ||
| 352 | const runRemoveStorageInstance = useAtomSet(removeStorageInstance, { | ||
| 353 | mode: "promise", | ||
| 354 | }); | ||
| 355 | const runListStorageDirectory = useAtomSet(listStorageDirectory, { | ||
| 356 | mode: "promise", | ||
| 357 | }); | ||
| 358 | const runReadStorageFile = useAtomSet(readStorageFile, { mode: "promise" }); | ||
| 359 | const runListStorageKv = useAtomSet(listStorageKv, { mode: "promise" }); | ||
| 360 | const runQueryStorageSql = useAtomSet(queryStorageSql, { mode: "promise" }); | ||
| 361 | const runResolveApproval = useAtomSet(resolveApproval, { mode: "promise" }); | ||
| 362 | const setOptimisticSources = useAtomSet( | ||
| 363 | optimisticSourcesByWorkspace(workspaceId), | ||
| 364 | ); | ||
| 365 | |||
| 366 | const sourceItems = useMemo( | ||
| 367 | () => sources.items.map(sourceToLegacyRecord), | ||
| 368 | [sources.items], | ||
| 369 | ); | ||
| 370 | |||
| 371 | const credentialItems = useMemo( | ||
| 372 | () => credentialBindingsState.items, | ||
| 373 | [credentialBindingsState.items], | ||
| 374 | ); | ||
| 375 | |||
| 376 | const policyItems = useMemo( | ||
| 377 | () => optimisticPolicies ?? policiesState.items, | ||
| 378 | [optimisticPolicies, policiesState.items], | ||
| 379 | ); | ||
| 380 | |||
| 381 | const storageItems = useMemo( | ||
| 382 | () => storageState.items, | ||
| 383 | [storageState.items], | ||
| 384 | ); | ||
| 385 | |||
| 386 | const selectedStorageInstance = useMemo(() => { | ||
| 387 | if (storageItems.length === 0) { | ||
| 388 | return null; | ||
| 389 | } | ||
| 390 | |||
| 391 | if (storageSelectedId === null) { | ||
| 392 | return storageItems[0] ?? null; | ||
| 393 | } | ||
| 394 | |||
| 395 | return storageItems.find((instance) => instance.id === storageSelectedId) | ||
| 396 | ?? storageItems[0] | ||
| 397 | ?? null; | ||
| 398 | }, [storageItems, storageSelectedId]); | ||
| 399 | |||
| 400 | useEffect(() => { | ||
| 401 | if (selectedStorageInstance === null) { | ||
| 402 | if (storageSelectedId !== null) { | ||
| 403 | setStorageSelectedId(null); | ||
| 404 | } | ||
| 405 | return; | ||
| 406 | } | ||
| 407 | |||
| 408 | if (storageSelectedId !== selectedStorageInstance.id) { | ||
| 409 | setStorageSelectedId(selectedStorageInstance.id); | ||
| 410 | } | ||
| 411 | }, [selectedStorageInstance, storageSelectedId]); | ||
| 412 | |||
| 413 | useEffect(() => { | ||
| 414 | if (workspaceNameInput.trim().length > 0) { | ||
| 415 | return; | ||
| 416 | } | ||
| 417 | |||
| 418 | const current = workspaces.items.find((workspace) => workspace.id === workspaceId); | ||
| 419 | if (!current) { | ||
| 420 | return; | ||
| 421 | } | ||
| 422 | |||
| 423 | setWorkspaceEditIdInput(current.id); | ||
| 424 | setWorkspaceNameInput(current.name); | ||
| 425 | setWorkspaceOrganizationIdInput(current.organizationId ?? ""); | ||
| 426 | }, [workspaceId, workspaceNameInput, workspaces.items]); | ||
| 427 | |||
| 428 | useEffect(() => { | ||
| 429 | if (organizationIdInput.trim().length > 0) { | ||
| 430 | return; | ||
| 431 | } | ||
| 432 | |||
| 433 | const currentWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId); | ||
| 434 | if (!currentWorkspace?.organizationId) { | ||
| 435 | return; | ||
| 436 | } | ||
| 437 | |||
| 438 | const currentOrganization = organizations.items.find( | ||
| 439 | (organization) => organization.id === currentWorkspace.organizationId, | ||
| 440 | ); | ||
| 441 | |||
| 442 | if (!currentOrganization) { | ||
| 443 | return; | ||
| 444 | } | ||
| 445 | |||
| 446 | setOrganizationIdInput(currentOrganization.id); | ||
| 447 | setOrganizationSlugInput(currentOrganization.slug); | ||
| 448 | setOrganizationNameInput(currentOrganization.name); | ||
| 449 | setOrganizationStatusInput(currentOrganization.status); | ||
| 450 | }, [organizationIdInput, organizations.items, workspaceId, workspaces.items]); | ||
| 451 | |||
| 452 | const approvalItems = useMemo( | ||
| 453 | () => optimisticApprovals ?? approvalsState.items, | ||
| 454 | [approvalsState.items, optimisticApprovals], | ||
| 455 | ); | ||
| 456 | |||
| 457 | const toolItems = useMemo(() => workspaceTools.items, [workspaceTools.items]); | ||
| 458 | |||
| 459 | const filteredToolItems = useMemo(() => { | ||
| 460 | const query = toolSearchQuery.trim().toLowerCase(); | ||
| 461 | |||
| 462 | return toolItems.filter((tool) => { | ||
| 463 | if (selectedToolSourceId !== "all" && tool.sourceId !== selectedToolSourceId) { | ||
| 464 | return false; | ||
| 465 | } | ||
| 466 | |||
| 467 | if (query.length === 0) { | ||
| 468 | return true; | ||
| 469 | } | ||
| 470 | |||
| 471 | return ( | ||
| 472 | tool.sourceName.toLowerCase().includes(query) | ||
| 473 | || tool.name.toLowerCase().includes(query) | ||
| 474 | || tool.toolId.toLowerCase().includes(query) | ||
| 475 | || `${tool.method} ${tool.path}`.toLowerCase().includes(query) | ||
| 476 | ); | ||
| 477 | }); | ||
| 478 | }, [selectedToolSourceId, toolItems, toolSearchQuery]); | ||
| 479 | |||
| 480 | const filteredSourceItems = useMemo(() => { | ||
| 481 | const query = searchQuery.trim().toLowerCase(); | ||
| 482 | if (query.length === 0) { | ||
| 483 | return sourceItems; | ||
| 484 | } | ||
| 485 | |||
| 486 | return sourceItems.filter((source) => { | ||
| 487 | return ( | ||
| 488 | source.name.toLowerCase().includes(query) | ||
| 489 | || source.type.toLowerCase().includes(query) | ||
| 490 | || source.endpoint.toLowerCase().includes(query) | ||
| 491 | ); | ||
| 492 | }); | ||
| 493 | }, [searchQuery, sourceItems]); | ||
| 494 | |||
| 495 | const filteredCredentials = useMemo(() => { | ||
| 496 | const query = credentialSearchQuery.trim().toLowerCase(); | ||
| 497 | if (query.length === 0) { | ||
| 498 | return credentialItems; | ||
| 499 | } | ||
| 500 | |||
| 501 | return credentialItems.filter((binding) => | ||
| 502 | binding.sourceKey.toLowerCase().includes(query) | ||
| 503 | || binding.provider.toLowerCase().includes(query) | ||
| 504 | || binding.scopeType.toLowerCase().includes(query) | ||
| 505 | || binding.secretRef.toLowerCase().includes(query), | ||
| 506 | ); | ||
| 507 | }, [credentialItems, credentialSearchQuery]); | ||
| 508 | |||
| 509 | const filteredPolicies = useMemo(() => { | ||
| 510 | const query = policySearchQuery.trim().toLowerCase(); | ||
| 511 | if (query.length === 0) { | ||
| 512 | return policyItems; | ||
| 513 | } | ||
| 514 | |||
| 515 | return policyItems.filter((policy) => | ||
| 516 | policy.toolPathPattern.toLowerCase().includes(query) | ||
| 517 | || policy.decision.toLowerCase().includes(query), | ||
| 518 | ); | ||
| 519 | }, [policyItems, policySearchQuery]); | ||
| 520 | |||
| 521 | const filteredStorageItems = useMemo(() => { | ||
| 522 | const query = storageSearchQuery.trim().toLowerCase(); | ||
| 523 | if (query.length === 0) { | ||
| 524 | return storageItems; | ||
| 525 | } | ||
| 526 | |||
| 527 | return storageItems.filter((storageInstance) => { | ||
| 528 | return ( | ||
| 529 | storageInstance.scopeType.toLowerCase().includes(query) | ||
| 530 | || storageInstance.durability.toLowerCase().includes(query) | ||
| 531 | || storageInstance.status.toLowerCase().includes(query) | ||
| 532 | || storageInstance.provider.toLowerCase().includes(query) | ||
| 533 | || storageInstance.backendKey.toLowerCase().includes(query) | ||
| 534 | || (storageInstance.purpose?.toLowerCase().includes(query) ?? false) | ||
| 535 | ); | ||
| 536 | }); | ||
| 537 | }, [storageItems, storageSearchQuery]); | ||
| 538 | |||
| 539 | const filteredApprovals = useMemo(() => { | ||
| 540 | const query = approvalSearchQuery.trim().toLowerCase(); | ||
| 541 | |||
| 542 | return approvalItems.filter((approval) => { | ||
| 543 | if (approvalFilter === "pending" && approval.status !== "pending") { | ||
| 544 | return false; | ||
| 545 | } | ||
| 546 | |||
| 547 | if ( | ||
| 548 | approvalFilter === "resolved" | ||
| 549 | && (approval.status === "pending" || approval.status === "expired") | ||
| 550 | ) { | ||
| 551 | return false; | ||
| 552 | } | ||
| 553 | |||
| 554 | if (query.length === 0) { | ||
| 555 | return true; | ||
| 556 | } | ||
| 557 | |||
| 558 | return ( | ||
| 559 | approval.toolPath.toLowerCase().includes(query) | ||
| 560 | || approval.status.toLowerCase().includes(query) | ||
| 561 | || approval.callId.toLowerCase().includes(query) | ||
| 562 | || approval.taskRunId.toLowerCase().includes(query) | ||
| 563 | ); | ||
| 564 | }); | ||
| 565 | }, [approvalFilter, approvalItems, approvalSearchQuery]); | ||
| 566 | |||
| 567 | const pendingApprovalCount = useMemo( | ||
| 568 | () => approvalItems.filter((approval) => approval.status === "pending").length, | ||
| 569 | [approvalItems], | ||
| 570 | ); | ||
| 571 | |||
| 572 | const isEditing = Boolean(formState.id); | ||
| 47 | 573 | ||
| 48 | const handleWorkspaceChange = (value: string) => { | 574 | const handleWorkspaceChange = (value: string) => { |
| 49 | setWorkspaceIdInput(value); | 575 | setWorkspaceIdInput(value); |
| 50 | setStatusText(null); | 576 | setStatusText(null); |
| 577 | setCredentialStatusText(null); | ||
| 578 | setPolicyStatusText(null); | ||
| 579 | setStorageStatusText(null); | ||
| 580 | setApprovalStatusText(null); | ||
| 581 | setOrganizationStatusText(null); | ||
| 582 | setWorkspaceStatusText(null); | ||
| 583 | setSearchQuery(""); | ||
| 584 | setToolSearchQuery(""); | ||
| 585 | setCredentialSearchQuery(""); | ||
| 586 | setPolicySearchQuery(""); | ||
| 587 | setStorageSearchQuery(""); | ||
| 588 | setApprovalSearchQuery(""); | ||
| 589 | |||
| 590 | setCredentialSourceKey(""); | ||
| 591 | setCredentialProvider("api_key"); | ||
| 592 | setCredentialScopeType("workspace"); | ||
| 593 | setCredentialIdInput(""); | ||
| 594 | setCredentialSecretRef(""); | ||
| 595 | setCredentialAccountId(""); | ||
| 596 | setCredentialAdditionalHeadersJson(""); | ||
| 597 | setCredentialBoundAuthFingerprint(""); | ||
| 598 | setCredentialEditingId(null); | ||
| 599 | setCredentialBusyId(null); | ||
| 600 | |||
| 601 | setPolicyPattern(""); | ||
| 602 | setPolicyDecision("require_approval"); | ||
| 603 | setPolicyEditingId(null); | ||
| 604 | |||
| 605 | setStorageScopeType("scratch"); | ||
| 606 | setStorageDurability("ephemeral"); | ||
| 607 | setStorageProvider("agentfs-local"); | ||
| 608 | setStoragePurposeInput(""); | ||
| 609 | setStorageTtlHoursInput("24"); | ||
| 610 | setStorageAccountIdInput(""); | ||
| 611 | setStorageBusyId(null); | ||
| 612 | setStorageSelectedId(null); | ||
| 613 | setStorageDirectoryPath("/"); | ||
| 614 | setStorageDirectoryEntries([]); | ||
| 615 | setStorageDirectoryBusy(false); | ||
| 616 | setStorageFilePreviewPath(null); | ||
| 617 | setStorageFilePreviewContent(""); | ||
| 618 | setStorageFilePreviewBusy(false); | ||
| 619 | setStorageKvPrefix(""); | ||
| 620 | setStorageKvLimit("100"); | ||
| 621 | setStorageKvItems([]); | ||
| 622 | setStorageKvBusy(false); | ||
| 623 | setStorageSqlText("SELECT name FROM sqlite_master LIMIT 50"); | ||
| 624 | setStorageSqlMaxRows("200"); | ||
| 625 | setStorageSqlResult(null); | ||
| 626 | setStorageSqlBusy(false); | ||
| 627 | |||
| 628 | setOrganizationIdInput(""); | ||
| 629 | setOrganizationSlugInput(""); | ||
| 630 | setOrganizationNameInput(""); | ||
| 631 | setOrganizationStatusInput("active"); | ||
| 632 | setWorkspaceEditIdInput(""); | ||
| 633 | setWorkspaceNameInput(""); | ||
| 634 | setWorkspaceOrganizationIdInput(""); | ||
| 635 | setSelectedToolSourceId("all"); | ||
| 636 | |||
| 637 | setFormState(defaultFormState()); | ||
| 638 | setOptimisticPolicies(null); | ||
| 639 | setOptimisticApprovals(null); | ||
| 640 | }; | ||
| 641 | |||
| 642 | const setFormField = <K extends keyof LegacySourceFormState>( | ||
| 643 | key: K, | ||
| 644 | value: LegacySourceFormState[K], | ||
| 645 | ) => { | ||
| 646 | setFormState((current) => ({ | ||
| 647 | ...current, | ||
| 648 | [key]: value, | ||
| 649 | })); | ||
| 650 | }; | ||
| 651 | |||
| 652 | const resetForm = () => { | ||
| 653 | setFormState(defaultFormState()); | ||
| 654 | }; | ||
| 655 | |||
| 656 | const handleTemplateUse = (template: (typeof catalogTemplates)[number]) => { | ||
| 657 | setFormState((current) => ({ | ||
| 658 | ...current, | ||
| 659 | id: undefined, | ||
| 660 | name: template.name, | ||
| 661 | type: template.type, | ||
| 662 | endpoint: template.endpoint, | ||
| 663 | baseUrl: "", | ||
| 664 | mcpTransport: "auto", | ||
| 665 | })); | ||
| 666 | setStatusText(`Loaded template for ${template.name}.`); | ||
| 667 | }; | ||
| 668 | |||
| 669 | const handleEdit = (sourceId: SourceId) => { | ||
| 670 | const source = sourceItems.find((item) => item.id === sourceId); | ||
| 671 | if (!source) { | ||
| 672 | return; | ||
| 673 | } | ||
| 674 | |||
| 675 | setFormState(formStateFromSource(source)); | ||
| 676 | setStatusText(`Editing ${source.name}.`); | ||
| 677 | }; | ||
| 678 | |||
| 679 | const handleCancelEdit = () => { | ||
| 680 | resetForm(); | ||
| 681 | setStatusText("Edit cancelled."); | ||
| 682 | }; | ||
| 683 | |||
| 684 | const handleUpsertOrganization = (event: FormEvent<HTMLFormElement>) => { | ||
| 685 | event.preventDefault(); | ||
| 686 | |||
| 687 | const slug = organizationSlugInput.trim(); | ||
| 688 | const name = organizationNameInput.trim(); | ||
| 689 | const id = organizationIdInput.trim(); | ||
| 690 | |||
| 691 | if (slug.length === 0 || name.length === 0) { | ||
| 692 | setOrganizationStatusText("Organization slug and name are required."); | ||
| 693 | return; | ||
| 694 | } | ||
| 695 | |||
| 696 | void runUpsertOrganization({ | ||
| 697 | payload: toOrganizationUpsertPayload({ | ||
| 698 | id: id.length > 0 ? (id as Organization["id"]) : undefined, | ||
| 699 | slug, | ||
| 700 | name, | ||
| 701 | status: organizationStatusInput, | ||
| 702 | }), | ||
| 703 | }) | ||
| 704 | .then((organization) => { | ||
| 705 | setOrganizationStatusText(`Saved organization ${organization.slug}.`); | ||
| 706 | setOrganizationIdInput(organization.id); | ||
| 707 | refreshOrganizations(); | ||
| 708 | }) | ||
| 709 | .catch(() => { | ||
| 710 | setOrganizationStatusText("Organization save failed."); | ||
| 711 | refreshOrganizations(); | ||
| 712 | }); | ||
| 713 | }; | ||
| 714 | |||
| 715 | const handleUpsertWorkspace = (event: FormEvent<HTMLFormElement>) => { | ||
| 716 | event.preventDefault(); | ||
| 717 | |||
| 718 | const name = workspaceNameInput.trim(); | ||
| 719 | const id = workspaceEditIdInput.trim(); | ||
| 720 | const organizationId = workspaceOrganizationIdInput.trim(); | ||
| 721 | |||
| 722 | if (name.length === 0) { | ||
| 723 | setWorkspaceStatusText("Workspace name is required."); | ||
| 724 | return; | ||
| 725 | } | ||
| 726 | |||
| 727 | void runUpsertWorkspace({ | ||
| 728 | payload: toWorkspaceUpsertPayload({ | ||
| 729 | id: id.length > 0 ? (id as Workspace["id"]) : undefined, | ||
| 730 | name, | ||
| 731 | organizationId: | ||
| 732 | organizationId.length > 0 | ||
| 733 | ? (organizationId as Workspace["organizationId"]) | ||
| 734 | : null, | ||
| 735 | }), | ||
| 736 | }) | ||
| 737 | .then((workspace) => { | ||
| 738 | setWorkspaceStatusText(`Saved workspace ${workspace.name}.`); | ||
| 739 | setWorkspaceEditIdInput(workspace.id); | ||
| 740 | setWorkspaceOrganizationIdInput(workspace.organizationId ?? ""); | ||
| 741 | setWorkspaceIdInput(workspace.id); | ||
| 742 | refreshWorkspaces(); | ||
| 743 | }) | ||
| 744 | .catch(() => { | ||
| 745 | setWorkspaceStatusText("Workspace save failed."); | ||
| 746 | refreshWorkspaces(); | ||
| 747 | }); | ||
| 748 | }; | ||
| 749 | |||
| 750 | const handleLoadWorkspace = (workspace: Workspace) => { | ||
| 751 | setWorkspaceEditIdInput(workspace.id); | ||
| 752 | setWorkspaceNameInput(workspace.name); | ||
| 753 | setWorkspaceOrganizationIdInput(workspace.organizationId ?? ""); | ||
| 754 | setWorkspaceIdInput(workspace.id); | ||
| 755 | setWorkspaceStatusText(`Loaded workspace ${workspace.id}.`); | ||
| 756 | }; | ||
| 757 | |||
| 758 | const handleLoadOrganization = (organization: Organization) => { | ||
| 759 | setOrganizationIdInput(organization.id); | ||
| 760 | setOrganizationSlugInput(organization.slug); | ||
| 761 | setOrganizationNameInput(organization.name); | ||
| 762 | setOrganizationStatusInput(organization.status); | ||
| 763 | setOrganizationStatusText(`Loaded organization ${organization.slug}.`); | ||
| 51 | }; | 764 | }; |
| 52 | 765 | ||
| 53 | const handleAddSource = (event: FormEvent<HTMLFormElement>) => { | 766 | const handleUpsertSource = (event: FormEvent<HTMLFormElement>) => { |
| 54 | event.preventDefault(); | 767 | event.preventDefault(); |
| 55 | 768 | ||
| 56 | if (sourcesPending) { | 769 | if (sourcesPending) { |
| 57 | return; | 770 | return; |
| 58 | } | 771 | } |
| 59 | 772 | ||
| 60 | const sourceId = `src_${crypto.randomUUID()}` as SourceId; | 773 | if (formState.name.trim().length === 0 || formState.endpoint.trim().length === 0) { |
| 774 | setStatusText("Name and endpoint are required."); | ||
| 775 | return; | ||
| 776 | } | ||
| 61 | 777 | ||
| 62 | const payload: UpsertSourcePayload = { | 778 | const sourceId = formState.id ?? (createLocalId("src_") as SourceId); |
| 63 | id: sourceId, | 779 | const payload = upsertPayloadFromForm({ |
| 64 | name, | 780 | workspaceId, |
| 65 | kind, | 781 | form: formState, |
| 66 | endpoint, | 782 | sourceId, |
| 67 | enabled: true, | 783 | }); |
| 68 | configJson: "{}", | ||
| 69 | status: "draft", | ||
| 70 | sourceHash: null, | ||
| 71 | lastError: null, | ||
| 72 | }; | ||
| 73 | 784 | ||
| 74 | const previousSources = sources.items; | 785 | const previousSources = sources.items; |
| 75 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); | 786 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); |
| @@ -87,7 +798,12 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 11 unmodified lines | |||
| 87 | payload, | 798 | payload, |
| 88 | }) | 799 | }) |
| 89 | .then(() => { | 800 | .then(() => { |
| 90 | setStatusText("Source saved."); | 801 | setStatusText( |
| 802 | isEditing | ||
| 803 | ? `Updated ${formState.name.trim()}.` | ||
| 804 | : `Saved ${formState.name.trim()}.`, | ||
| 805 | ); | ||
| 806 | resetForm(); | ||
| 91 | refreshSources(); | 807 | refreshSources(); |
| 92 | }) | 808 | }) |
| 93 | .catch(() => { | 809 | .catch(() => { |
| @@ -118,6 +834,9 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 24 unmodified lines | |||
| 118 | }) | 834 | }) |
| 119 | .then(() => { | 835 | .then(() => { |
| 120 | setStatusText("Source removed."); | 836 | setStatusText("Source removed."); |
| 837 | if (formState.id === sourceId) { | ||
| 838 | resetForm(); | ||
| 839 | } | ||
| 121 | refreshSources(); | 840 | refreshSources(); |
| 122 | }) | 841 | }) |
| 123 | .catch(() => { | 842 | .catch(() => { |
| @@ -127,122 +846,2184 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 3 unmodified lines | |||
| 127 | }); | 846 | }); |
| 128 | }; | 847 | }; |
| 129 | 848 | ||
| 130 | return ( | 849 | const handleRefreshTools = async () => { |
| 131 | <main> | 850 | if (sourcesPending || toolsRefreshPending) { |
| 132 | <section className="shell"> | 851 | return; |
| 133 | <header className="hero"> | 852 | } |
| 134 | <div className="hero-top-row"> | ||
| 135 | <h1>Executor v2 Control Plane</h1> | ||
| 136 | {authEnabled ? ( | ||
| 137 | <a className="sign-out-link" href="/sign-out"> | ||
| 138 | Sign out | ||
| 139 | </a> | ||
| 140 | ) : null} | ||
| 141 | </div> | ||
| 142 | <p> | ||
| 143 | Basic Next.js frontend wired to the shared Effect HttpApi client via | ||
| 144 | Effect Atom. | ||
| 145 | </p> | ||
| 146 | </header> | ||
| 147 | |||
| 148 | <div className="grid"> | ||
| 149 | <section className="card"> | ||
| 150 | <h2>Workspace + Source</h2> | ||
| 151 | <form onSubmit={handleAddSource}> | ||
| 152 | <div className="field"> | ||
| 153 | <label htmlFor="workspace-id">Workspace ID</label> | ||
| 154 | <input | ||
| 155 | id="workspace-id" | ||
| 156 | value={workspaceIdInput} | ||
| 157 | onChange={(event) => handleWorkspaceChange(event.target.value)} | ||
| 158 | required | ||
| 159 | /> | ||
| 160 | </div> | ||
| 161 | 853 | ||
| 162 | <div className="field"> | 854 | const openApiSources = sourceItems.filter((source) => source.type === "openapi"); |
| 163 | <label htmlFor="source-name">Name</label> | 855 | if (openApiSources.length === 0) { |
| 164 | <input | 856 | setStatusText("No OpenAPI sources available to refresh."); |
| 165 | id="source-name" | 857 | refreshWorkspaceTools(); |
| 166 | value={name} | 858 | return; |
| 167 | onChange={(event) => setName(event.target.value)} | 859 | } |
| 168 | required | ||
| 169 | /> | ||
| 170 | </div> | ||
| 171 | 860 | ||
| 172 | <div className="field"> | 861 | setToolsRefreshPending(true); |
| 173 | <label htmlFor="source-kind">Kind</label> | 862 | setStatusText( |
| 174 | <select | 863 | `Refreshing tools for ${openApiSources.length} OpenAPI source${ |
| 175 | id="source-kind" | 864 | openApiSources.length === 1 ? "" : "s" |
| 176 | value={kind} | 865 | }...`, |
| 177 | onChange={(event) => setKind(event.target.value as SourceKind)} | 866 | ); |
| 178 | > | ||
| 179 | {kindOptions.map((option) => ( | ||
| 180 | <option key={option} value={option}> | ||
| 181 | {option} | ||
| 182 | </option> | ||
| 183 | ))} | ||
| 184 | </select> | ||
| 185 | </div> | ||
| 186 | 867 | ||
| 187 | <div className="field"> | 868 | try { |
| 188 | <label htmlFor="source-endpoint">Endpoint</label> | 869 | await Promise.all( |
| 189 | <input | 870 | openApiSources.map((source) => |
| 190 | id="source-endpoint" | 871 | runUpsertSource({ |
| 191 | value={endpoint} | 872 | path: { workspaceId }, |
| 192 | onChange={(event) => setEndpoint(event.target.value)} | 873 | payload: upsertPayloadFromForm({ |
| 193 | required | 874 | workspaceId, |
| 194 | /> | 875 | sourceId: source.id, |
| 195 | </div> | 876 | form: formStateFromSource(source), |
| 877 | }), | ||
| 878 | }), | ||
| 879 | ), | ||
| 880 | ); | ||
| 196 | 881 | ||
| 197 | <button className="primary" type="submit" disabled={sourcesPending}> | 882 | setStatusText( |
| 198 | {sourcesPending ? "Saving..." : "Save Source"} | 883 | `Refreshed tools for ${openApiSources.length} OpenAPI source${ |
| 199 | </button> | 884 | openApiSources.length === 1 ? "" : "s" |
| 200 | </form> | 885 | }.`, |
| 886 | ); | ||
| 887 | } catch { | ||
| 888 | setStatusText("Tool refresh failed."); | ||
| 889 | } finally { | ||
| 890 | refreshSources(); | ||
| 891 | refreshWorkspaceTools(); | ||
| 892 | setToolsRefreshPending(false); | ||
| 893 | } | ||
| 894 | }; | ||
| 201 | 895 | ||
| 202 | {statusText ? <p className="status">{statusText}</p> : null} | ||
| 203 | </section> | ||
| 204 | 896 | ||
| 205 | <section className="card"> | 897 | const handleEditCredential = (credentialBindingId: SourceCredentialBinding["id"]) => { |
| 206 | <h2>Sources</h2> | 898 | const binding = credentialItems.find((item) => item.id === credentialBindingId); |
| 899 | if (!binding) { | ||
| 900 | return; | ||
| 901 | } | ||
| 207 | 902 | ||
| 208 | {sources.state === "loading" ? ( | 903 | setCredentialEditingId(binding.id); |
| 209 | <p className="status">Loading sources...</p> | 904 | setCredentialSourceKey(binding.sourceKey); |
| 210 | ) : null} | 905 | setCredentialProvider(binding.provider); |
| 906 | setCredentialScopeType(binding.scopeType); | ||
| 907 | setCredentialIdInput(binding.credentialId); | ||
| 908 | setCredentialSecretRef(binding.secretRef); | ||
| 909 | setCredentialAccountId(binding.accountId ?? ""); | ||
| 910 | setCredentialAdditionalHeadersJson(binding.additionalHeadersJson ?? ""); | ||
| 911 | setCredentialBoundAuthFingerprint(binding.boundAuthFingerprint ?? ""); | ||
| 912 | setCredentialStatusText(`Editing credential binding ${binding.sourceKey}.`); | ||
| 913 | }; | ||
| 211 | 914 | ||
| 212 | {sources.state === "error" ? ( | 915 | const handleCancelCredentialEdit = () => { |
| 213 | <p className="status error">{sources.message}</p> | 916 | setCredentialEditingId(null); |
| 214 | ) : null} | 917 | setCredentialSourceKey(""); |
| 918 | setCredentialProvider("api_key"); | ||
| 919 | setCredentialScopeType("workspace"); | ||
| 920 | setCredentialIdInput(""); | ||
| 921 | setCredentialSecretRef(""); | ||
| 922 | setCredentialAccountId(""); | ||
| 923 | setCredentialAdditionalHeadersJson(""); | ||
| 924 | setCredentialBoundAuthFingerprint(""); | ||
| 925 | setCredentialStatusText("Credential edit cancelled."); | ||
| 926 | }; | ||
| 215 | 927 | ||
| 216 | {sources.state !== "loading" && sources.items.length === 0 ? ( | 928 | const handleUpsertCredential = (event: FormEvent<HTMLFormElement>) => { |
| 217 | <p className="status">No sources yet in this workspace.</p> | 929 | event.preventDefault(); |
| 218 | ) : null} | ||
| 219 | 930 | ||
| 220 | <div className="list"> | 931 | if (credentialBusyId !== null) { |
| 221 | {sources.items.map((source) => ( | 932 | return; |
| 222 | <article className="list-item" key={source.id}> | 933 | } |
| 223 | <header> | 934 | |
| 224 | <div> | 935 | const sourceKey = credentialSourceKey.trim(); |
| 225 | <strong>{source.name}</strong> | 936 | const credentialId = credentialIdInput.trim(); |
| 226 | <div className="meta"> | 937 | const secretRef = credentialSecretRef.trim(); |
| 227 | {source.kind} · {source.status} | 938 | const accountId = credentialAccountId.trim(); |
| 228 | </div> | 939 | |
| 229 | <div className="meta">{source.endpoint}</div> | 940 | if (sourceKey.length === 0 || credentialId.length === 0 || secretRef.length === 0) { |
| 230 | </div> | 941 | setCredentialStatusText("Source key, credential id, and secret ref are required."); |
| 231 | <button | 942 | return; |
| 232 | className="danger" | 943 | } |
| 233 | type="button" | 944 | |
| 234 | onClick={() => handleRemoveSource(source.id)} | 945 | if (credentialScopeType === "account" && accountId.length === 0) { |
| 235 | disabled={sourcesPending} | 946 | setCredentialStatusText("Account scope credentials require account id."); |
| 236 | > | 947 | return; |
| 237 | Remove | 948 | } |
| 238 | </button> | 949 | |
| 239 | </header> | 950 | const requestId = |
| 240 | </article> | 951 | credentialEditingId |
| 241 | ))} | 952 | ?? (createLocalId("credential_binding_") as SourceCredentialBinding["id"]); |
| 242 | </div> | 953 | |
| 243 | </section> | 954 | setCredentialBusyId(requestId); |
| 244 | </div> | 955 | |
| 245 | </section> | 956 | void runUpsertCredentialBinding({ |
| 957 | path: { workspaceId }, | ||
| 958 | payload: toCredentialBindingUpsertPayload({ | ||
| 959 | id: credentialEditingId ?? undefined, | ||
| 960 | credentialId: credentialId as SourceCredentialBinding["credentialId"], | ||
| 961 | scopeType: credentialScopeType, | ||
| 962 | sourceKey, | ||
| 963 | provider: credentialProvider, | ||
| 964 | secretRef, | ||
| 965 | accountId: credentialScopeType === "account" | ||
| 966 | ? (accountId as SourceCredentialBinding["accountId"]) | ||
| 967 | : null, | ||
| 968 | additionalHeadersJson: | ||
| 969 | credentialAdditionalHeadersJson.trim().length > 0 | ||
| 970 | ? credentialAdditionalHeadersJson.trim() | ||
| 971 | : null, | ||
| 972 | boundAuthFingerprint: | ||
| 973 | credentialBoundAuthFingerprint.trim().length > 0 | ||
| 974 | ? credentialBoundAuthFingerprint.trim() | ||
| 975 | : null, | ||
| 976 | }), | ||
| 977 | }) | ||
| 978 | .then(() => { | ||
| 979 | setCredentialStatusText( | ||
| 980 | credentialEditingId | ||
| 981 | ? `Updated credential binding ${sourceKey}.` | ||
| 982 | : `Added credential binding ${sourceKey}.`, | ||
| 983 | ); | ||
| 984 | setCredentialEditingId(null); | ||
| 985 | setCredentialSourceKey(""); | ||
| 986 | setCredentialProvider("api_key"); | ||
| 987 | setCredentialScopeType("workspace"); | ||
| 988 | setCredentialIdInput(""); | ||
| 989 | setCredentialSecretRef(""); | ||
| 990 | setCredentialAccountId(""); | ||
| 991 | setCredentialAdditionalHeadersJson(""); | ||
| 992 | setCredentialBoundAuthFingerprint(""); | ||
| 993 | refreshCredentialBindings(); | ||
| 994 | }) | ||
| 995 | .catch(() => { | ||
| 996 | setCredentialStatusText("Credential save failed."); | ||
| 997 | refreshCredentialBindings(); | ||
| 998 | }) | ||
| 999 | .finally(() => { | ||
| 1000 | setCredentialBusyId(null); | ||
| 1001 | }); | ||
| 1002 | }; | ||
| 1003 | |||
| 1004 | const handleRemoveCredential = (credentialBindingId: SourceCredentialBinding["id"]) => { | ||
| 1005 | if (credentialBusyId !== null) { | ||
| 1006 | return; | ||
| 1007 | } | ||
| 1008 | |||
| 1009 | setCredentialBusyId(credentialBindingId); | ||
| 1010 | |||
| 1011 | void runRemoveCredentialBinding({ | ||
| 1012 | path: { | ||
| 1013 | workspaceId, | ||
| 1014 | credentialBindingId, | ||
| 1015 | }, | ||
| 1016 | }) | ||
| 1017 | .then((result) => { | ||
| 1018 | const removed = toCredentialBindingRemoveResult(result); | ||
| 1019 | setCredentialStatusText( | ||
| 1020 | removed ? "Credential binding removed." : "Credential binding not found.", | ||
| 1021 | ); | ||
| 1022 | |||
| 1023 | if (credentialEditingId === credentialBindingId) { | ||
| 1024 | handleCancelCredentialEdit(); | ||
| 1025 | } | ||
| 1026 | |||
| 1027 | refreshCredentialBindings(); | ||
| 1028 | }) | ||
| 1029 | .catch(() => { | ||
| 1030 | setCredentialStatusText("Credential removal failed."); | ||
| 1031 | refreshCredentialBindings(); | ||
| 1032 | }) | ||
| 1033 | .finally(() => { | ||
| 1034 | setCredentialBusyId(null); | ||
| 1035 | }); | ||
| 1036 | }; | ||
| 1037 | |||
| 1038 | const handleEditPolicy = (policyId: PolicyId) => { | ||
| 1039 | const policy = policyItems.find((item) => item.id === policyId); | ||
| 1040 | if (!policy) { | ||
| 1041 | return; | ||
| 1042 | } | ||
| 1043 | |||
| 1044 | setPolicyEditingId(policy.id); | ||
| 1045 | setPolicyPattern(policy.toolPathPattern); | ||
| 1046 | setPolicyDecision(policy.decision); | ||
| 1047 | setPolicyStatusText(`Editing policy ${policy.toolPathPattern}.`); | ||
| 1048 | }; | ||
| 1049 | |||
| 1050 | const handleCancelPolicyEdit = () => { | ||
| 1051 | setPolicyEditingId(null); | ||
| 1052 | setPolicyPattern(""); | ||
| 1053 | setPolicyDecision("require_approval"); | ||
| 1054 | setPolicyStatusText("Policy edit cancelled."); | ||
| 1055 | }; | ||
| 1056 | |||
| 1057 | const handleUpsertPolicy = (event: FormEvent<HTMLFormElement>) => { | ||
| 1058 | event.preventDefault(); | ||
| 1059 | |||
| 1060 | if (policyBusyId !== null) { | ||
| 1061 | return; | ||
| 1062 | } | ||
| 1063 | |||
| 1064 | const toolPathPattern = policyPattern.trim(); | ||
| 1065 | if (toolPathPattern.length === 0) { | ||
| 1066 | setPolicyStatusText("Tool path pattern is required."); | ||
| 1067 | return; | ||
| 1068 | } | ||
| 1069 | |||
| 1070 | const policyId = policyEditingId ?? (createLocalId("pol_") as PolicyId); | ||
| 1071 | const nextPolicies = optimisticUpsertPolicy(policyItems, { | ||
| 1072 | workspaceId, | ||
| 1073 | policyId, | ||
| 1074 | toolPathPattern, | ||
| 1075 | decision: policyDecision, | ||
| 1076 | }); | ||
| 1077 | |||
| 1078 | setPolicyBusyId(policyId); | ||
| 1079 | setOptimisticPolicies(nextPolicies); | ||
| 1080 | |||
| 1081 | void runUpsertPolicy({ | ||
| 1082 | path: { workspaceId }, | ||
| 1083 | payload: toPolicyUpsertPayload({ | ||
| 1084 | id: policyEditingId ?? undefined, | ||
| 1085 | toolPathPattern, | ||
| 1086 | decision: policyDecision, | ||
| 1087 | }), | ||
| 1088 | }) | ||
| 1089 | .then(() => { | ||
| 1090 | setPolicyStatusText( | ||
| 1091 | policyEditingId | ||
| 1092 | ? `Updated policy ${toolPathPattern}.` | ||
| 1093 | : `Added policy ${toolPathPattern}.`, | ||
| 1094 | ); | ||
| 1095 | setPolicyEditingId(null); | ||
| 1096 | setPolicyPattern(""); | ||
| 1097 | setPolicyDecision("require_approval"); | ||
| 1098 | setOptimisticPolicies(null); | ||
| 1099 | refreshPolicies(); | ||
| 1100 | }) | ||
| 1101 | .catch(() => { | ||
| 1102 | setPolicyStatusText("Policy save failed."); | ||
| 1103 | setOptimisticPolicies(null); | ||
| 1104 | refreshPolicies(); | ||
| 1105 | }) | ||
| 1106 | .finally(() => { | ||
| 1107 | setPolicyBusyId(null); | ||
| 1108 | }); | ||
| 1109 | }; | ||
| 1110 | |||
| 1111 | const handleRemovePolicy = (policyId: PolicyId) => { | ||
| 1112 | if (policyBusyId !== null) { | ||
| 1113 | return; | ||
| 1114 | } | ||
| 1115 | |||
| 1116 | const nextPolicies = optimisticRemovePolicy(policyItems, policyId); | ||
| 1117 | setPolicyBusyId(policyId); | ||
| 1118 | setOptimisticPolicies(nextPolicies); | ||
| 1119 | |||
| 1120 | void runRemovePolicy({ | ||
| 1121 | path: { workspaceId, policyId }, | ||
| 1122 | }) | ||
| 1123 | .then((result) => { | ||
| 1124 | const removed = toPolicyRemoveResult(result); | ||
| 1125 | setPolicyStatusText(removed ? "Policy removed." : "Policy not found."); | ||
| 1126 | if (policyEditingId === policyId) { | ||
| 1127 | setPolicyEditingId(null); | ||
| 1128 | setPolicyPattern(""); | ||
| 1129 | setPolicyDecision("require_approval"); | ||
| 1130 | } | ||
| 1131 | setOptimisticPolicies(null); | ||
| 1132 | refreshPolicies(); | ||
| 1133 | }) | ||
| 1134 | .catch(() => { | ||
| 1135 | setPolicyStatusText("Policy removal failed."); | ||
| 1136 | setOptimisticPolicies(null); | ||
| 1137 | refreshPolicies(); | ||
| 1138 | }) | ||
| 1139 | .finally(() => { | ||
| 1140 | setPolicyBusyId(null); | ||
| 1141 | }); | ||
| 1142 | }; | ||
| 1143 | |||
| 1144 | const handleOpenStorage = (event: FormEvent<HTMLFormElement>) => { | ||
| 1145 | event.preventDefault(); | ||
| 1146 | |||
| 1147 | if (storageBusyId !== null) { | ||
| 1148 | return; | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | const purpose = storagePurposeInput.trim(); | ||
| 1152 | const accountId = storageAccountIdInput.trim(); | ||
| 1153 | const ttlHours = Number.parseInt(storageTtlHoursInput, 10); | ||
| 1154 | |||
| 1155 | if (storageScopeType === "account" && accountId.length === 0) { | ||
| 1156 | setStorageStatusText("Account scope storage requires account id."); | ||
| 1157 | return; | ||
| 1158 | } | ||
| 1159 | |||
| 1160 | if ( | ||
| 1161 | storageDurability === "ephemeral" | ||
| 1162 | && (!Number.isFinite(ttlHours) || ttlHours <= 0) | ||
| 1163 | ) { | ||
| 1164 | setStorageStatusText("Ephemeral storage requires a positive TTL in hours."); | ||
| 1165 | return; | ||
| 1166 | } | ||
| 1167 | |||
| 1168 | setStorageBusyId("create"); | ||
| 1169 | |||
| 1170 | void runOpenStorageInstance({ | ||
| 1171 | path: { workspaceId }, | ||
| 1172 | payload: toOpenStoragePayload({ | ||
| 1173 | scopeType: storageScopeType, | ||
| 1174 | durability: storageDurability, | ||
| 1175 | provider: storageProvider, | ||
| 1176 | purpose: purpose.length > 0 ? purpose : undefined, | ||
| 1177 | ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined, | ||
| 1178 | accountId: | ||
| 1179 | storageScopeType === "account" | ||
| 1180 | ? (accountId as Exclude<StorageInstance["accountId"], null>) | ||
| 1181 | : undefined, | ||
| 1182 | }), | ||
| 1183 | }) | ||
| 1184 | .then((storageInstance) => { | ||
| 1185 | setStorageStatusText(`Opened storage instance ${storageInstance.id}.`); | ||
| 1186 | setStoragePurposeInput(""); | ||
| 1187 | setStorageAccountIdInput(""); | ||
| 1188 | setStorageSelectedId(storageInstance.id); | ||
| 1189 | refreshStorage(); | ||
| 1190 | }) | ||
| 1191 | .catch(() => { | ||
| 1192 | setStorageStatusText("Storage open failed."); | ||
| 1193 | refreshStorage(); | ||
| 1194 | }) | ||
| 1195 | .finally(() => { | ||
| 1196 | setStorageBusyId(null); | ||
| 1197 | }); | ||
| 1198 | }; | ||
| 1199 | |||
| 1200 | const handleCloseStorage = (storageInstanceId: StorageInstance["id"]) => { | ||
| 1201 | if (storageBusyId !== null) { | ||
| 1202 | return; | ||
| 1203 | } | ||
| 1204 | |||
| 1205 | setStorageBusyId(storageInstanceId); | ||
| 1206 | |||
| 1207 | void runCloseStorageInstance({ | ||
| 1208 | path: { | ||
| 1209 | workspaceId, | ||
| 1210 | storageInstanceId, | ||
| 1211 | }, | ||
| 1212 | }) | ||
| 1213 | .then(() => { | ||
| 1214 | setStorageStatusText("Storage instance closed."); | ||
| 1215 | refreshStorage(); | ||
| 1216 | }) | ||
| 1217 | .catch(() => { | ||
| 1218 | setStorageStatusText("Storage close failed."); | ||
| 1219 | refreshStorage(); | ||
| 1220 | }) | ||
| 1221 | .finally(() => { | ||
| 1222 | setStorageBusyId(null); | ||
| 1223 | }); | ||
| 1224 | }; | ||
| 1225 | |||
| 1226 | const handleRemoveStorage = (storageInstanceId: StorageInstance["id"]) => { | ||
| 1227 | if (storageBusyId !== null) { | ||
| 1228 | return; | ||
| 1229 | } | ||
| 1230 | |||
| 1231 | setStorageBusyId(storageInstanceId); | ||
| 1232 | |||
| 1233 | void runRemoveStorageInstance({ | ||
| 1234 | path: { | ||
| 1235 | workspaceId, | ||
| 1236 | storageInstanceId, | ||
| 1237 | }, | ||
| 1238 | }) | ||
| 1239 | .then((result) => { | ||
| 1240 | const removed = toStorageRemoveResult(result); | ||
| 1241 | setStorageStatusText( | ||
| 1242 | removed ? "Storage instance removed." : "Storage instance not found.", | ||
| 1243 | ); | ||
| 1244 | if (storageSelectedId === storageInstanceId) { | ||
| 1245 | setStorageSelectedId(null); | ||
| 1246 | setStorageDirectoryEntries([]); | ||
| 1247 | setStorageFilePreviewPath(null); | ||
| 1248 | setStorageFilePreviewContent(""); | ||
| 1249 | setStorageKvItems([]); | ||
| 1250 | setStorageSqlResult(null); | ||
| 1251 | } | ||
| 1252 | refreshStorage(); | ||
| 1253 | }) | ||
| 1254 | .catch(() => { | ||
| 1255 | setStorageStatusText("Storage removal failed."); | ||
| 1256 | refreshStorage(); | ||
| 1257 | }) | ||
| 1258 | .finally(() => { | ||
| 1259 | setStorageBusyId(null); | ||
| 1260 | }); | ||
| 1261 | }; | ||
| 1262 | |||
| 1263 | const handleListStorageDirectory = (nextPath?: string) => { | ||
| 1264 | if (selectedStorageInstance === null || storageDirectoryBusy) { | ||
| 1265 | return; | ||
| 1266 | } | ||
| 1267 | |||
| 1268 | const pathValue = (nextPath ?? storageDirectoryPath).trim(); | ||
| 1269 | const normalizedPath = pathValue.length > 0 ? pathValue : "/"; | ||
| 1270 | |||
| 1271 | setStorageDirectoryBusy(true); | ||
| 1272 | |||
| 1273 | void runListStorageDirectory({ | ||
| 1274 | path: { | ||
| 1275 | workspaceId, | ||
| 1276 | storageInstanceId: selectedStorageInstance.id, | ||
| 1277 | }, | ||
| 1278 | payload: toListStorageDirectoryPayload({ | ||
| 1279 | path: normalizedPath, | ||
| 1280 | }), | ||
| 1281 | }) | ||
| 1282 | .then((result) => { | ||
| 1283 | const directory = toStorageDirectoryResult(result); | ||
| 1284 | setStorageDirectoryPath(directory.path); | ||
| 1285 | setStorageDirectoryEntries(directory.entries); | ||
| 1286 | setStorageStatusText(`Loaded directory ${directory.path}.`); | ||
| 1287 | }) | ||
| 1288 | .catch(() => { | ||
| 1289 | setStorageStatusText("Directory listing failed."); | ||
| 1290 | }) | ||
| 1291 | .finally(() => { | ||
| 1292 | setStorageDirectoryBusy(false); | ||
| 1293 | }); | ||
| 1294 | }; | ||
| 1295 | |||
| 1296 | const handleReadStorageFile = (filePath: string) => { | ||
| 1297 | if (selectedStorageInstance === null || storageFilePreviewBusy) { | ||
| 1298 | return; | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | setStorageFilePreviewBusy(true); | ||
| 1302 | |||
| 1303 | void runReadStorageFile({ | ||
| 1304 | path: { | ||
| 1305 | workspaceId, | ||
| 1306 | storageInstanceId: selectedStorageInstance.id, | ||
| 1307 | }, | ||
| 1308 | payload: toReadStorageFilePayload({ | ||
| 1309 | path: filePath, | ||
| 1310 | encoding: "utf8", | ||
| 1311 | }), | ||
| 1312 | }) | ||
| 1313 | .then((result) => { | ||
| 1314 | const fileResult = toStorageReadFileResult(result); | ||
| 1315 | setStorageFilePreviewPath(fileResult.path); | ||
| 1316 | setStorageFilePreviewContent(fileResult.content); | ||
| 1317 | }) | ||
| 1318 | .catch(() => { | ||
| 1319 | setStorageStatusText("File read failed."); | ||
| 1320 | }) | ||
| 1321 | .finally(() => { | ||
| 1322 | setStorageFilePreviewBusy(false); | ||
| 1323 | }); | ||
| 1324 | }; | ||
| 1325 | |||
| 1326 | const handleListStorageKv = () => { | ||
| 1327 | if (selectedStorageInstance === null || storageKvBusy) { | ||
| 1328 | return; | ||
| 1329 | } | ||
| 1330 | |||
| 1331 | const parsedLimit = Number.parseInt(storageKvLimit, 10); | ||
| 1332 | |||
| 1333 | setStorageKvBusy(true); | ||
| 1334 | |||
| 1335 | void runListStorageKv({ | ||
| 1336 | path: { | ||
| 1337 | workspaceId, | ||
| 1338 | storageInstanceId: selectedStorageInstance.id, | ||
| 1339 | }, | ||
| 1340 | payload: toListStorageKvPayload({ | ||
| 1341 | prefix: storageKvPrefix.trim(), | ||
| 1342 | limit: Number.isFinite(parsedLimit) ? parsedLimit : undefined, | ||
| 1343 | }), | ||
| 1344 | }) | ||
| 1345 | .then((result) => { | ||
| 1346 | const kvResult = toStorageKvResult(result); | ||
| 1347 | setStorageKvItems(kvResult.items); | ||
| 1348 | }) | ||
| 1349 | .catch(() => { | ||
| 1350 | setStorageStatusText("KV listing failed."); | ||
| 1351 | }) | ||
| 1352 | .finally(() => { | ||
| 1353 | setStorageKvBusy(false); | ||
| 1354 | }); | ||
| 1355 | }; | ||
| 1356 | |||
| 1357 | const handleQueryStorageSql = () => { | ||
| 1358 | if (selectedStorageInstance === null || storageSqlBusy) { | ||
| 1359 | return; | ||
| 1360 | } | ||
| 1361 | |||
| 1362 | const sql = storageSqlText.trim(); | ||
| 1363 | const maxRows = Number.parseInt(storageSqlMaxRows, 10); | ||
| 1364 | |||
| 1365 | if (sql.length === 0) { | ||
| 1366 | setStorageStatusText("SQL query is required."); | ||
| 1367 | return; | ||
| 1368 | } | ||
| 1369 | |||
| 1370 | setStorageSqlBusy(true); | ||
| 1371 | |||
| 1372 | void runQueryStorageSql({ | ||
| 1373 | path: { | ||
| 1374 | workspaceId, | ||
| 1375 | storageInstanceId: selectedStorageInstance.id, | ||
| 1376 | }, | ||
| 1377 | payload: toQueryStorageSqlPayload({ | ||
| 1378 | sql, | ||
| 1379 | maxRows: Number.isFinite(maxRows) ? maxRows : undefined, | ||
| 1380 | }), | ||
| 1381 | }) | ||
| 1382 | .then((result) => { | ||
| 1383 | const sqlResult = toStorageSqlResult(result); | ||
| 1384 | setStorageSqlResult(sqlResult); | ||
| 1385 | }) | ||
| 1386 | .catch(() => { | ||
| 1387 | setStorageStatusText("SQL query failed."); | ||
| 1388 | }) | ||
| 1389 | .finally(() => { | ||
| 1390 | setStorageSqlBusy(false); | ||
| 1391 | }); | ||
| 1392 | }; | ||
| 1393 | |||
| 1394 | const handleSelectStorageInstance = (storageInstanceId: StorageInstance["id"]) => { | ||
| 1395 | setStorageSelectedId(storageInstanceId); | ||
| 1396 | setStorageDirectoryPath("/"); | ||
| 1397 | setStorageDirectoryEntries([]); | ||
| 1398 | setStorageFilePreviewPath(null); | ||
| 1399 | setStorageFilePreviewContent(""); | ||
| 1400 | setStorageKvItems([]); | ||
| 1401 | setStorageSqlResult(null); | ||
| 1402 | }; | ||
| 1403 | |||
| 1404 | const handleResolveApproval = (approvalId: ApprovalId, status: "approved" | "denied") => { | ||
| 1405 | if (approvalBusyId !== null) { | ||
| 1406 | return; | ||
| 1407 | } | ||
| 1408 | |||
| 1409 | setApprovalBusyId(approvalId); | ||
| 1410 | |||
| 1411 | const nextOptimistic = optimisticResolveApproval(approvalItems, { | ||
| 1412 | approvalId, | ||
| 1413 | payload: { | ||
| 1414 | status, | ||
| 1415 | reason: null, | ||
| 1416 | }, | ||
| 1417 | }); | ||
| 1418 | |||
| 1419 | setOptimisticApprovals(nextOptimistic); | ||
| 1420 | |||
| 1421 | void runResolveApproval({ | ||
| 1422 | path: { | ||
| 1423 | workspaceId, | ||
| 1424 | approvalId, | ||
| 1425 | }, | ||
| 1426 | payload: { | ||
| 1427 | status, | ||
| 1428 | reason: null, | ||
| 1429 | }, | ||
| 1430 | }) | ||
| 1431 | .then(() => { | ||
| 1432 | setApprovalStatusText( | ||
| 1433 | status === "approved" ? "Approval granted." : "Approval denied.", | ||
| 1434 | ); | ||
| 1435 | setOptimisticApprovals(null); | ||
| 1436 | refreshApprovals(); | ||
| 1437 | }) | ||
| 1438 | .catch(() => { | ||
| 1439 | setApprovalStatusText("Approval update failed."); | ||
| 1440 | setOptimisticApprovals(null); | ||
| 1441 | refreshApprovals(); | ||
| 1442 | }) | ||
| 1443 | .finally(() => { | ||
| 1444 | setApprovalBusyId(null); | ||
| 1445 | }); | ||
| 1446 | }; | ||
| 1447 | |||
| 1448 | return ( | ||
| 1449 | <main className="min-h-screen py-8 sm:py-10"> | ||
| 1450 | <div className="mx-auto w-full max-w-7xl px-4 sm:px-6"> | ||
| 1451 | <Card className="overflow-hidden border-border/70 bg-card/95 backdrop-blur-sm"> | ||
| 1452 | <CardHeader className="space-y-4 border-b border-dashed border-border/80 pb-5"> | ||
| 1453 | <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> | ||
| 1454 | <div className="space-y-2"> | ||
| 1455 | <CardTitle className="text-2xl sm:text-3xl">Executor v2 Console</CardTitle> | ||
| 1456 | <CardDescription> | ||
| 1457 | Migrated legacy control workflows onto the v2 control-plane APIs. | ||
| 1458 | </CardDescription> | ||
| 1459 | </div> | ||
| 1460 | {authEnabled ? ( | ||
| 1461 | <a | ||
| 1462 | href="/sign-out" | ||
| 1463 | className="inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" | ||
| 1464 | > | ||
| 1465 | Sign out | ||
| 1466 | </a> | ||
| 1467 | ) : null} | ||
| 1468 | </div> | ||
| 1469 | |||
| 1470 | <div className="grid gap-2 sm:max-w-sm"> | ||
| 1471 | <label className="text-xs font-medium uppercase tracking-wide text-muted-foreground" htmlFor="workspace-id"> | ||
| 1472 | Workspace | ||
| 1473 | </label> | ||
| 1474 | <Input | ||
| 1475 | id="workspace-id" | ||
| 1476 | value={workspaceIdInput} | ||
| 1477 | onChange={(event) => handleWorkspaceChange(event.target.value)} | ||
| 1478 | required | ||
| 1479 | /> | ||
| 1480 | </div> | ||
| 1481 | |||
| 1482 | <div className="flex flex-wrap gap-2" role="tablist" aria-label="Console tabs"> | ||
| 1483 | <Button | ||
| 1484 | type="button" | ||
| 1485 | size="sm" | ||
| 1486 | variant={activeTab === "sources" ? "default" : "secondary"} | ||
| 1487 | onClick={() => setActiveTab("sources")} | ||
| 1488 | role="tab" | ||
| 1489 | aria-selected={activeTab === "sources"} | ||
| 1490 | > | ||
| 1491 | Sources | ||
| 1492 | </Button> | ||
| 1493 | <Button | ||
| 1494 | type="button" | ||
| 1495 | size="sm" | ||
| 1496 | variant={activeTab === "tools" ? "default" : "secondary"} | ||
| 1497 | onClick={() => setActiveTab("tools")} | ||
| 1498 | role="tab" | ||
| 1499 | aria-selected={activeTab === "tools"} | ||
| 1500 | > | ||
| 1501 | Tools | ||
| 1502 | </Button> | ||
| 1503 | <Button | ||
| 1504 | type="button" | ||
| 1505 | size="sm" | ||
| 1506 | variant={activeTab === "credentials" ? "default" : "secondary"} | ||
| 1507 | onClick={() => setActiveTab("credentials")} | ||
| 1508 | role="tab" | ||
| 1509 | aria-selected={activeTab === "credentials"} | ||
| 1510 | > | ||
| 1511 | Credentials | ||
| 1512 | </Button> | ||
| 1513 | <Button | ||
| 1514 | type="button" | ||
| 1515 | size="sm" | ||
| 1516 | variant={activeTab === "policies" ? "default" : "secondary"} | ||
| 1517 | onClick={() => setActiveTab("policies")} | ||
| 1518 | role="tab" | ||
| 1519 | aria-selected={activeTab === "policies"} | ||
| 1520 | > | ||
| 1521 | Policies | ||
| 1522 | </Button> | ||
| 1523 | <Button | ||
| 1524 | type="button" | ||
| 1525 | size="sm" | ||
| 1526 | variant={activeTab === "organizations" ? "default" : "secondary"} | ||
| 1527 | onClick={() => setActiveTab("organizations")} | ||
| 1528 | role="tab" | ||
| 1529 | aria-selected={activeTab === "organizations"} | ||
| 1530 | > | ||
| 1531 | Organizations | ||
| 1532 | </Button> | ||
| 1533 | <Button | ||
| 1534 | type="button" | ||
| 1535 | size="sm" | ||
| 1536 | variant={activeTab === "workspaces" ? "default" : "secondary"} | ||
| 1537 | onClick={() => setActiveTab("workspaces")} | ||
| 1538 | role="tab" | ||
| 1539 | aria-selected={activeTab === "workspaces"} | ||
| 1540 | > | ||
| 1541 | Workspaces | ||
| 1542 | </Button> | ||
| 1543 | <Button | ||
| 1544 | type="button" | ||
| 1545 | size="sm" | ||
| 1546 | variant={activeTab === "storage" ? "default" : "secondary"} | ||
| 1547 | onClick={() => setActiveTab("storage")} | ||
| 1548 | role="tab" | ||
| 1549 | aria-selected={activeTab === "storage"} | ||
| 1550 | > | ||
| 1551 | Storage | ||
| 1552 | </Button> | ||
| 1553 | <Button | ||
| 1554 | type="button" | ||
| 1555 | size="sm" | ||
| 1556 | variant={activeTab === "approvals" ? "default" : "secondary"} | ||
| 1557 | onClick={() => setActiveTab("approvals")} | ||
| 1558 | role="tab" | ||
| 1559 | aria-selected={activeTab === "approvals"} | ||
| 1560 | className="gap-2" | ||
| 1561 | > | ||
| 1562 | Approvals | ||
| 1563 | {pendingApprovalCount > 0 ? ( | ||
| 1564 | <span className="inline-flex min-w-[1.2rem] items-center justify-center rounded-full bg-background/20 px-1 text-[10px]"> | ||
| 1565 | {pendingApprovalCount} | ||
| 1566 | </span> | ||
| 1567 | ) : null} | ||
| 1568 | </Button> | ||
| 1569 | </div> | ||
| 1570 | </CardHeader> | ||
| 1571 | |||
| 1572 | <CardContent className="pt-6"> | ||
| 1573 | {activeTab === "sources" ? ( | ||
| 1574 | <div className="grid gap-5 lg:grid-cols-2"> | ||
| 1575 | <Card className="border-border/70"> | ||
| 1576 | <CardHeader className="pb-3"> | ||
| 1577 | <CardTitle>{isEditing ? "Edit Source" : "Add Source"}</CardTitle> | ||
| 1578 | <CardDescription> | ||
| 1579 | Manage endpoints, transport, and auth wiring in one place. | ||
| 1580 | </CardDescription> | ||
| 1581 | </CardHeader> | ||
| 1582 | <CardContent className="space-y-4"> | ||
| 1583 | <div className="grid gap-2 sm:grid-cols-2"> | ||
| 1584 | {catalogTemplates.map((template) => ( | ||
| 1585 | <Button | ||
| 1586 | key={template.name} | ||
| 1587 | type="button" | ||
| 1588 | variant="outline" | ||
| 1589 | size="sm" | ||
| 1590 | className="justify-start" | ||
| 1591 | onClick={() => handleTemplateUse(template)} | ||
| 1592 | > | ||
| 1593 | {template.name} | ||
| 1594 | </Button> | ||
| 1595 | ))} | ||
| 1596 | </div> | ||
| 1597 | |||
| 1598 | <form className="space-y-3" onSubmit={handleUpsertSource}> | ||
| 1599 | <div className="grid gap-1.5"> | ||
| 1600 | <label className="text-xs text-muted-foreground" htmlFor="source-name">Name</label> | ||
| 1601 | <Input | ||
| 1602 | id="source-name" | ||
| 1603 | value={formState.name} | ||
| 1604 | onChange={(event) => setFormField("name", event.target.value)} | ||
| 1605 | required | ||
| 1606 | /> | ||
| 1607 | </div> | ||
| 1608 | |||
| 1609 | <div className="grid gap-1.5"> | ||
| 1610 | <label className="text-xs text-muted-foreground" htmlFor="source-kind">Kind</label> | ||
| 1611 | <Select | ||
| 1612 | id="source-kind" | ||
| 1613 | value={formState.type} | ||
| 1614 | onChange={(event) => | ||
| 1615 | setFormField("type", event.target.value as LegacySourceType) | ||
| 1616 | } | ||
| 1617 | > | ||
| 1618 | {kindOptions.map((option) => ( | ||
| 1619 | <option key={option} value={option}> | ||
| 1620 | {option} | ||
| 1621 | </option> | ||
| 1622 | ))} | ||
| 1623 | </Select> | ||
| 1624 | </div> | ||
| 1625 | |||
| 1626 | <div className="grid gap-1.5"> | ||
| 1627 | <label className="text-xs text-muted-foreground" htmlFor="source-endpoint">Endpoint</label> | ||
| 1628 | <Input | ||
| 1629 | id="source-endpoint" | ||
| 1630 | value={formState.endpoint} | ||
| 1631 | onChange={(event) => setFormField("endpoint", event.target.value)} | ||
| 1632 | placeholder="https://api.example.com/openapi.json" | ||
| 1633 | required | ||
| 1634 | /> | ||
| 1635 | </div> | ||
| 1636 | |||
| 1637 | {formState.type === "openapi" ? ( | ||
| 1638 | <div className="grid gap-1.5"> | ||
| 1639 | <label className="text-xs text-muted-foreground" htmlFor="source-base-url">Base URL</label> | ||
| 1640 | <Input | ||
| 1641 | id="source-base-url" | ||
| 1642 | value={formState.baseUrl} | ||
| 1643 | onChange={(event) => setFormField("baseUrl", event.target.value)} | ||
| 1644 | placeholder="https://api.example.com" | ||
| 1645 | /> | ||
| 1646 | </div> | ||
| 1647 | ) : null} | ||
| 1648 | |||
| 1649 | {formState.type === "mcp" ? ( | ||
| 1650 | <div className="grid gap-1.5"> | ||
| 1651 | <label className="text-xs text-muted-foreground" htmlFor="source-transport">MCP Transport</label> | ||
| 1652 | <Select | ||
| 1653 | id="source-transport" | ||
| 1654 | value={formState.mcpTransport} | ||
| 1655 | onChange={(event) => | ||
| 1656 | setFormField( | ||
| 1657 | "mcpTransport", | ||
| 1658 | event.target.value as "auto" | "streamable-http" | "sse", | ||
| 1659 | ) | ||
| 1660 | } | ||
| 1661 | > | ||
| 1662 | <option value="auto">auto</option> | ||
| 1663 | <option value="streamable-http">streamable-http</option> | ||
| 1664 | <option value="sse">sse</option> | ||
| 1665 | </Select> | ||
| 1666 | </div> | ||
| 1667 | ) : null} | ||
| 1668 | |||
| 1669 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 1670 | <div className="grid gap-1.5"> | ||
| 1671 | <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label> | ||
| 1672 | <Select | ||
| 1673 | id="source-auth-type" | ||
| 1674 | value={formState.authType} | ||
| 1675 | onChange={(event) => | ||
| 1676 | setFormField( | ||
| 1677 | "authType", | ||
| 1678 | event.target.value as "none" | "bearer" | "apiKey" | "basic", | ||
| 1679 | ) | ||
| 1680 | } | ||
| 1681 | > | ||
| 1682 | <option value="none">none</option> | ||
| 1683 | <option value="bearer">bearer</option> | ||
| 1684 | <option value="apiKey">apiKey</option> | ||
| 1685 | <option value="basic">basic</option> | ||
| 1686 | </Select> | ||
| 1687 | </div> | ||
| 1688 | |||
| 1689 | <div className="grid gap-1.5"> | ||
| 1690 | <label className="text-xs text-muted-foreground" htmlFor="source-auth-mode">Auth Scope</label> | ||
| 1691 | <Select | ||
| 1692 | id="source-auth-mode" | ||
| 1693 | value={formState.authMode} | ||
| 1694 | onChange={(event) => | ||
| 1695 | setFormField( | ||
| 1696 | "authMode", | ||
| 1697 | event.target.value as "workspace" | "organization" | "account", | ||
| 1698 | ) | ||
| 1699 | } | ||
| 1700 | > | ||
| 1701 | <option value="workspace">workspace</option> | ||
| 1702 | <option value="organization">organization</option> | ||
| 1703 | <option value="account">account</option> | ||
| 1704 | </Select> | ||
| 1705 | </div> | ||
| 1706 | </div> | ||
| 1707 | |||
| 1708 | {formState.authType === "apiKey" ? ( | ||
| 1709 | <div className="grid gap-1.5"> | ||
| 1710 | <label className="text-xs text-muted-foreground" htmlFor="source-auth-header">API Key Header</label> | ||
| 1711 | <Input | ||
| 1712 | id="source-auth-header" | ||
| 1713 | value={formState.apiKeyHeader} | ||
| 1714 | onChange={(event) => setFormField("apiKeyHeader", event.target.value)} | ||
| 1715 | placeholder="Authorization" | ||
| 1716 | /> | ||
| 1717 | </div> | ||
| 1718 | ) : null} | ||
| 1719 | |||
| 1720 | <div className="flex items-center justify-between rounded-md border border-border bg-muted/35 px-3 py-2"> | ||
| 1721 | <label htmlFor="source-enabled" className="text-xs font-medium">Enabled</label> | ||
| 1722 | <input | ||
| 1723 | id="source-enabled" | ||
| 1724 | checked={formState.enabled} | ||
| 1725 | onChange={(event) => setFormField("enabled", event.target.checked)} | ||
| 1726 | type="checkbox" | ||
| 1727 | className="h-4 w-4 rounded border-input bg-background text-primary" | ||
| 1728 | /> | ||
| 1729 | </div> | ||
| 1730 | |||
| 1731 | <div className="grid gap-2 sm:grid-cols-2"> | ||
| 1732 | <Button type="submit" disabled={sourcesPending}> | ||
| 1733 | {sourcesPending | ||
| 1734 | ? "Saving..." | ||
| 1735 | : isEditing | ||
| 1736 | ? "Save Source" | ||
| 1737 | : "Add Source"} | ||
| 1738 | </Button> | ||
| 1739 | {isEditing ? ( | ||
| 1740 | <Button | ||
| 1741 | type="button" | ||
| 1742 | variant="outline" | ||
| 1743 | onClick={handleCancelEdit} | ||
| 1744 | disabled={sourcesPending} | ||
| 1745 | > | ||
| 1746 | Cancel | ||
| 1747 | </Button> | ||
| 1748 | ) : null} | ||
| 1749 | </div> | ||
| 1750 | </form> | ||
| 1751 | |||
| 1752 | {statusText ? ( | ||
| 1753 | <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> | ||
| 1754 | {statusText} | ||
| 1755 | </div> | ||
| 1756 | ) : null} | ||
| 1757 | </CardContent> | ||
| 1758 | </Card> | ||
| 1759 | |||
| 1760 | <Card className="border-border/70"> | ||
| 1761 | <CardHeader className="pb-3"> | ||
| 1762 | <CardTitle>Sources</CardTitle> | ||
| 1763 | <CardDescription>Review existing sources and update quickly.</CardDescription> | ||
| 1764 | </CardHeader> | ||
| 1765 | <CardContent className="space-y-4"> | ||
| 1766 | <div className="grid gap-1.5"> | ||
| 1767 | <label className="text-xs text-muted-foreground" htmlFor="source-search">Search</label> | ||
| 1768 | <Input | ||
| 1769 | id="source-search" | ||
| 1770 | value={searchQuery} | ||
| 1771 | onChange={(event) => setSearchQuery(event.target.value)} | ||
| 1772 | placeholder="Search by name, kind, endpoint" | ||
| 1773 | /> | ||
| 1774 | </div> | ||
| 1775 | |||
| 1776 | {sources.state === "loading" ? ( | ||
| 1777 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 1778 | Loading sources... | ||
| 1779 | </div> | ||
| 1780 | ) : null} | ||
| 1781 | |||
| 1782 | {sources.state === "error" ? ( | ||
| 1783 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 1784 | {sources.message} | ||
| 1785 | </div> | ||
| 1786 | ) : null} | ||
| 1787 | |||
| 1788 | {sources.state !== "loading" && filteredSourceItems.length === 0 ? ( | ||
| 1789 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 1790 | {sourceItems.length === 0 | ||
| 1791 | ? "No sources yet in this workspace." | ||
| 1792 | : "No sources match your search."} | ||
| 1793 | </div> | ||
| 1794 | ) : null} | ||
| 1795 | |||
| 1796 | <div className="space-y-2"> | ||
| 1797 | {filteredSourceItems.map((source) => ( | ||
| 1798 | <div | ||
| 1799 | key={source.id} | ||
| 1800 | className="rounded-lg border border-border bg-background/70 p-3" | ||
| 1801 | > | ||
| 1802 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | ||
| 1803 | <div className="min-w-0 space-y-1"> | ||
| 1804 | <p className="truncate text-sm font-medium">{source.name}</p> | ||
| 1805 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 1806 | <Badge variant="outline">{source.type}</Badge> | ||
| 1807 | <span>{source.status}</span> | ||
| 1808 | <span>{source.enabled ? "enabled" : "disabled"}</span> | ||
| 1809 | </div> | ||
| 1810 | <p className="break-all text-xs text-muted-foreground"> | ||
| 1811 | {source.endpoint} | ||
| 1812 | </p> | ||
| 1813 | </div> | ||
| 1814 | <div className="flex items-center gap-2"> | ||
| 1815 | <Button | ||
| 1816 | type="button" | ||
| 1817 | size="sm" | ||
| 1818 | variant="outline" | ||
| 1819 | onClick={() => handleEdit(source.id)} | ||
| 1820 | disabled={sourcesPending} | ||
| 1821 | > | ||
| 1822 | Edit | ||
| 1823 | </Button> | ||
| 1824 | <Button | ||
| 1825 | type="button" | ||
| 1826 | size="sm" | ||
| 1827 | variant="destructive" | ||
| 1828 | onClick={() => handleRemoveSource(source.id)} | ||
| 1829 | disabled={sourcesPending} | ||
| 1830 | > | ||
| 1831 | Remove | ||
| 1832 | </Button> | ||
| 1833 | </div> | ||
| 1834 | </div> | ||
| 1835 | </div> | ||
| 1836 | ))} | ||
| 1837 | </div> | ||
| 1838 | </CardContent> | ||
| 1839 | </Card> | ||
| 1840 | </div> | ||
| 1841 | ) : null} | ||
| 1842 | |||
| 1843 | {activeTab === "tools" ? ( | ||
| 1844 | <div className="space-y-5"> | ||
| 1845 | <Card className="border-border/70"> | ||
| 1846 | <CardHeader className="pb-3"> | ||
| 1847 | <CardTitle>Source Tools</CardTitle> | ||
| 1848 | <CardDescription> | ||
| 1849 | Inspect extracted tools for each source in the current workspace. | ||
| 1850 | </CardDescription> | ||
| 1851 | </CardHeader> | ||
| 1852 | <CardContent className="space-y-4"> | ||
| 1853 | <div className="grid gap-3 md:grid-cols-2"> | ||
| 1854 | <div className="grid gap-1.5"> | ||
| 1855 | <label className="text-xs text-muted-foreground" htmlFor="tools-source-filter"> | ||
| 1856 | Source filter | ||
| 1857 | </label> | ||
| 1858 | <Select | ||
| 1859 | id="tools-source-filter" | ||
| 1860 | value={selectedToolSourceId} | ||
| 1861 | onChange={(event) => | ||
| 1862 | setSelectedToolSourceId(event.target.value as SourceId | "all") | ||
| 1863 | } | ||
| 1864 | > | ||
| 1865 | <option value="all">All sources</option> | ||
| 1866 | {sourceItems.map((source) => ( | ||
| 1867 | <option key={source.id} value={source.id}> | ||
| 1868 | {source.name} | ||
| 1869 | </option> | ||
| 1870 | ))} | ||
| 1871 | </Select> | ||
| 1872 | </div> | ||
| 1873 | <div className="grid gap-1.5"> | ||
| 1874 | <label className="text-xs text-muted-foreground" htmlFor="tools-search"> | ||
| 1875 | Search | ||
| 1876 | </label> | ||
| 1877 | <Input | ||
| 1878 | id="tools-search" | ||
| 1879 | value={toolSearchQuery} | ||
| 1880 | onChange={(event) => setToolSearchQuery(event.target.value)} | ||
| 1881 | placeholder="Search tool id, name, path" | ||
| 1882 | /> | ||
| 1883 | </div> | ||
| 1884 | </div> | ||
| 1885 | |||
| 1886 | <div className="flex justify-end"> | ||
| 1887 | <Button | ||
| 1888 | type="button" | ||
| 1889 | variant="outline" | ||
| 1890 | size="sm" | ||
| 1891 | onClick={() => { | ||
| 1892 | void handleRefreshTools(); | ||
| 1893 | }} | ||
| 1894 | disabled={sourcesPending || toolsRefreshPending} | ||
| 1895 | > | ||
| 1896 | {toolsRefreshPending ? "Refreshing..." : "Refresh tools"} | ||
| 1897 | </Button> | ||
| 1898 | </div> | ||
| 1899 | |||
| 1900 | {workspaceTools.state === "loading" ? ( | ||
| 1901 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 1902 | Loading tools... | ||
| 1903 | </div> | ||
| 1904 | ) : null} | ||
| 1905 | |||
| 1906 | {workspaceTools.state === "error" ? ( | ||
| 1907 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 1908 | {workspaceTools.message} | ||
| 1909 | </div> | ||
| 1910 | ) : null} | ||
| 1911 | |||
| 1912 | {workspaceTools.state !== "loading" && filteredToolItems.length === 0 ? ( | ||
| 1913 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 1914 | No tools found for this workspace/source filter. | ||
| 1915 | </div> | ||
| 1916 | ) : null} | ||
| 1917 | |||
| 1918 | <div className="space-y-2"> | ||
| 1919 | {filteredToolItems.map((tool) => ( | ||
| 1920 | <div key={`${tool.sourceId}:${tool.toolId}`} className="rounded-lg border border-border bg-background/70 p-3"> | ||
| 1921 | <div className="space-y-1"> | ||
| 1922 | <p className="text-sm font-medium">{tool.name}</p> | ||
| 1923 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 1924 | <Badge variant="outline">{tool.sourceName}</Badge> | ||
| 1925 | <Badge variant="outline">{tool.method.toUpperCase()}</Badge> | ||
| 1926 | <span className="break-all">{tool.path}</span> | ||
| 1927 | </div> | ||
| 1928 | <p className="break-all text-xs text-muted-foreground">{tool.toolId}</p> | ||
| 1929 | </div> | ||
| 1930 | </div> | ||
| 1931 | ))} | ||
| 1932 | </div> | ||
| 1933 | </CardContent> | ||
| 1934 | </Card> | ||
| 1935 | </div> | ||
| 1936 | ) : null} | ||
| 1937 | |||
| 1938 | {activeTab === "credentials" ? ( | ||
| 1939 | <div className="grid gap-5 lg:grid-cols-2"> | ||
| 1940 | <Card className="border-border/70"> | ||
| 1941 | <CardHeader className="pb-3"> | ||
| 1942 | <CardTitle>{credentialEditingId ? "Edit Credential Binding" : "Add Credential Binding"}</CardTitle> | ||
| 1943 | <CardDescription> | ||
| 1944 | Bind workspace credentials to sources and providers. | ||
| 1945 | </CardDescription> | ||
| 1946 | </CardHeader> | ||
| 1947 | <CardContent className="space-y-4"> | ||
| 1948 | <form className="space-y-3" onSubmit={handleUpsertCredential}> | ||
| 1949 | <div className="grid gap-1.5"> | ||
| 1950 | <label className="text-xs text-muted-foreground" htmlFor="credential-source-key">Source key</label> | ||
| 1951 | <Input | ||
| 1952 | id="credential-source-key" | ||
| 1953 | value={credentialSourceKey} | ||
| 1954 | onChange={(event) => setCredentialSourceKey(event.target.value)} | ||
| 1955 | placeholder="source_github" | ||
| 1956 | required | ||
| 1957 | /> | ||
| 1958 | </div> | ||
| 1959 | |||
| 1960 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 1961 | <div className="grid gap-1.5"> | ||
| 1962 | <label className="text-xs text-muted-foreground" htmlFor="credential-provider">Provider</label> | ||
| 1963 | <Select | ||
| 1964 | id="credential-provider" | ||
| 1965 | value={credentialProvider} | ||
| 1966 | onChange={(event) => | ||
| 1967 | setCredentialProvider(event.target.value as CredentialProvider) | ||
| 1968 | } | ||
| 1969 | > | ||
| 1970 | <option value="api_key">api_key</option> | ||
| 1971 | <option value="bearer">bearer</option> | ||
| 1972 | <option value="oauth2">oauth2</option> | ||
| 1973 | <option value="custom">custom</option> | ||
| 1974 | </Select> | ||
| 1975 | </div> | ||
| 1976 | |||
| 1977 | <div className="grid gap-1.5"> | ||
| 1978 | <label className="text-xs text-muted-foreground" htmlFor="credential-scope-type">Scope</label> | ||
| 1979 | <Select | ||
| 1980 | id="credential-scope-type" | ||
| 1981 | value={credentialScopeType} | ||
| 1982 | onChange={(event) => | ||
| 1983 | setCredentialScopeType(event.target.value as CredentialScopeType) | ||
| 1984 | } | ||
| 1985 | > | ||
| 1986 | <option value="workspace">workspace</option> | ||
| 1987 | <option value="organization">organization</option> | ||
| 1988 | <option value="account">account</option> | ||
| 1989 | </Select> | ||
| 1990 | </div> | ||
| 1991 | </div> | ||
| 1992 | |||
| 1993 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 1994 | <div className="grid gap-1.5"> | ||
| 1995 | <label className="text-xs text-muted-foreground" htmlFor="credential-id">Credential id</label> | ||
| 1996 | <Input | ||
| 1997 | id="credential-id" | ||
| 1998 | value={credentialIdInput} | ||
| 1999 | onChange={(event) => setCredentialIdInput(event.target.value)} | ||
| 2000 | placeholder="cred_123" | ||
| 2001 | required | ||
| 2002 | /> | ||
| 2003 | </div> | ||
| 2004 | |||
| 2005 | <div className="grid gap-1.5"> | ||
| 2006 | <label className="text-xs text-muted-foreground" htmlFor="credential-secret-ref">Secret ref</label> | ||
| 2007 | <Input | ||
| 2008 | id="credential-secret-ref" | ||
| 2009 | value={credentialSecretRef} | ||
| 2010 | onChange={(event) => setCredentialSecretRef(event.target.value)} | ||
| 2011 | placeholder="secrets/github-token" | ||
| 2012 | required | ||
| 2013 | /> | ||
| 2014 | </div> | ||
| 2015 | </div> | ||
| 2016 | |||
| 2017 | {credentialScopeType === "account" ? ( | ||
| 2018 | <div className="grid gap-1.5"> | ||
| 2019 | <label className="text-xs text-muted-foreground" htmlFor="credential-account-id">Account id</label> | ||
| 2020 | <Input | ||
| 2021 | id="credential-account-id" | ||
| 2022 | value={credentialAccountId} | ||
| 2023 | onChange={(event) => setCredentialAccountId(event.target.value)} | ||
| 2024 | placeholder="acct_123" | ||
| 2025 | required | ||
| 2026 | /> | ||
| 2027 | </div> | ||
| 2028 | ) : null} | ||
| 2029 | |||
| 2030 | <div className="grid gap-1.5"> | ||
| 2031 | <label className="text-xs text-muted-foreground" htmlFor="credential-headers-json">Additional headers JSON (optional)</label> | ||
| 2032 | <Input | ||
| 2033 | id="credential-headers-json" | ||
| 2034 | value={credentialAdditionalHeadersJson} | ||
| 2035 | onChange={(event) => setCredentialAdditionalHeadersJson(event.target.value)} | ||
| 2036 | placeholder='{"X-Team": "tools"}' | ||
| 2037 | /> | ||
| 2038 | </div> | ||
| 2039 | |||
| 2040 | <div className="grid gap-1.5"> | ||
| 2041 | <label className="text-xs text-muted-foreground" htmlFor="credential-auth-fingerprint">Bound auth fingerprint (optional)</label> | ||
| 2042 | <Input | ||
| 2043 | id="credential-auth-fingerprint" | ||
| 2044 | value={credentialBoundAuthFingerprint} | ||
| 2045 | onChange={(event) => setCredentialBoundAuthFingerprint(event.target.value)} | ||
| 2046 | placeholder="fingerprint hash" | ||
| 2047 | /> | ||
| 2048 | </div> | ||
| 2049 | |||
| 2050 | <div className="grid gap-2 sm:grid-cols-2"> | ||
| 2051 | <Button type="submit" disabled={credentialBusyId !== null}> | ||
| 2052 | {credentialBusyId !== null | ||
| 2053 | ? "Saving..." | ||
| 2054 | : credentialEditingId | ||
| 2055 | ? "Save Binding" | ||
| 2056 | : "Add Binding"} | ||
| 2057 | </Button> | ||
| 2058 | {credentialEditingId ? ( | ||
| 2059 | <Button | ||
| 2060 | type="button" | ||
| 2061 | variant="outline" | ||
| 2062 | onClick={handleCancelCredentialEdit} | ||
| 2063 | disabled={credentialBusyId !== null} | ||
| 2064 | > | ||
| 2065 | Cancel | ||
| 2066 | </Button> | ||
| 2067 | ) : null} | ||
| 2068 | </div> | ||
| 2069 | </form> | ||
| 2070 | |||
| 2071 | {credentialStatusText ? ( | ||
| 2072 | <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2073 | {credentialStatusText} | ||
| 2074 | </div> | ||
| 2075 | ) : null} | ||
| 2076 | </CardContent> | ||
| 2077 | </Card> | ||
| 2078 | |||
| 2079 | <Card className="border-border/70"> | ||
| 2080 | <CardHeader className="pb-3"> | ||
| 2081 | <CardTitle>Credential Bindings</CardTitle> | ||
| 2082 | <CardDescription>Credentials currently linked to sources.</CardDescription> | ||
| 2083 | </CardHeader> | ||
| 2084 | <CardContent className="space-y-4"> | ||
| 2085 | <div className="grid gap-1.5"> | ||
| 2086 | <label className="text-xs text-muted-foreground" htmlFor="credential-search">Search</label> | ||
| 2087 | <Input | ||
| 2088 | id="credential-search" | ||
| 2089 | value={credentialSearchQuery} | ||
| 2090 | onChange={(event) => setCredentialSearchQuery(event.target.value)} | ||
| 2091 | placeholder="source key, provider, scope, secret" | ||
| 2092 | /> | ||
| 2093 | </div> | ||
| 2094 | |||
| 2095 | {credentialBindingsState.state === "loading" ? ( | ||
| 2096 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2097 | Loading credential bindings... | ||
| 2098 | </div> | ||
| 2099 | ) : null} | ||
| 2100 | |||
| 2101 | {credentialBindingsState.state === "error" ? ( | ||
| 2102 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 2103 | {credentialBindingsState.message} | ||
| 2104 | </div> | ||
| 2105 | ) : null} | ||
| 2106 | |||
| 2107 | {credentialBindingsState.state !== "loading" && filteredCredentials.length === 0 ? ( | ||
| 2108 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2109 | No credential bindings found. | ||
| 2110 | </div> | ||
| 2111 | ) : null} | ||
| 2112 | |||
| 2113 | <div className="space-y-2"> | ||
| 2114 | {filteredCredentials.map((binding) => { | ||
| 2115 | const isBusy = credentialBusyId === binding.id; | ||
| 2116 | |||
| 2117 | return ( | ||
| 2118 | <div | ||
| 2119 | key={binding.id} | ||
| 2120 | className={cn( | ||
| 2121 | "rounded-lg border border-border bg-background/70 p-3", | ||
| 2122 | isBusy && "opacity-80", | ||
| 2123 | )} | ||
| 2124 | > | ||
| 2125 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | ||
| 2126 | <div className="min-w-0 space-y-1"> | ||
| 2127 | <p className="truncate text-sm font-medium">{binding.sourceKey}</p> | ||
| 2128 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 2129 | <Badge variant="outline">{binding.provider}</Badge> | ||
| 2130 | <Badge variant="outline">{binding.scopeType}</Badge> | ||
| 2131 | <span>{binding.credentialId}</span> | ||
| 2132 | </div> | ||
| 2133 | <p className="break-all text-xs text-muted-foreground"> | ||
| 2134 | secret {binding.secretRef} | ||
| 2135 | </p> | ||
| 2136 | {binding.accountId ? ( | ||
| 2137 | <p className="break-all text-xs text-muted-foreground"> | ||
| 2138 | account {binding.accountId} | ||
| 2139 | </p> | ||
| 2140 | ) : null} | ||
| 2141 | </div> | ||
| 2142 | |||
| 2143 | <div className="flex items-center gap-2"> | ||
| 2144 | <Button | ||
| 2145 | type="button" | ||
| 2146 | size="sm" | ||
| 2147 | variant="outline" | ||
| 2148 | onClick={() => handleEditCredential(binding.id)} | ||
| 2149 | disabled={credentialBusyId !== null} | ||
| 2150 | > | ||
| 2151 | Edit | ||
| 2152 | </Button> | ||
| 2153 | <Button | ||
| 2154 | type="button" | ||
| 2155 | size="sm" | ||
| 2156 | variant="destructive" | ||
| 2157 | onClick={() => handleRemoveCredential(binding.id)} | ||
| 2158 | disabled={credentialBusyId !== null} | ||
| 2159 | > | ||
| 2160 | {isBusy ? "Working..." : "Remove"} | ||
| 2161 | </Button> | ||
| 2162 | </div> | ||
| 2163 | </div> | ||
| 2164 | </div> | ||
| 2165 | ); | ||
| 2166 | })} | ||
| 2167 | </div> | ||
| 2168 | </CardContent> | ||
| 2169 | </Card> | ||
| 2170 | </div> | ||
| 2171 | ) : null} | ||
| 2172 | |||
| 2173 | {activeTab === "policies" ? ( | ||
| 2174 | <Card className="border-border/70"> | ||
| 2175 | <CardHeader className="pb-3"> | ||
| 2176 | <CardTitle>Policies</CardTitle> | ||
| 2177 | <CardDescription> | ||
| 2178 | Define default allow/deny/approval behavior by tool path pattern. | ||
| 2179 | </CardDescription> | ||
| 2180 | </CardHeader> | ||
| 2181 | <CardContent className="space-y-4"> | ||
| 2182 | <form className="space-y-3" onSubmit={handleUpsertPolicy}> | ||
| 2183 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 2184 | <div className="grid gap-1.5 sm:col-span-2"> | ||
| 2185 | <label className="text-xs text-muted-foreground" htmlFor="policy-pattern">Tool path pattern</label> | ||
| 2186 | <Input | ||
| 2187 | id="policy-pattern" | ||
| 2188 | value={policyPattern} | ||
| 2189 | onChange={(event) => setPolicyPattern(event.target.value)} | ||
| 2190 | placeholder="source:* or source:github/*" | ||
| 2191 | required | ||
| 2192 | /> | ||
| 2193 | </div> | ||
| 2194 | |||
| 2195 | <div className="grid gap-1.5"> | ||
| 2196 | <label className="text-xs text-muted-foreground" htmlFor="policy-decision">Decision</label> | ||
| 2197 | <Select | ||
| 2198 | id="policy-decision" | ||
| 2199 | value={policyDecision} | ||
| 2200 | onChange={(event) => | ||
| 2201 | setPolicyDecision(event.target.value as PolicyDecision) | ||
| 2202 | } | ||
| 2203 | > | ||
| 2204 | <option value="allow">allow</option> | ||
| 2205 | <option value="require_approval">require_approval</option> | ||
| 2206 | <option value="deny">deny</option> | ||
| 2207 | </Select> | ||
| 2208 | </div> | ||
| 2209 | </div> | ||
| 2210 | |||
| 2211 | <div className="grid gap-2 sm:grid-cols-2"> | ||
| 2212 | <Button type="submit" disabled={policyBusyId !== null}> | ||
| 2213 | {policyBusyId !== null | ||
| 2214 | ? "Saving..." | ||
| 2215 | : policyEditingId | ||
| 2216 | ? "Save Policy" | ||
| 2217 | : "Add Policy"} | ||
| 2218 | </Button> | ||
| 2219 | |||
| 2220 | {policyEditingId ? ( | ||
| 2221 | <Button | ||
| 2222 | type="button" | ||
| 2223 | variant="outline" | ||
| 2224 | onClick={handleCancelPolicyEdit} | ||
| 2225 | disabled={policyBusyId !== null} | ||
| 2226 | > | ||
| 2227 | Cancel | ||
| 2228 | </Button> | ||
| 2229 | ) : null} | ||
| 2230 | </div> | ||
| 2231 | </form> | ||
| 2232 | |||
| 2233 | {policyStatusText ? ( | ||
| 2234 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2235 | {policyStatusText} | ||
| 2236 | </div> | ||
| 2237 | ) : null} | ||
| 2238 | |||
| 2239 | <div className="grid gap-1.5"> | ||
| 2240 | <label className="text-xs text-muted-foreground" htmlFor="policy-search">Search</label> | ||
| 2241 | <Input | ||
| 2242 | id="policy-search" | ||
| 2243 | value={policySearchQuery} | ||
| 2244 | onChange={(event) => setPolicySearchQuery(event.target.value)} | ||
| 2245 | placeholder="pattern or decision" | ||
| 2246 | /> | ||
| 2247 | </div> | ||
| 2248 | |||
| 2249 | {policiesState.state === "loading" ? ( | ||
| 2250 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2251 | Loading policies... | ||
| 2252 | </div> | ||
| 2253 | ) : null} | ||
| 2254 | |||
| 2255 | {policiesState.state === "error" ? ( | ||
| 2256 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 2257 | {policiesState.message} | ||
| 2258 | </div> | ||
| 2259 | ) : null} | ||
| 2260 | |||
| 2261 | {policiesState.state !== "loading" && filteredPolicies.length === 0 ? ( | ||
| 2262 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2263 | No policies configured. | ||
| 2264 | </div> | ||
| 2265 | ) : null} | ||
| 2266 | |||
| 2267 | <div className="space-y-2"> | ||
| 2268 | {filteredPolicies.map((policy) => ( | ||
| 2269 | <div | ||
| 2270 | key={policy.id} | ||
| 2271 | className="rounded-lg border border-border bg-background/70 p-3" | ||
| 2272 | > | ||
| 2273 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | ||
| 2274 | <div className="min-w-0 space-y-1"> | ||
| 2275 | <p className="break-all text-sm font-medium">{policy.toolPathPattern}</p> | ||
| 2276 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 2277 | <Badge variant="outline">{policy.decision}</Badge> | ||
| 2278 | <span>updated {formatTimestamp(policy.updatedAt)}</span> | ||
| 2279 | </div> | ||
| 2280 | </div> | ||
| 2281 | |||
| 2282 | <div className="flex items-center gap-2"> | ||
| 2283 | <Button | ||
| 2284 | type="button" | ||
| 2285 | size="sm" | ||
| 2286 | variant="outline" | ||
| 2287 | onClick={() => handleEditPolicy(policy.id)} | ||
| 2288 | disabled={policyBusyId !== null} | ||
| 2289 | > | ||
| 2290 | Edit | ||
| 2291 | </Button> | ||
| 2292 | <Button | ||
| 2293 | type="button" | ||
| 2294 | size="sm" | ||
| 2295 | variant="destructive" | ||
| 2296 | onClick={() => handleRemovePolicy(policy.id)} | ||
| 2297 | disabled={policyBusyId !== null} | ||
| 2298 | > | ||
| 2299 | Remove | ||
| 2300 | </Button> | ||
| 2301 | </div> | ||
| 2302 | </div> | ||
| 2303 | </div> | ||
| 2304 | ))} | ||
| 2305 | </div> | ||
| 2306 | </CardContent> | ||
| 2307 | </Card> | ||
| 2308 | ) : null} | ||
| 2309 | |||
| 2310 | {activeTab === "organizations" ? ( | ||
| 2311 | <div className="grid gap-5 lg:grid-cols-2"> | ||
| 2312 | <Card className="border-border/70"> | ||
| 2313 | <CardHeader className="pb-3"> | ||
| 2314 | <CardTitle>Organization Profile</CardTitle> | ||
| 2315 | <CardDescription> | ||
| 2316 | Create or update organization records used by workspaces. | ||
| 2317 | </CardDescription> | ||
| 2318 | </CardHeader> | ||
| 2319 | <CardContent className="space-y-4"> | ||
| 2320 | <form className="space-y-3" onSubmit={handleUpsertOrganization}> | ||
| 2321 | <div className="grid gap-1.5"> | ||
| 2322 | <label className="text-xs text-muted-foreground" htmlFor="organization-id">ID (optional)</label> | ||
| 2323 | <Input | ||
| 2324 | id="organization-id" | ||
| 2325 | value={organizationIdInput} | ||
| 2326 | onChange={(event) => setOrganizationIdInput(event.target.value)} | ||
| 2327 | placeholder="org_local" | ||
| 2328 | /> | ||
| 2329 | </div> | ||
| 2330 | <div className="grid gap-1.5"> | ||
| 2331 | <label className="text-xs text-muted-foreground" htmlFor="organization-slug">Slug</label> | ||
| 2332 | <Input | ||
| 2333 | id="organization-slug" | ||
| 2334 | value={organizationSlugInput} | ||
| 2335 | onChange={(event) => setOrganizationSlugInput(event.target.value)} | ||
| 2336 | placeholder="acme" | ||
| 2337 | required | ||
| 2338 | /> | ||
| 2339 | </div> | ||
| 2340 | <div className="grid gap-1.5"> | ||
| 2341 | <label className="text-xs text-muted-foreground" htmlFor="organization-name">Name</label> | ||
| 2342 | <Input | ||
| 2343 | id="organization-name" | ||
| 2344 | value={organizationNameInput} | ||
| 2345 | onChange={(event) => setOrganizationNameInput(event.target.value)} | ||
| 2346 | placeholder="Acme" | ||
| 2347 | required | ||
| 2348 | /> | ||
| 2349 | </div> | ||
| 2350 | <div className="grid gap-1.5"> | ||
| 2351 | <label className="text-xs text-muted-foreground" htmlFor="organization-status">Status</label> | ||
| 2352 | <Select | ||
| 2353 | id="organization-status" | ||
| 2354 | value={organizationStatusInput} | ||
| 2355 | onChange={(event) => | ||
| 2356 | setOrganizationStatusInput(event.target.value as Organization["status"]) | ||
| 2357 | } | ||
| 2358 | > | ||
| 2359 | <option value="active">active</option> | ||
| 2360 | <option value="suspended">suspended</option> | ||
| 2361 | <option value="archived">archived</option> | ||
| 2362 | </Select> | ||
| 2363 | </div> | ||
| 2364 | <div className="flex gap-2"> | ||
| 2365 | <Button type="submit">Save Organization</Button> | ||
| 2366 | <Button type="button" variant="outline" onClick={() => refreshOrganizations()}> | ||
| 2367 | Refresh | ||
| 2368 | </Button> | ||
| 2369 | </div> | ||
| 2370 | </form> | ||
| 2371 | {organizationStatusText ? ( | ||
| 2372 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2373 | {organizationStatusText} | ||
| 2374 | </div> | ||
| 2375 | ) : null} | ||
| 2376 | </CardContent> | ||
| 2377 | </Card> | ||
| 2378 | |||
| 2379 | <Card className="border-border/70"> | ||
| 2380 | <CardHeader className="pb-3"> | ||
| 2381 | <CardTitle>Organizations</CardTitle> | ||
| 2382 | <CardDescription>Available organizations from the control plane.</CardDescription> | ||
| 2383 | </CardHeader> | ||
| 2384 | <CardContent className="space-y-2"> | ||
| 2385 | {organizations.state === "loading" ? ( | ||
| 2386 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2387 | Loading organizations... | ||
| 2388 | </div> | ||
| 2389 | ) : null} | ||
| 2390 | {organizations.state === "error" ? ( | ||
| 2391 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 2392 | {organizations.message} | ||
| 2393 | </div> | ||
| 2394 | ) : null} | ||
| 2395 | {organizations.state !== "loading" && organizations.items.length === 0 ? ( | ||
| 2396 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2397 | No organizations yet. | ||
| 2398 | </div> | ||
| 2399 | ) : null} | ||
| 2400 | {organizations.items.map((organization) => ( | ||
| 2401 | <div key={organization.id} className="rounded-lg border border-border bg-background/70 p-3"> | ||
| 2402 | <div className="flex items-center justify-between gap-3"> | ||
| 2403 | <div className="space-y-1"> | ||
| 2404 | <p className="text-sm font-medium">{organization.name}</p> | ||
| 2405 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 2406 | <Badge variant="outline">{organization.status}</Badge> | ||
| 2407 | <span>{organization.slug}</span> | ||
| 2408 | <span>{organization.id}</span> | ||
| 2409 | </div> | ||
| 2410 | </div> | ||
| 2411 | <Button | ||
| 2412 | type="button" | ||
| 2413 | size="sm" | ||
| 2414 | variant="outline" | ||
| 2415 | onClick={() => handleLoadOrganization(organization)} | ||
| 2416 | > | ||
| 2417 | Load | ||
| 2418 | </Button> | ||
| 2419 | </div> | ||
| 2420 | </div> | ||
| 2421 | ))} | ||
| 2422 | </CardContent> | ||
| 2423 | </Card> | ||
| 2424 | </div> | ||
| 2425 | ) : null} | ||
| 2426 | |||
| 2427 | {activeTab === "workspaces" ? ( | ||
| 2428 | <div className="grid gap-5 lg:grid-cols-2"> | ||
| 2429 | <Card className="border-border/70"> | ||
| 2430 | <CardHeader className="pb-3"> | ||
| 2431 | <CardTitle>Workspace Profile</CardTitle> | ||
| 2432 | <CardDescription>Create or update workspace records.</CardDescription> | ||
| 2433 | </CardHeader> | ||
| 2434 | <CardContent className="space-y-4"> | ||
| 2435 | <form className="space-y-3" onSubmit={handleUpsertWorkspace}> | ||
| 2436 | <div className="grid gap-1.5"> | ||
| 2437 | <label className="text-xs text-muted-foreground" htmlFor="workspace-edit-id">ID (optional)</label> | ||
| 2438 | <Input | ||
| 2439 | id="workspace-edit-id" | ||
| 2440 | value={workspaceEditIdInput} | ||
| 2441 | onChange={(event) => setWorkspaceEditIdInput(event.target.value)} | ||
| 2442 | placeholder="ws_local" | ||
| 2443 | /> | ||
| 2444 | </div> | ||
| 2445 | <div className="grid gap-1.5"> | ||
| 2446 | <label className="text-xs text-muted-foreground" htmlFor="workspace-name">Name</label> | ||
| 2447 | <Input | ||
| 2448 | id="workspace-name" | ||
| 2449 | value={workspaceNameInput} | ||
| 2450 | onChange={(event) => setWorkspaceNameInput(event.target.value)} | ||
| 2451 | placeholder="Local Workspace" | ||
| 2452 | required | ||
| 2453 | /> | ||
| 2454 | </div> | ||
| 2455 | <div className="grid gap-1.5"> | ||
| 2456 | <label className="text-xs text-muted-foreground" htmlFor="workspace-organization-id">Organization ID</label> | ||
| 2457 | <Input | ||
| 2458 | id="workspace-organization-id" | ||
| 2459 | value={workspaceOrganizationIdInput} | ||
| 2460 | onChange={(event) => | ||
| 2461 | setWorkspaceOrganizationIdInput(event.target.value) | ||
| 2462 | } | ||
| 2463 | placeholder="org_local (optional)" | ||
| 2464 | /> | ||
| 2465 | </div> | ||
| 2466 | <div className="flex gap-2"> | ||
| 2467 | <Button type="submit">Save Workspace</Button> | ||
| 2468 | <Button type="button" variant="outline" onClick={() => refreshWorkspaces()}> | ||
| 2469 | Refresh | ||
| 2470 | </Button> | ||
| 2471 | </div> | ||
| 2472 | </form> | ||
| 2473 | {workspaceStatusText ? ( | ||
| 2474 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2475 | {workspaceStatusText} | ||
| 2476 | </div> | ||
| 2477 | ) : null} | ||
| 2478 | </CardContent> | ||
| 2479 | </Card> | ||
| 2480 | |||
| 2481 | <Card className="border-border/70"> | ||
| 2482 | <CardHeader className="pb-3"> | ||
| 2483 | <CardTitle>Workspaces</CardTitle> | ||
| 2484 | <CardDescription>Available workspaces from the control plane.</CardDescription> | ||
| 2485 | </CardHeader> | ||
| 2486 | <CardContent className="space-y-2"> | ||
| 2487 | {workspaces.state === "loading" ? ( | ||
| 2488 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2489 | Loading workspaces... | ||
| 2490 | </div> | ||
| 2491 | ) : null} | ||
| 2492 | {workspaces.state === "error" ? ( | ||
| 2493 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 2494 | {workspaces.message} | ||
| 2495 | </div> | ||
| 2496 | ) : null} | ||
| 2497 | {workspaces.state !== "loading" && workspaces.items.length === 0 ? ( | ||
| 2498 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2499 | No workspaces yet. | ||
| 2500 | </div> | ||
| 2501 | ) : null} | ||
| 2502 | {workspaces.items.map((workspace) => ( | ||
| 2503 | <div key={workspace.id} className="rounded-lg border border-border bg-background/70 p-3"> | ||
| 2504 | <div className="flex items-center justify-between gap-3"> | ||
| 2505 | <div className="space-y-1"> | ||
| 2506 | <p className="text-sm font-medium">{workspace.name}</p> | ||
| 2507 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 2508 | <span>{workspace.id}</span> | ||
| 2509 | <span>org {workspace.organizationId ?? "-"}</span> | ||
| 2510 | </div> | ||
| 2511 | </div> | ||
| 2512 | <Button | ||
| 2513 | type="button" | ||
| 2514 | size="sm" | ||
| 2515 | variant="outline" | ||
| 2516 | onClick={() => handleLoadWorkspace(workspace)} | ||
| 2517 | > | ||
| 2518 | Load | ||
| 2519 | </Button> | ||
| 2520 | </div> | ||
| 2521 | </div> | ||
| 2522 | ))} | ||
| 2523 | </CardContent> | ||
| 2524 | </Card> | ||
| 2525 | </div> | ||
| 2526 | ) : null} | ||
| 2527 | |||
| 2528 | {activeTab === "storage" ? ( | ||
| 2529 | <div className="grid gap-5 lg:grid-cols-2"> | ||
| 2530 | <Card className="border-border/70"> | ||
| 2531 | <CardHeader className="pb-3"> | ||
| 2532 | <CardTitle>Open Storage Instance</CardTitle> | ||
| 2533 | <CardDescription> | ||
| 2534 | Provision workspace storage for files, KV, and SQLite workloads. | ||
| 2535 | </CardDescription> | ||
| 2536 | </CardHeader> | ||
| 2537 | <CardContent className="space-y-4"> | ||
| 2538 | <form className="space-y-3" onSubmit={handleOpenStorage}> | ||
| 2539 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 2540 | <div className="grid gap-1.5"> | ||
| 2541 | <label className="text-xs text-muted-foreground" htmlFor="storage-scope">Scope</label> | ||
| 2542 | <Select | ||
| 2543 | id="storage-scope" | ||
| 2544 | value={storageScopeType} | ||
| 2545 | onChange={(event) => | ||
| 2546 | setStorageScopeType(event.target.value as StorageScopeType) | ||
| 2547 | } | ||
| 2548 | > | ||
| 2549 | <option value="scratch">scratch</option> | ||
| 2550 | <option value="workspace">workspace</option> | ||
| 2551 | <option value="organization">organization</option> | ||
| 2552 | <option value="account">account</option> | ||
| 2553 | </Select> | ||
| 2554 | </div> | ||
| 2555 | |||
| 2556 | <div className="grid gap-1.5"> | ||
| 2557 | <label className="text-xs text-muted-foreground" htmlFor="storage-durability">Durability</label> | ||
| 2558 | <Select | ||
| 2559 | id="storage-durability" | ||
| 2560 | value={storageDurability} | ||
| 2561 | onChange={(event) => | ||
| 2562 | setStorageDurability(event.target.value as StorageDurability) | ||
| 2563 | } | ||
| 2564 | > | ||
| 2565 | <option value="ephemeral">ephemeral</option> | ||
| 2566 | <option value="durable">durable</option> | ||
| 2567 | </Select> | ||
| 2568 | </div> | ||
| 2569 | </div> | ||
| 2570 | |||
| 2571 | <div className="grid gap-1.5"> | ||
| 2572 | <label className="text-xs text-muted-foreground" htmlFor="storage-provider">Provider</label> | ||
| 2573 | <Select | ||
| 2574 | id="storage-provider" | ||
| 2575 | value={storageProvider} | ||
| 2576 | onChange={(event) => | ||
| 2577 | setStorageProvider(event.target.value as StorageInstance["provider"]) | ||
| 2578 | } | ||
| 2579 | > | ||
| 2580 | <option value="agentfs-local">agentfs-local</option> | ||
| 2581 | <option value="agentfs-cloudflare">agentfs-cloudflare</option> | ||
| 2582 | </Select> | ||
| 2583 | </div> | ||
| 2584 | |||
| 2585 | <div className="grid gap-1.5"> | ||
| 2586 | <label className="text-xs text-muted-foreground" htmlFor="storage-purpose">Purpose (optional)</label> | ||
| 2587 | <Input | ||
| 2588 | id="storage-purpose" | ||
| 2589 | value={storagePurposeInput} | ||
| 2590 | onChange={(event) => setStoragePurposeInput(event.target.value)} | ||
| 2591 | placeholder="tool execution workspace" | ||
| 2592 | /> | ||
| 2593 | </div> | ||
| 2594 | |||
| 2595 | {storageDurability === "ephemeral" ? ( | ||
| 2596 | <div className="grid gap-1.5"> | ||
| 2597 | <label className="text-xs text-muted-foreground" htmlFor="storage-ttl-hours">TTL hours</label> | ||
| 2598 | <Input | ||
| 2599 | id="storage-ttl-hours" | ||
| 2600 | value={storageTtlHoursInput} | ||
| 2601 | onChange={(event) => setStorageTtlHoursInput(event.target.value)} | ||
| 2602 | placeholder="24" | ||
| 2603 | inputMode="numeric" | ||
| 2604 | /> | ||
| 2605 | </div> | ||
| 2606 | ) : null} | ||
| 2607 | |||
| 2608 | {storageScopeType === "account" ? ( | ||
| 2609 | <div className="grid gap-1.5"> | ||
| 2610 | <label className="text-xs text-muted-foreground" htmlFor="storage-account-id">Account id</label> | ||
| 2611 | <Input | ||
| 2612 | id="storage-account-id" | ||
| 2613 | value={storageAccountIdInput} | ||
| 2614 | onChange={(event) => setStorageAccountIdInput(event.target.value)} | ||
| 2615 | placeholder="acct_123" | ||
| 2616 | required | ||
| 2617 | /> | ||
| 2618 | </div> | ||
| 2619 | ) : null} | ||
| 2620 | |||
| 2621 | <Button type="submit" disabled={storageBusyId !== null}> | ||
| 2622 | {storageBusyId === "create" ? "Opening..." : "Open Storage"} | ||
| 2623 | </Button> | ||
| 2624 | </form> | ||
| 2625 | |||
| 2626 | {storageStatusText ? ( | ||
| 2627 | <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2628 | {storageStatusText} | ||
| 2629 | </div> | ||
| 2630 | ) : null} | ||
| 2631 | </CardContent> | ||
| 2632 | </Card> | ||
| 2633 | |||
| 2634 | <Card className="border-border/70"> | ||
| 2635 | <CardHeader className="pb-3"> | ||
| 2636 | <CardTitle>Storage Instances</CardTitle> | ||
| 2637 | <CardDescription>Inspect and manage workspace storage instances.</CardDescription> | ||
| 2638 | </CardHeader> | ||
| 2639 | <CardContent className="space-y-4"> | ||
| 2640 | <div className="grid gap-1.5"> | ||
| 2641 | <label className="text-xs text-muted-foreground" htmlFor="storage-search">Search</label> | ||
| 2642 | <Input | ||
| 2643 | id="storage-search" | ||
| 2644 | value={storageSearchQuery} | ||
| 2645 | onChange={(event) => setStorageSearchQuery(event.target.value)} | ||
| 2646 | placeholder="scope, durability, status, provider" | ||
| 2647 | /> | ||
| 2648 | </div> | ||
| 2649 | |||
| 2650 | <div className="grid gap-1.5"> | ||
| 2651 | <label className="text-xs text-muted-foreground" htmlFor="storage-selected">Inspector target</label> | ||
| 2652 | <Select | ||
| 2653 | id="storage-selected" | ||
| 2654 | value={selectedStorageInstance?.id ?? ""} | ||
| 2655 | onChange={(event) => | ||
| 2656 | handleSelectStorageInstance(event.target.value as StorageInstance["id"]) | ||
| 2657 | } | ||
| 2658 | disabled={storageItems.length === 0} | ||
| 2659 | > | ||
| 2660 | {storageItems.length === 0 ? ( | ||
| 2661 | <option value="">No storage instances</option> | ||
| 2662 | ) : ( | ||
| 2663 | storageItems.map((storageInstance) => ( | ||
| 2664 | <option key={storageInstance.id} value={storageInstance.id}> | ||
| 2665 | {storageInstance.id} | ||
| 2666 | </option> | ||
| 2667 | )) | ||
| 2668 | )} | ||
| 2669 | </Select> | ||
| 2670 | </div> | ||
| 2671 | |||
| 2672 | {storageState.state === "loading" ? ( | ||
| 2673 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2674 | Loading storage instances... | ||
| 2675 | </div> | ||
| 2676 | ) : null} | ||
| 2677 | |||
| 2678 | {storageState.state === "error" ? ( | ||
| 2679 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 2680 | {storageState.message} | ||
| 2681 | </div> | ||
| 2682 | ) : null} | ||
| 2683 | |||
| 2684 | {storageState.state !== "loading" && filteredStorageItems.length === 0 ? ( | ||
| 2685 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2686 | No storage instances found. | ||
| 2687 | </div> | ||
| 2688 | ) : null} | ||
| 2689 | |||
| 2690 | <div className="space-y-2"> | ||
| 2691 | {filteredStorageItems.map((storageInstance) => { | ||
| 2692 | const busy = storageBusyId === storageInstance.id; | ||
| 2693 | |||
| 2694 | return ( | ||
| 2695 | <div | ||
| 2696 | key={storageInstance.id} | ||
| 2697 | className={cn( | ||
| 2698 | "rounded-lg border border-border bg-background/70 p-3", | ||
| 2699 | busy && "opacity-80", | ||
| 2700 | )} | ||
| 2701 | > | ||
| 2702 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | ||
| 2703 | <div className="min-w-0 space-y-1"> | ||
| 2704 | <p className="truncate text-sm font-medium">{storageInstance.id}</p> | ||
| 2705 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 2706 | <Badge variant="outline">{storageInstance.scopeType}</Badge> | ||
| 2707 | <Badge variant="outline">{storageInstance.durability}</Badge> | ||
| 2708 | <Badge variant="outline">{storageInstance.status}</Badge> | ||
| 2709 | <span>{storageInstance.provider}</span> | ||
| 2710 | </div> | ||
| 2711 | <p className="break-all text-xs text-muted-foreground"> | ||
| 2712 | backend {storageInstance.backendKey} | ||
| 2713 | </p> | ||
| 2714 | {storageInstance.purpose ? ( | ||
| 2715 | <p className="break-all text-xs text-muted-foreground"> | ||
| 2716 | purpose {storageInstance.purpose} | ||
| 2717 | </p> | ||
| 2718 | ) : null} | ||
| 2719 | <p className="break-all text-xs text-muted-foreground"> | ||
| 2720 | updated {formatTimestamp(storageInstance.updatedAt)} | ||
| 2721 | </p> | ||
| 2722 | </div> | ||
| 2723 | |||
| 2724 | <div className="flex items-center gap-2"> | ||
| 2725 | <Button | ||
| 2726 | type="button" | ||
| 2727 | size="sm" | ||
| 2728 | variant="outline" | ||
| 2729 | onClick={() => handleSelectStorageInstance(storageInstance.id)} | ||
| 2730 | disabled={storageBusyId !== null} | ||
| 2731 | > | ||
| 2732 | Inspect | ||
| 2733 | </Button> | ||
| 2734 | <Button | ||
| 2735 | type="button" | ||
| 2736 | size="sm" | ||
| 2737 | variant="outline" | ||
| 2738 | onClick={() => handleCloseStorage(storageInstance.id)} | ||
| 2739 | disabled={ | ||
| 2740 | storageBusyId !== null || storageInstance.status !== "active" | ||
| 2741 | } | ||
| 2742 | > | ||
| 2743 | Close | ||
| 2744 | </Button> | ||
| 2745 | <Button | ||
| 2746 | type="button" | ||
| 2747 | size="sm" | ||
| 2748 | variant="destructive" | ||
| 2749 | onClick={() => handleRemoveStorage(storageInstance.id)} | ||
| 2750 | disabled={storageBusyId !== null} | ||
| 2751 | > | ||
| 2752 | {busy ? "Working..." : "Remove"} | ||
| 2753 | </Button> | ||
| 2754 | </div> | ||
| 2755 | </div> | ||
| 2756 | </div> | ||
| 2757 | ); | ||
| 2758 | })} | ||
| 2759 | </div> | ||
| 2760 | |||
| 2761 | {selectedStorageInstance ? ( | ||
| 2762 | <div className="space-y-4 rounded-lg border border-border bg-muted/20 p-3"> | ||
| 2763 | <div className="flex flex-wrap items-center justify-between gap-2"> | ||
| 2764 | <p className="text-sm font-medium">Inspector {selectedStorageInstance.id}</p> | ||
| 2765 | <Button | ||
| 2766 | type="button" | ||
| 2767 | size="sm" | ||
| 2768 | variant="outline" | ||
| 2769 | onClick={() => handleListStorageDirectory()} | ||
| 2770 | disabled={storageDirectoryBusy} | ||
| 2771 | > | ||
| 2772 | {storageDirectoryBusy ? "Loading..." : "Load Directory"} | ||
| 2773 | </Button> | ||
| 2774 | </div> | ||
| 2775 | |||
| 2776 | <div className="grid gap-2 sm:grid-cols-[1fr_auto]"> | ||
| 2777 | <Input | ||
| 2778 | value={storageDirectoryPath} | ||
| 2779 | onChange={(event) => setStorageDirectoryPath(event.target.value)} | ||
| 2780 | placeholder="/" | ||
| 2781 | /> | ||
| 2782 | <Button | ||
| 2783 | type="button" | ||
| 2784 | size="sm" | ||
| 2785 | variant="outline" | ||
| 2786 | onClick={() => handleListStorageDirectory()} | ||
| 2787 | disabled={storageDirectoryBusy} | ||
| 2788 | > | ||
| 2789 | Open Path | ||
| 2790 | </Button> | ||
| 2791 | </div> | ||
| 2792 | |||
| 2793 | <div className="max-h-52 space-y-1 overflow-auto rounded-md border border-border bg-background/70 p-2"> | ||
| 2794 | {storageDirectoryEntries.length === 0 ? ( | ||
| 2795 | <p className="text-xs text-muted-foreground">No directory entries loaded.</p> | ||
| 2796 | ) : ( | ||
| 2797 | storageDirectoryEntries.map((entry) => ( | ||
| 2798 | <div | ||
| 2799 | key={`${entry.path}:${entry.kind}`} | ||
| 2800 | className="flex items-center justify-between gap-2 rounded border border-border/60 px-2 py-1" | ||
| 2801 | > | ||
| 2802 | <div className="min-w-0"> | ||
| 2803 | <p className="truncate text-xs font-medium">{entry.name}</p> | ||
| 2804 | <p className="truncate text-[11px] text-muted-foreground">{entry.path}</p> | ||
| 2805 | </div> | ||
| 2806 | {entry.kind === "file" ? ( | ||
| 2807 | <Button | ||
| 2808 | type="button" | ||
| 2809 | size="sm" | ||
| 2810 | variant="outline" | ||
| 2811 | onClick={() => handleReadStorageFile(entry.path)} | ||
| 2812 | disabled={storageFilePreviewBusy} | ||
| 2813 | > | ||
| 2814 | Read | ||
| 2815 | </Button> | ||
| 2816 | ) : ( | ||
| 2817 | <Button | ||
| 2818 | type="button" | ||
| 2819 | size="sm" | ||
| 2820 | variant="outline" | ||
| 2821 | onClick={() => handleListStorageDirectory(entry.path)} | ||
| 2822 | disabled={storageDirectoryBusy} | ||
| 2823 | > | ||
| 2824 | Open | ||
| 2825 | </Button> | ||
| 2826 | )} | ||
| 2827 | </div> | ||
| 2828 | )) | ||
| 2829 | )} | ||
| 2830 | </div> | ||
| 2831 | |||
| 2832 | <div className="space-y-1"> | ||
| 2833 | <p className="text-xs text-muted-foreground"> | ||
| 2834 | File preview {storageFilePreviewPath ?? "-"} | ||
| 2835 | </p> | ||
| 2836 | <pre className="max-h-40 overflow-auto rounded-md border border-border bg-background/70 p-2 text-[11px] text-muted-foreground"> | ||
| 2837 | {storageFilePreviewContent.length > 0 | ||
| 2838 | ? storageFilePreviewContent | ||
| 2839 | : "No file loaded."} | ||
| 2840 | </pre> | ||
| 2841 | </div> | ||
| 2842 | |||
| 2843 | <div className="grid gap-2 sm:grid-cols-[1fr_auto_auto]"> | ||
| 2844 | <Input | ||
| 2845 | value={storageKvPrefix} | ||
| 2846 | onChange={(event) => setStorageKvPrefix(event.target.value)} | ||
| 2847 | placeholder="KV prefix" | ||
| 2848 | /> | ||
| 2849 | <Input | ||
| 2850 | value={storageKvLimit} | ||
| 2851 | onChange={(event) => setStorageKvLimit(event.target.value)} | ||
| 2852 | inputMode="numeric" | ||
| 2853 | placeholder="100" | ||
| 2854 | /> | ||
| 2855 | <Button | ||
| 2856 | type="button" | ||
| 2857 | size="sm" | ||
| 2858 | variant="outline" | ||
| 2859 | onClick={handleListStorageKv} | ||
| 2860 | disabled={storageKvBusy} | ||
| 2861 | > | ||
| 2862 | {storageKvBusy ? "Loading..." : "Load KV"} | ||
| 2863 | </Button> | ||
| 2864 | </div> | ||
| 2865 | |||
| 2866 | <pre className="max-h-40 overflow-auto rounded-md border border-border bg-background/70 p-2 text-[11px] text-muted-foreground"> | ||
| 2867 | {JSON.stringify(storageKvItems, null, 2)} | ||
| 2868 | </pre> | ||
| 2869 | |||
| 2870 | <div className="space-y-2"> | ||
| 2871 | <textarea | ||
| 2872 | className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-xs" | ||
| 2873 | value={storageSqlText} | ||
| 2874 | onChange={(event) => setStorageSqlText(event.target.value)} | ||
| 2875 | /> | ||
| 2876 | <div className="grid gap-2 sm:grid-cols-[1fr_auto]"> | ||
| 2877 | <Input | ||
| 2878 | value={storageSqlMaxRows} | ||
| 2879 | onChange={(event) => setStorageSqlMaxRows(event.target.value)} | ||
| 2880 | inputMode="numeric" | ||
| 2881 | placeholder="200" | ||
| 2882 | /> | ||
| 2883 | <Button | ||
| 2884 | type="button" | ||
| 2885 | size="sm" | ||
| 2886 | variant="outline" | ||
| 2887 | onClick={handleQueryStorageSql} | ||
| 2888 | disabled={storageSqlBusy} | ||
| 2889 | > | ||
| 2890 | {storageSqlBusy ? "Running..." : "Run SQL"} | ||
| 2891 | </Button> | ||
| 2892 | </div> | ||
| 2893 | <pre className="max-h-48 overflow-auto rounded-md border border-border bg-background/70 p-2 text-[11px] text-muted-foreground"> | ||
| 2894 | {JSON.stringify(storageSqlResult, null, 2)} | ||
| 2895 | </pre> | ||
| 2896 | </div> | ||
| 2897 | </div> | ||
| 2898 | ) : null} | ||
| 2899 | </CardContent> | ||
| 2900 | </Card> | ||
| 2901 | </div> | ||
| 2902 | ) : null} | ||
| 2903 | |||
| 2904 | {activeTab === "approvals" ? ( | ||
| 2905 | <Card className="border-border/70"> | ||
| 2906 | <CardHeader className="pb-3"> | ||
| 2907 | <CardTitle>Approvals</CardTitle> | ||
| 2908 | <CardDescription>Review and resolve pending tool approvals.</CardDescription> | ||
| 2909 | </CardHeader> | ||
| 2910 | <CardContent className="space-y-4"> | ||
| 2911 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 2912 | <div className="grid gap-1.5"> | ||
| 2913 | <label className="text-xs text-muted-foreground" htmlFor="approval-filter">Filter</label> | ||
| 2914 | <Select | ||
| 2915 | id="approval-filter" | ||
| 2916 | value={approvalFilter} | ||
| 2917 | onChange={(event) => | ||
| 2918 | setApprovalFilter(event.target.value as ApprovalFilter) | ||
| 2919 | } | ||
| 2920 | > | ||
| 2921 | <option value="pending">pending</option> | ||
| 2922 | <option value="resolved">resolved</option> | ||
| 2923 | <option value="all">all</option> | ||
| 2924 | </Select> | ||
| 2925 | </div> | ||
| 2926 | |||
| 2927 | <div className="grid gap-1.5"> | ||
| 2928 | <label className="text-xs text-muted-foreground" htmlFor="approval-search">Search</label> | ||
| 2929 | <Input | ||
| 2930 | id="approval-search" | ||
| 2931 | value={approvalSearchQuery} | ||
| 2932 | onChange={(event) => setApprovalSearchQuery(event.target.value)} | ||
| 2933 | placeholder="tool path, status, call id" | ||
| 2934 | /> | ||
| 2935 | </div> | ||
| 2936 | </div> | ||
| 2937 | |||
| 2938 | {approvalStatusText ? ( | ||
| 2939 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2940 | {approvalStatusText} | ||
| 2941 | </div> | ||
| 2942 | ) : null} | ||
| 2943 | |||
| 2944 | {approvalsState.state === "loading" ? ( | ||
| 2945 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2946 | Loading approvals... | ||
| 2947 | </div> | ||
| 2948 | ) : null} | ||
| 2949 | |||
| 2950 | {approvalsState.state === "error" ? ( | ||
| 2951 | <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive"> | ||
| 2952 | {approvalsState.message} | ||
| 2953 | </div> | ||
| 2954 | ) : null} | ||
| 2955 | |||
| 2956 | {approvalsState.state !== "loading" && filteredApprovals.length === 0 ? ( | ||
| 2957 | <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground"> | ||
| 2958 | No approvals for this filter. | ||
| 2959 | </div> | ||
| 2960 | ) : null} | ||
| 2961 | |||
| 2962 | <div className="space-y-2"> | ||
| 2963 | {filteredApprovals.map((approval) => { | ||
| 2964 | const busy = approvalBusyId === approval.id; | ||
| 2965 | const canResolve = approval.status === "pending"; | ||
| 2966 | |||
| 2967 | return ( | ||
| 2968 | <div | ||
| 2969 | className={cn( | ||
| 2970 | "rounded-lg border border-border bg-background/70 p-3", | ||
| 2971 | busy && "opacity-80", | ||
| 2972 | )} | ||
| 2973 | key={approval.id} | ||
| 2974 | > | ||
| 2975 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | ||
| 2976 | <div className="min-w-0 space-y-1"> | ||
| 2977 | <p className="break-all text-sm font-medium">{approval.toolPath}</p> | ||
| 2978 | <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> | ||
| 2979 | <Badge variant={statusBadgeVariant(approval.status)}> | ||
| 2980 | {approval.status} | ||
| 2981 | </Badge> | ||
| 2982 | <span>requested {formatTimestamp(approval.requestedAt)}</span> | ||
| 2983 | <span>resolved {formatTimestamp(approval.resolvedAt)}</span> | ||
| 2984 | </div> | ||
| 2985 | <p className="break-all text-xs text-muted-foreground">task {approval.taskRunId}</p> | ||
| 2986 | <p className="break-all text-xs text-muted-foreground">call {approval.callId}</p> | ||
| 2987 | {approval.reason ? ( | ||
| 2988 | <p className="break-all text-xs text-muted-foreground">reason: {approval.reason}</p> | ||
| 2989 | ) : null} | ||
| 2990 | </div> | ||
| 2991 | |||
| 2992 | <div className="flex items-center gap-2"> | ||
| 2993 | <Button | ||
| 2994 | type="button" | ||
| 2995 | size="sm" | ||
| 2996 | variant="outline" | ||
| 2997 | onClick={() => handleResolveApproval(approval.id, "approved")} | ||
| 2998 | disabled={!canResolve || busy} | ||
| 2999 | > | ||
| 3000 | {busy && canResolve ? "Working..." : "Approve"} | ||
| 3001 | </Button> | ||
| 3002 | <Button | ||
| 3003 | type="button" | ||
| 3004 | size="sm" | ||
| 3005 | variant="destructive" | ||
| 3006 | onClick={() => handleResolveApproval(approval.id, "denied")} | ||
| 3007 | disabled={!canResolve || busy} | ||
| 3008 | > | ||
| 3009 | Deny | ||
| 3010 | </Button> | ||
| 3011 | </div> | ||
| 3012 | </div> | ||
| 3013 | |||
| 3014 | <pre className="mt-3 max-h-56 overflow-auto rounded-md border border-border bg-muted/45 p-3 text-[11px] leading-5 text-muted-foreground"> | ||
| 3015 | {previewFromInputJson(approval.inputPreviewJson)} | ||
| 3016 | </pre> | ||
| 3017 | </div> | ||
| 3018 | ); | ||
| 3019 | })} | ||
| 3020 | </div> | ||
| 3021 | </CardContent> | ||
| 3022 | </Card> | ||
| 3023 | ) : null} | ||
| 3024 | </CardContent> | ||
| 3025 | </Card> | ||
| 3026 | </div> | ||
| 246 | </main> | 3027 | </main> |
| 247 | ); | 3028 | ); |
| 248 | }; | 3029 | }; |
| @@ -0,0 +1,20 @@ | |||
| 1 | { | ||
| 2 | "$schema": "https://ui.shadcn.com/schema.json", | ||
| 3 | "style": "new-york", | ||
| 4 | "rsc": true, | ||
| 5 | "tsx": true, | ||
| 6 | "tailwind": { | ||
| 7 | "config": "", | ||
| 8 | "css": "app/globals.css", | ||
| 9 | "baseColor": "neutral", | ||
| 10 | "cssVariables": true, | ||
| 11 | "prefix": "" | ||
| 12 | }, | ||
| 13 | "iconLibrary": "lucide", | ||
| 14 | "aliases": { | ||
| 15 | "components": "./components", | ||
| 16 | "utils": "./lib/utils", | ||
| 17 | "ui": "./components/ui", | ||
| 18 | "lib": "./lib" | ||
| 19 | } | ||
| 20 | } | ||
| @@ -0,0 +1,29 @@ | |||
| 1 | import type { HTMLAttributes } from "react"; | ||
| 2 | import { cva, type VariantProps } from "class-variance-authority"; | ||
| 3 | |||
| 4 | import { cn } from "../../lib/utils"; | ||
| 5 | |||
| 6 | const badgeVariants = cva( | ||
| 7 | "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium", | ||
| 8 | { | ||
| 9 | variants: { | ||
| 10 | variant: { | ||
| 11 | default: "border-transparent bg-primary text-primary-foreground", | ||
| 12 | secondary: "border-transparent bg-secondary text-secondary-foreground", | ||
| 13 | outline: "border-border text-foreground", | ||
| 14 | pending: "border-emerald-700/20 bg-emerald-600/10 text-emerald-800", | ||
| 15 | approved: "border-sky-700/20 bg-sky-600/10 text-sky-800", | ||
| 16 | denied: "border-destructive/20 bg-destructive/10 text-destructive", | ||
| 17 | }, | ||
| 18 | }, | ||
| 19 | defaultVariants: { | ||
| 20 | variant: "default", | ||
| 21 | }, | ||
| 22 | }, | ||
| 23 | ); | ||
| 24 | |||
| 25 | type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>; | ||
| 26 | |||
| 27 | export function Badge({ className, variant, ...props }: BadgeProps) { | ||
| 28 | return <span className={cn(badgeVariants({ variant }), className)} {...props} />; | ||
| 29 | } | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | import type { ButtonHTMLAttributes } from "react"; | ||
| 2 | import { cva, type VariantProps } from "class-variance-authority"; | ||
| 3 | |||
| 4 | import { cn } from "../../lib/utils"; | ||
| 5 | |||
| 6 | const buttonVariants = cva( | ||
| 7 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1", | ||
| 8 | { | ||
| 9 | variants: { | ||
| 10 | variant: { | ||
| 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", | ||
| 12 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", | ||
| 13 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", | ||
| 14 | ghost: "hover:bg-accent hover:text-accent-foreground", | ||
| 15 | destructive: "bg-destructive text-white hover:bg-destructive/90", | ||
| 16 | }, | ||
| 17 | size: { | ||
| 18 | default: "h-9 px-4 py-2", | ||
| 19 | sm: "h-8 rounded-md px-3 text-xs", | ||
| 20 | lg: "h-10 rounded-md px-6", | ||
| 21 | }, | ||
| 22 | }, | ||
| 23 | defaultVariants: { | ||
| 24 | variant: "default", | ||
| 25 | size: "default", | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | ); | ||
| 29 | |||
| 30 | type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> | ||
| 31 | & VariantProps<typeof buttonVariants>; | ||
| 32 | |||
| 33 | export function Button({ className, variant, size, ...props }: ButtonProps) { | ||
| 34 | return ( | ||
| 35 | <button | ||
| 36 | className={cn(buttonVariants({ variant, size }), className)} | ||
| 37 | {...props} | ||
| 38 | /> | ||
| 39 | ); | ||
| 40 | } | ||
| @@ -0,0 +1,41 @@ | |||
| 1 | import type { HTMLAttributes } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "../../lib/utils"; | ||
| 4 | |||
| 5 | export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) { | ||
| 6 | return ( | ||
| 7 | <div | ||
| 8 | className={cn( | ||
| 9 | "rounded-xl border bg-card text-card-foreground shadow-sm", | ||
| 10 | className, | ||
| 11 | )} | ||
| 12 | {...props} | ||
| 13 | /> | ||
| 14 | ); | ||
| 15 | } | ||
| 16 | |||
| 17 | export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) { | ||
| 18 | return <div className={cn("px-6 pt-5 pb-3", className)} {...props} />; | ||
| 19 | } | ||
| 20 | |||
| 21 | export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) { | ||
| 22 | return ( | ||
| 23 | <h2 | ||
| 24 | className={cn("font-display text-lg font-semibold tracking-tight", className)} | ||
| 25 | {...props} | ||
| 26 | /> | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | |||
| 30 | export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) { | ||
| 31 | return ( | ||
| 32 | <p | ||
| 33 | className={cn("text-sm text-muted-foreground", className)} | ||
| 34 | {...props} | ||
| 35 | /> | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 39 | export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) { | ||
| 40 | return <div className={cn("px-6 pb-6", className)} {...props} />; | ||
| 41 | } | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | import type { InputHTMLAttributes } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "../../lib/utils"; | ||
| 4 | |||
| 5 | export function Input({ className, type = "text", ...props }: InputHTMLAttributes<HTMLInputElement>) { | ||
| 6 | return ( | ||
| 7 | <input | ||
| 8 | type={type} | ||
| 9 | className={cn( | ||
| 10 | "flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50", | ||
| 11 | className, | ||
| 12 | )} | ||
| 13 | {...props} | ||
| 14 | /> | ||
| 15 | ); | ||
| 16 | } | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | import type { SelectHTMLAttributes } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "../../lib/utils"; | ||
| 4 | |||
| 5 | export function Select({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) { | ||
| 6 | return ( | ||
| 7 | <select | ||
| 8 | className={cn( | ||
| 9 | "flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50", | ||
| 10 | className, | ||
| 11 | )} | ||
| 12 | {...props} | ||
| 13 | > | ||
| 14 | {children} | ||
| 15 | </select> | ||
| 16 | ); | ||
| 17 | } | ||
| @@ -1,6 +1,52 @@ | |||
| 1 | import { Atom, Result } from "@effect-atom/atom"; | 1 | import { Atom, Result } from "@effect-atom/atom"; |
| 2 | import type { UpsertSourcePayload } from "@executor-v2/management-api"; | 2 | import type { ResolveApprovalPayload } from "@executor-v2/management-api/approvals/api"; |
| 3 | import type { Source, SourceId, WorkspaceId } from "@executor-v2/schema"; | 3 | import type { |
| 4 | RemoveCredentialBindingResult, | ||
| 5 | UpsertCredentialBindingPayload, | ||
| 6 | } from "@executor-v2/management-api/credentials/api"; | ||
| 7 | import type { | ||
| 8 | UpsertOrganizationPayload, | ||
| 9 | } from "@executor-v2/management-api/organizations/api"; | ||
| 10 | import type { | ||
| 11 | RemovePolicyResult, | ||
| 12 | UpsertPolicyPayload, | ||
| 13 | } from "@executor-v2/management-api/policies/api"; | ||
| 14 | import type { | ||
| 15 | ListStorageDirectoryPayload, | ||
| 16 | ListStorageDirectoryResult, | ||
| 17 | ListStorageKvPayload, | ||
| 18 | ListStorageKvResult, | ||
| 19 | OpenStorageInstancePayload, | ||
| 20 | QueryStorageSqlPayload, | ||
| 21 | QueryStorageSqlResult, | ||
| 22 | ReadStorageFilePayload, | ||
| 23 | ReadStorageFileResult, | ||
| 24 | RemoveStorageInstanceResult, | ||
| 25 | } from "@executor-v2/management-api/storage/api"; | ||
| 26 | import type { UpsertSourcePayload } from "@executor-v2/management-api/sources/api"; | ||
| 27 | import type { SourceToolSummary } from "@executor-v2/management-api/tools/api"; | ||
| 28 | import type { | ||
| 29 | UpsertWorkspacePayload, | ||
| 30 | } from "@executor-v2/management-api/workspaces/api"; | ||
| 31 | import type { | ||
| 32 | Approval, | ||
| 33 | ApprovalId, | ||
| 34 | CredentialBindingId, | ||
| 35 | CredentialProvider, | ||
| 36 | CredentialScopeType, | ||
| 37 | Organization, | ||
| 38 | Policy, | ||
| 39 | PolicyDecision, | ||
| 40 | PolicyId, | ||
| 41 | Source, | ||
| 42 | SourceCredentialBinding, | ||
| 43 | SourceId, | ||
| 44 | StorageDurability, | ||
| 45 | StorageInstance, | ||
| 46 | StorageScopeType, | ||
| 47 | Workspace, | ||
| 48 | WorkspaceId, | ||
| 49 | } from "@executor-v2/schema"; | ||
| 4 | import * as Cause from "effect/Cause"; | 50 | import * as Cause from "effect/Cause"; |
| 5 | import * as Option from "effect/Option"; | 51 | import * as Option from "effect/Option"; |
| 6 | 52 | ||
| @@ -195,3 +241,733 @@ export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => · 188 unmodified lines | |||
| 195 | }); | 241 | }); |
| 196 | }), | 242 | }), |
| 197 | ); | 243 | ); |
| 244 | |||
| 245 | |||
| 246 | |||
| 247 | type OrganizationsResult = Result.Result<ReadonlyArray<Organization>, unknown>; | ||
| 248 | |||
| 249 | export const organizationsResult = | ||
| 250 | controlPlaneClient.query("organizations", "list", {}) as Atom.Atom<OrganizationsResult>; | ||
| 251 | |||
| 252 | export const upsertOrganization = controlPlaneClient.mutation( | ||
| 253 | "organizations", | ||
| 254 | "upsert", | ||
| 255 | ); | ||
| 256 | |||
| 257 | export type OrganizationsState = | ||
| 258 | | { | ||
| 259 | state: "loading"; | ||
| 260 | items: ReadonlyArray<Organization>; | ||
| 261 | message: null; | ||
| 262 | } | ||
| 263 | | { | ||
| 264 | state: "error"; | ||
| 265 | items: ReadonlyArray<Organization>; | ||
| 266 | message: string; | ||
| 267 | } | ||
| 268 | | { | ||
| 269 | state: "ready"; | ||
| 270 | items: ReadonlyArray<Organization>; | ||
| 271 | message: null; | ||
| 272 | }; | ||
| 273 | |||
| 274 | const sortOrganizations = ( | ||
| 275 | organizations: ReadonlyArray<Organization>, | ||
| 276 | ): Array<Organization> => | ||
| 277 | [...organizations].sort((left, right) => { | ||
| 278 | const leftName = left.name.toLowerCase(); | ||
| 279 | const rightName = right.name.toLowerCase(); | ||
| 280 | |||
| 281 | if (leftName === rightName) { | ||
| 282 | return left.id.localeCompare(right.id); | ||
| 283 | } | ||
| 284 | |||
| 285 | return leftName.localeCompare(rightName); | ||
| 286 | }); | ||
| 287 | |||
| 288 | const organizationsStateFromResult = ( | ||
| 289 | result: OrganizationsResult, | ||
| 290 | ): OrganizationsState => | ||
| 291 | Result.match(result, { | ||
| 292 | onInitial: () => ({ | ||
| 293 | state: "loading", | ||
| 294 | items: [], | ||
| 295 | message: null, | ||
| 296 | }), | ||
| 297 | onFailure: (failure) => ({ | ||
| 298 | state: "error", | ||
| 299 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 300 | message: Cause.pretty(failure.cause), | ||
| 301 | }), | ||
| 302 | onSuccess: (success) => ({ | ||
| 303 | state: "ready", | ||
| 304 | items: sortOrganizations(success.value), | ||
| 305 | message: null, | ||
| 306 | }), | ||
| 307 | }); | ||
| 308 | |||
| 309 | export const organizationsState = Atom.make((get): OrganizationsState => | ||
| 310 | organizationsStateFromResult(get(organizationsResult)) | ||
| 311 | ); | ||
| 312 | |||
| 313 | export const toOrganizationUpsertPayload = (input: { | ||
| 314 | id?: Organization["id"]; | ||
| 315 | slug: string; | ||
| 316 | name: string; | ||
| 317 | status?: Organization["status"]; | ||
| 318 | }): UpsertOrganizationPayload => ({ | ||
| 319 | ...(input.id ? { id: input.id } : {}), | ||
| 320 | slug: input.slug, | ||
| 321 | name: input.name, | ||
| 322 | ...(input.status !== undefined ? { status: input.status } : {}), | ||
| 323 | }); | ||
| 324 | |||
| 325 | type WorkspacesResult = Result.Result<ReadonlyArray<Workspace>, unknown>; | ||
| 326 | |||
| 327 | export const workspacesResult = | ||
| 328 | controlPlaneClient.query("workspaces", "list", {}) as Atom.Atom<WorkspacesResult>; | ||
| 329 | |||
| 330 | export const upsertWorkspace = controlPlaneClient.mutation("workspaces", "upsert"); | ||
| 331 | |||
| 332 | export type WorkspacesState = | ||
| 333 | | { | ||
| 334 | state: "loading"; | ||
| 335 | items: ReadonlyArray<Workspace>; | ||
| 336 | message: null; | ||
| 337 | } | ||
| 338 | | { | ||
| 339 | state: "error"; | ||
| 340 | items: ReadonlyArray<Workspace>; | ||
| 341 | message: string; | ||
| 342 | } | ||
| 343 | | { | ||
| 344 | state: "ready"; | ||
| 345 | items: ReadonlyArray<Workspace>; | ||
| 346 | message: null; | ||
| 347 | }; | ||
| 348 | |||
| 349 | const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> => | ||
| 350 | [...workspaces].sort((left, right) => { | ||
| 351 | const leftName = left.name.toLowerCase(); | ||
| 352 | const rightName = right.name.toLowerCase(); | ||
| 353 | |||
| 354 | if (leftName === rightName) { | ||
| 355 | return left.id.localeCompare(right.id); | ||
| 356 | } | ||
| 357 | |||
| 358 | return leftName.localeCompare(rightName); | ||
| 359 | }); | ||
| 360 | |||
| 361 | const workspacesStateFromResult = (result: WorkspacesResult): WorkspacesState => | ||
| 362 | Result.match(result, { | ||
| 363 | onInitial: () => ({ | ||
| 364 | state: "loading", | ||
| 365 | items: [], | ||
| 366 | message: null, | ||
| 367 | }), | ||
| 368 | onFailure: (failure) => ({ | ||
| 369 | state: "error", | ||
| 370 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 371 | message: Cause.pretty(failure.cause), | ||
| 372 | }), | ||
| 373 | onSuccess: (success) => ({ | ||
| 374 | state: "ready", | ||
| 375 | items: sortWorkspaces(success.value), | ||
| 376 | message: null, | ||
| 377 | }), | ||
| 378 | }); | ||
| 379 | |||
| 380 | export const workspacesState = Atom.make((get): WorkspacesState => | ||
| 381 | workspacesStateFromResult(get(workspacesResult)) | ||
| 382 | ); | ||
| 383 | |||
| 384 | export const toWorkspaceUpsertPayload = (input: { | ||
| 385 | id?: Workspace["id"]; | ||
| 386 | organizationId?: Workspace["organizationId"]; | ||
| 387 | name: string; | ||
| 388 | }): UpsertWorkspacePayload => ({ | ||
| 389 | ...(input.id ? { id: input.id } : {}), | ||
| 390 | ...(input.organizationId !== undefined | ||
| 391 | ? { | ||
| 392 | organizationId: input.organizationId, | ||
| 393 | } | ||
| 394 | : {}), | ||
| 395 | name: input.name, | ||
| 396 | }); | ||
| 397 | |||
| 398 | type SourceToolsResult = Result.Result<ReadonlyArray<SourceToolSummary>, unknown>; | ||
| 399 | |||
| 400 | export const workspaceToolsResultByWorkspace = Atom.family( | ||
| 401 | (workspaceId: WorkspaceId): Atom.Atom<SourceToolsResult> => | ||
| 402 | controlPlaneClient.query("tools", "listWorkspaceTools", { | ||
| 403 | path: { | ||
| 404 | workspaceId, | ||
| 405 | }, | ||
| 406 | }) as Atom.Atom<SourceToolsResult>, | ||
| 407 | ); | ||
| 408 | |||
| 409 | export const sourceToolsResultBySource = Atom.family( | ||
| 410 | (input: { | ||
| 411 | workspaceId: WorkspaceId; | ||
| 412 | sourceId: SourceId; | ||
| 413 | }): Atom.Atom<SourceToolsResult> => | ||
| 414 | controlPlaneClient.query("tools", "listSourceTools", { | ||
| 415 | path: { | ||
| 416 | workspaceId: input.workspaceId, | ||
| 417 | sourceId: input.sourceId, | ||
| 418 | }, | ||
| 419 | }) as Atom.Atom<SourceToolsResult>, | ||
| 420 | ); | ||
| 421 | |||
| 422 | export type SourceToolsState = | ||
| 423 | | { | ||
| 424 | state: "loading"; | ||
| 425 | items: ReadonlyArray<SourceToolSummary>; | ||
| 426 | message: null; | ||
| 427 | } | ||
| 428 | | { | ||
| 429 | state: "error"; | ||
| 430 | items: ReadonlyArray<SourceToolSummary>; | ||
| 431 | message: string; | ||
| 432 | } | ||
| 433 | | { | ||
| 434 | state: "ready"; | ||
| 435 | items: ReadonlyArray<SourceToolSummary>; | ||
| 436 | message: null; | ||
| 437 | }; | ||
| 438 | |||
| 439 | const sortSourceTools = ( | ||
| 440 | tools: ReadonlyArray<SourceToolSummary>, | ||
| 441 | ): Array<SourceToolSummary> => | ||
| 442 | [...tools].sort((left, right) => { | ||
| 443 | const leftSource = left.sourceName.toLowerCase(); | ||
| 444 | const rightSource = right.sourceName.toLowerCase(); | ||
| 445 | |||
| 446 | if (leftSource !== rightSource) { | ||
| 447 | return leftSource.localeCompare(rightSource); | ||
| 448 | } | ||
| 449 | |||
| 450 | const leftName = left.name.toLowerCase(); | ||
| 451 | const rightName = right.name.toLowerCase(); | ||
| 452 | |||
| 453 | if (leftName !== rightName) { | ||
| 454 | return leftName.localeCompare(rightName); | ||
| 455 | } | ||
| 456 | |||
| 457 | return left.toolId.localeCompare(right.toolId); | ||
| 458 | }); | ||
| 459 | |||
| 460 | const sourceToolsStateFromResult = (result: SourceToolsResult): SourceToolsState => | ||
| 461 | Result.match(result, { | ||
| 462 | onInitial: () => ({ | ||
| 463 | state: "loading", | ||
| 464 | items: [], | ||
| 465 | message: null, | ||
| 466 | }), | ||
| 467 | onFailure: (failure) => ({ | ||
| 468 | state: "error", | ||
| 469 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 470 | message: Cause.pretty(failure.cause), | ||
| 471 | }), | ||
| 472 | onSuccess: (success) => ({ | ||
| 473 | state: "ready", | ||
| 474 | items: sortSourceTools(success.value), | ||
| 475 | message: null, | ||
| 476 | }), | ||
| 477 | }); | ||
| 478 | |||
| 479 | export const workspaceToolsByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 480 | Atom.make((get): SourceToolsState => | ||
| 481 | sourceToolsStateFromResult(get(workspaceToolsResultByWorkspace(workspaceId))) | ||
| 482 | ) | ||
| 483 | ); | ||
| 484 | |||
| 485 | type ApprovalsResult = Result.Result<ReadonlyArray<Approval>, unknown>; | ||
| 486 | |||
| 487 | export const approvalsResultByWorkspace = Atom.family( | ||
| 488 | (workspaceId: WorkspaceId): Atom.Atom<ApprovalsResult> => | ||
| 489 | controlPlaneClient.query("approvals", "list", { | ||
| 490 | path: { | ||
| 491 | workspaceId, | ||
| 492 | }, | ||
| 493 | }) as Atom.Atom<ApprovalsResult>, | ||
| 494 | ); | ||
| 495 | |||
| 496 | export const resolveApproval = controlPlaneClient.mutation("approvals", "resolve"); | ||
| 497 | |||
| 498 | export type ApprovalsState = | ||
| 499 | | { | ||
| 500 | state: "loading"; | ||
| 501 | items: ReadonlyArray<Approval>; | ||
| 502 | message: null; | ||
| 503 | } | ||
| 504 | | { | ||
| 505 | state: "error"; | ||
| 506 | items: ReadonlyArray<Approval>; | ||
| 507 | message: string; | ||
| 508 | } | ||
| 509 | | { | ||
| 510 | state: "ready"; | ||
| 511 | items: ReadonlyArray<Approval>; | ||
| 512 | message: null; | ||
| 513 | }; | ||
| 514 | |||
| 515 | const approvalStoreKey = (approval: Approval): string => | ||
| 516 | `${approval.workspaceId}:${approval.id}`; | ||
| 517 | |||
| 518 | const sortApprovals = (approvals: ReadonlyArray<Approval>): Array<Approval> => | ||
| 519 | [...approvals].sort((left, right) => { | ||
| 520 | if (left.status !== right.status) { | ||
| 521 | if (left.status === "pending") return -1; | ||
| 522 | if (right.status === "pending") return 1; | ||
| 523 | } | ||
| 524 | |||
| 525 | if (left.requestedAt !== right.requestedAt) { | ||
| 526 | return right.requestedAt - left.requestedAt; | ||
| 527 | } | ||
| 528 | |||
| 529 | return approvalStoreKey(left).localeCompare(approvalStoreKey(right)); | ||
| 530 | }); | ||
| 531 | |||
| 532 | const approvalsStateFromResult = (result: ApprovalsResult): ApprovalsState => | ||
| 533 | Result.match(result, { | ||
| 534 | onInitial: () => ({ | ||
| 535 | state: "loading", | ||
| 536 | items: [], | ||
| 537 | message: null, | ||
| 538 | }), | ||
| 539 | onFailure: (failure) => ({ | ||
| 540 | state: "error", | ||
| 541 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 542 | message: Cause.pretty(failure.cause), | ||
| 543 | }), | ||
| 544 | onSuccess: (success) => ({ | ||
| 545 | state: "ready", | ||
| 546 | items: sortApprovals(success.value), | ||
| 547 | message: null, | ||
| 548 | }), | ||
| 549 | }); | ||
| 550 | |||
| 551 | export const approvalsByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 552 | Atom.make((get): ApprovalsState => { | ||
| 553 | const result = get(approvalsResultByWorkspace(workspaceId)); | ||
| 554 | return approvalsStateFromResult(result); | ||
| 555 | }), | ||
| 556 | ); | ||
| 557 | |||
| 558 | export const approvalPendingByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 559 | Atom.make((get): boolean => { | ||
| 560 | const state = get(approvalsByWorkspace(workspaceId)); | ||
| 561 | return state.state === "loading"; | ||
| 562 | }), | ||
| 563 | ); | ||
| 564 | |||
| 565 | export const optimisticResolveApproval = ( | ||
| 566 | currentApprovals: ReadonlyArray<Approval>, | ||
| 567 | input: { | ||
| 568 | approvalId: ApprovalId; | ||
| 569 | payload: ResolveApprovalPayload; | ||
| 570 | }, | ||
| 571 | ): ReadonlyArray<Approval> => | ||
| 572 | sortApprovals( | ||
| 573 | currentApprovals.map((approval) => { | ||
| 574 | if (approval.id !== input.approvalId) { | ||
| 575 | return approval; | ||
| 576 | } | ||
| 577 | |||
| 578 | return { | ||
| 579 | ...approval, | ||
| 580 | status: input.payload.status, | ||
| 581 | reason: | ||
| 582 | input.payload.reason === undefined | ||
| 583 | ? approval.reason | ||
| 584 | : input.payload.reason, | ||
| 585 | resolvedAt: Date.now(), | ||
| 586 | }; | ||
| 587 | }), | ||
| 588 | ); | ||
| 589 | |||
| 590 | |||
| 591 | |||
| 592 | type PoliciesResult = Result.Result<ReadonlyArray<Policy>, unknown>; | ||
| 593 | |||
| 594 | export const policiesResultByWorkspace = Atom.family( | ||
| 595 | (workspaceId: WorkspaceId): Atom.Atom<PoliciesResult> => | ||
| 596 | controlPlaneClient.query("policies", "list", { | ||
| 597 | path: { | ||
| 598 | workspaceId, | ||
| 599 | }, | ||
| 600 | }) as Atom.Atom<PoliciesResult>, | ||
| 601 | ); | ||
| 602 | |||
| 603 | export const upsertPolicy = controlPlaneClient.mutation("policies", "upsert"); | ||
| 604 | export const removePolicy = controlPlaneClient.mutation("policies", "remove"); | ||
| 605 | |||
| 606 | export type PoliciesState = | ||
| 607 | | { | ||
| 608 | state: "loading"; | ||
| 609 | items: ReadonlyArray<Policy>; | ||
| 610 | message: null; | ||
| 611 | } | ||
| 612 | | { | ||
| 613 | state: "error"; | ||
| 614 | items: ReadonlyArray<Policy>; | ||
| 615 | message: string; | ||
| 616 | } | ||
| 617 | | { | ||
| 618 | state: "ready"; | ||
| 619 | items: ReadonlyArray<Policy>; | ||
| 620 | message: null; | ||
| 621 | }; | ||
| 622 | |||
| 623 | const policyStoreKey = (policy: Policy): string => `${policy.workspaceId}:${policy.id}`; | ||
| 624 | |||
| 625 | const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> => | ||
| 626 | [...policies].sort((left, right) => { | ||
| 627 | const leftPattern = left.toolPathPattern.toLowerCase(); | ||
| 628 | const rightPattern = right.toolPathPattern.toLowerCase(); | ||
| 629 | |||
| 630 | if (leftPattern === rightPattern) { | ||
| 631 | return policyStoreKey(left).localeCompare(policyStoreKey(right)); | ||
| 632 | } | ||
| 633 | |||
| 634 | return leftPattern.localeCompare(rightPattern); | ||
| 635 | }); | ||
| 636 | |||
| 637 | const policiesStateFromResult = (result: PoliciesResult): PoliciesState => | ||
| 638 | Result.match(result, { | ||
| 639 | onInitial: () => ({ | ||
| 640 | state: "loading", | ||
| 641 | items: [], | ||
| 642 | message: null, | ||
| 643 | }), | ||
| 644 | onFailure: (failure) => ({ | ||
| 645 | state: "error", | ||
| 646 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 647 | message: Cause.pretty(failure.cause), | ||
| 648 | }), | ||
| 649 | onSuccess: (success) => ({ | ||
| 650 | state: "ready", | ||
| 651 | items: sortPolicies(success.value), | ||
| 652 | message: null, | ||
| 653 | }), | ||
| 654 | }); | ||
| 655 | |||
| 656 | export const policiesByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 657 | Atom.make((get): PoliciesState => { | ||
| 658 | const result = get(policiesResultByWorkspace(workspaceId)); | ||
| 659 | return policiesStateFromResult(result); | ||
| 660 | }), | ||
| 661 | ); | ||
| 662 | |||
| 663 | export const optimisticUpsertPolicy = ( | ||
| 664 | currentPolicies: ReadonlyArray<Policy>, | ||
| 665 | input: { | ||
| 666 | workspaceId: WorkspaceId; | ||
| 667 | policyId: PolicyId; | ||
| 668 | toolPathPattern: string; | ||
| 669 | decision: PolicyDecision; | ||
| 670 | }, | ||
| 671 | ): ReadonlyArray<Policy> => { | ||
| 672 | const now = Date.now(); | ||
| 673 | const existing = currentPolicies.find((policy) => policy.id === input.policyId); | ||
| 674 | |||
| 675 | const nextPolicy: Policy = { | ||
| 676 | id: input.policyId, | ||
| 677 | workspaceId: input.workspaceId, | ||
| 678 | toolPathPattern: input.toolPathPattern, | ||
| 679 | decision: input.decision, | ||
| 680 | createdAt: existing?.createdAt ?? now, | ||
| 681 | updatedAt: now, | ||
| 682 | }; | ||
| 683 | |||
| 684 | const rest = currentPolicies.filter((policy) => policy.id !== input.policyId); | ||
| 685 | return sortPolicies([...rest, nextPolicy]); | ||
| 686 | }; | ||
| 687 | |||
| 688 | export const optimisticRemovePolicy = ( | ||
| 689 | currentPolicies: ReadonlyArray<Policy>, | ||
| 690 | policyId: PolicyId, | ||
| 691 | ): ReadonlyArray<Policy> => currentPolicies.filter((policy) => policy.id !== policyId); | ||
| 692 | |||
| 693 | export const toPolicyUpsertPayload = (input: { | ||
| 694 | id?: PolicyId; | ||
| 695 | toolPathPattern: string; | ||
| 696 | decision: PolicyDecision; | ||
| 697 | }): UpsertPolicyPayload => ({ | ||
| 698 | ...(input.id ? { id: input.id } : {}), | ||
| 699 | toolPathPattern: input.toolPathPattern, | ||
| 700 | decision: input.decision, | ||
| 701 | }); | ||
| 702 | |||
| 703 | export const toPolicyRemoveResult = (result: RemovePolicyResult): boolean => | ||
| 704 | result.removed; | ||
| 705 | |||
| 706 | |||
| 707 | |||
| 708 | type CredentialBindingsResult = Result.Result< | ||
| 709 | ReadonlyArray<SourceCredentialBinding>, | ||
| 710 | unknown | ||
| 711 | >; | ||
| 712 | |||
| 713 | export const credentialBindingsResultByWorkspace = Atom.family( | ||
| 714 | (workspaceId: WorkspaceId): Atom.Atom<CredentialBindingsResult> => | ||
| 715 | controlPlaneClient.query("credentials", "list", { | ||
| 716 | path: { | ||
| 717 | workspaceId, | ||
| 718 | }, | ||
| 719 | }) as Atom.Atom<CredentialBindingsResult>, | ||
| 720 | ); | ||
| 721 | |||
| 722 | export const upsertCredentialBinding = controlPlaneClient.mutation( | ||
| 723 | "credentials", | ||
| 724 | "upsert", | ||
| 725 | ); | ||
| 726 | export const removeCredentialBinding = controlPlaneClient.mutation( | ||
| 727 | "credentials", | ||
| 728 | "remove", | ||
| 729 | ); | ||
| 730 | |||
| 731 | export type CredentialBindingsState = | ||
| 732 | | { | ||
| 733 | state: "loading"; | ||
| 734 | items: ReadonlyArray<SourceCredentialBinding>; | ||
| 735 | message: null; | ||
| 736 | } | ||
| 737 | | { | ||
| 738 | state: "error"; | ||
| 739 | items: ReadonlyArray<SourceCredentialBinding>; | ||
| 740 | message: string; | ||
| 741 | } | ||
| 742 | | { | ||
| 743 | state: "ready"; | ||
| 744 | items: ReadonlyArray<SourceCredentialBinding>; | ||
| 745 | message: null; | ||
| 746 | }; | ||
| 747 | |||
| 748 | const credentialBindingStoreKey = (binding: SourceCredentialBinding): string => | ||
| 749 | `${binding.workspaceId}:${binding.id}`; | ||
| 750 | |||
| 751 | const sortCredentialBindings = ( | ||
| 752 | bindings: ReadonlyArray<SourceCredentialBinding>, | ||
| 753 | ): Array<SourceCredentialBinding> => | ||
| 754 | [...bindings].sort((left, right) => { | ||
| 755 | const leftKey = `${left.sourceKey}:${left.provider}`.toLowerCase(); | ||
| 756 | const rightKey = `${right.sourceKey}:${right.provider}`.toLowerCase(); | ||
| 757 | |||
| 758 | if (leftKey === rightKey) { | ||
| 759 | return credentialBindingStoreKey(left).localeCompare( | ||
| 760 | credentialBindingStoreKey(right), | ||
| 761 | ); | ||
| 762 | } | ||
| 763 | |||
| 764 | return leftKey.localeCompare(rightKey); | ||
| 765 | }); | ||
| 766 | |||
| 767 | const credentialBindingsStateFromResult = ( | ||
| 768 | result: CredentialBindingsResult, | ||
| 769 | ): CredentialBindingsState => | ||
| 770 | Result.match(result, { | ||
| 771 | onInitial: () => ({ | ||
| 772 | state: "loading", | ||
| 773 | items: [], | ||
| 774 | message: null, | ||
| 775 | }), | ||
| 776 | onFailure: (failure) => ({ | ||
| 777 | state: "error", | ||
| 778 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 779 | message: Cause.pretty(failure.cause), | ||
| 780 | }), | ||
| 781 | onSuccess: (success) => ({ | ||
| 782 | state: "ready", | ||
| 783 | items: sortCredentialBindings(success.value), | ||
| 784 | message: null, | ||
| 785 | }), | ||
| 786 | }); | ||
| 787 | |||
| 788 | export const credentialBindingsByWorkspace = Atom.family( | ||
| 789 | (workspaceId: WorkspaceId) => | ||
| 790 | Atom.make((get): CredentialBindingsState => { | ||
| 791 | const result = get(credentialBindingsResultByWorkspace(workspaceId)); | ||
| 792 | return credentialBindingsStateFromResult(result); | ||
| 793 | }), | ||
| 794 | ); | ||
| 795 | |||
| 796 | export const toCredentialBindingUpsertPayload = (input: { | ||
| 797 | id?: CredentialBindingId; | ||
| 798 | credentialId: SourceCredentialBinding["credentialId"]; | ||
| 799 | scopeType: CredentialScopeType; | ||
| 800 | sourceKey: string; | ||
| 801 | provider: CredentialProvider; | ||
| 802 | secretRef: string; | ||
| 803 | accountId?: SourceCredentialBinding["accountId"]; | ||
| 804 | additionalHeadersJson?: SourceCredentialBinding["additionalHeadersJson"]; | ||
| 805 | boundAuthFingerprint?: SourceCredentialBinding["boundAuthFingerprint"]; | ||
| 806 | }): UpsertCredentialBindingPayload => ({ | ||
| 807 | ...(input.id ? { id: input.id } : {}), | ||
| 808 | credentialId: input.credentialId, | ||
| 809 | scopeType: input.scopeType, | ||
| 810 | sourceKey: input.sourceKey, | ||
| 811 | provider: input.provider, | ||
| 812 | secretRef: input.secretRef, | ||
| 813 | ...(input.accountId !== undefined ? { accountId: input.accountId } : {}), | ||
| 814 | ...(input.additionalHeadersJson !== undefined | ||
| 815 | ? { additionalHeadersJson: input.additionalHeadersJson } | ||
| 816 | : {}), | ||
| 817 | ...(input.boundAuthFingerprint !== undefined | ||
| 818 | ? { boundAuthFingerprint: input.boundAuthFingerprint } | ||
| 819 | : {}), | ||
| 820 | }); | ||
| 821 | |||
| 822 | export const toCredentialBindingRemoveResult = ( | ||
| 823 | result: RemoveCredentialBindingResult, | ||
| 824 | ): boolean => result.removed; | ||
| 825 | |||
| 826 | |||
| 827 | type StorageInstancesResult = Result.Result<ReadonlyArray<StorageInstance>, unknown>; | ||
| 828 | |||
| 829 | export const storageResultByWorkspace = Atom.family( | ||
| 830 | (workspaceId: WorkspaceId): Atom.Atom<StorageInstancesResult> => | ||
| 831 | controlPlaneClient.query("storage", "list", { | ||
| 832 | path: { | ||
| 833 | workspaceId, | ||
| 834 | }, | ||
| 835 | }) as Atom.Atom<StorageInstancesResult>, | ||
| 836 | ); | ||
| 837 | |||
| 838 | export const openStorageInstance = controlPlaneClient.mutation("storage", "open"); | ||
| 839 | export const closeStorageInstance = controlPlaneClient.mutation("storage", "close"); | ||
| 840 | export const removeStorageInstance = controlPlaneClient.mutation("storage", "remove"); | ||
| 841 | |||
| 842 | export type StorageInstancesState = | ||
| 843 | | { | ||
| 844 | state: "loading"; | ||
| 845 | items: ReadonlyArray<StorageInstance>; | ||
| 846 | message: null; | ||
| 847 | } | ||
| 848 | | { | ||
| 849 | state: "error"; | ||
| 850 | items: ReadonlyArray<StorageInstance>; | ||
| 851 | message: string; | ||
| 852 | } | ||
| 853 | | { | ||
| 854 | state: "ready"; | ||
| 855 | items: ReadonlyArray<StorageInstance>; | ||
| 856 | message: null; | ||
| 857 | }; | ||
| 858 | |||
| 859 | const sortStorageInstances = ( | ||
| 860 | storageInstances: ReadonlyArray<StorageInstance>, | ||
| 861 | ): Array<StorageInstance> => | ||
| 862 | [...storageInstances].sort((left, right) => { | ||
| 863 | if (left.updatedAt !== right.updatedAt) { | ||
| 864 | return right.updatedAt - left.updatedAt; | ||
| 865 | } | ||
| 866 | |||
| 867 | return left.id.localeCompare(right.id); | ||
| 868 | }); | ||
| 869 | |||
| 870 | const storageStateFromResult = ( | ||
| 871 | result: StorageInstancesResult, | ||
| 872 | ): StorageInstancesState => | ||
| 873 | Result.match(result, { | ||
| 874 | onInitial: () => ({ | ||
| 875 | state: "loading", | ||
| 876 | items: [], | ||
| 877 | message: null, | ||
| 878 | }), | ||
| 879 | onFailure: (failure) => ({ | ||
| 880 | state: "error", | ||
| 881 | items: Option.getOrElse(Result.value(result), () => []), | ||
| 882 | message: Cause.pretty(failure.cause), | ||
| 883 | }), | ||
| 884 | onSuccess: (success) => ({ | ||
| 885 | state: "ready", | ||
| 886 | items: sortStorageInstances(success.value), | ||
| 887 | message: null, | ||
| 888 | }), | ||
| 889 | }); | ||
| 890 | |||
| 891 | export const storageByWorkspace = Atom.family((workspaceId: WorkspaceId) => | ||
| 892 | Atom.make((get): StorageInstancesState => { | ||
| 893 | const result = get(storageResultByWorkspace(workspaceId)); | ||
| 894 | return storageStateFromResult(result); | ||
| 895 | }), | ||
| 896 | ); | ||
| 897 | |||
| 898 | export const toOpenStoragePayload = (input: { | ||
| 899 | scopeType: StorageScopeType; | ||
| 900 | durability: StorageDurability; | ||
| 901 | provider?: StorageInstance["provider"]; | ||
| 902 | purpose?: string; | ||
| 903 | ttlHours?: number; | ||
| 904 | accountId?: Exclude<StorageInstance["accountId"], null>; | ||
| 905 | sessionId?: string; | ||
| 906 | }): OpenStorageInstancePayload => ({ | ||
| 907 | scopeType: input.scopeType, | ||
| 908 | durability: input.durability, | ||
| 909 | ...(input.provider !== undefined ? { provider: input.provider } : {}), | ||
| 910 | ...(input.purpose !== undefined ? { purpose: input.purpose } : {}), | ||
| 911 | ...(input.ttlHours !== undefined ? { ttlHours: input.ttlHours } : {}), | ||
| 912 | ...(input.accountId !== undefined ? { accountId: input.accountId } : {}), | ||
| 913 | ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), | ||
| 914 | }); | ||
| 915 | |||
| 916 | export const toStorageRemoveResult = ( | ||
| 917 | result: RemoveStorageInstanceResult, | ||
| 918 | ): boolean => result.removed; | ||
| 919 | |||
| 920 | export const listStorageDirectory = controlPlaneClient.mutation( | ||
| 921 | "storage", | ||
| 922 | "listDirectory", | ||
| 923 | ); | ||
| 924 | export const readStorageFile = controlPlaneClient.mutation("storage", "readFile"); | ||
| 925 | export const listStorageKv = controlPlaneClient.mutation("storage", "listKv"); | ||
| 926 | export const queryStorageSql = controlPlaneClient.mutation("storage", "querySql"); | ||
| 927 | |||
| 928 | export const toListStorageDirectoryPayload = (input: { | ||
| 929 | path: string; | ||
| 930 | }): ListStorageDirectoryPayload => ({ | ||
| 931 | path: input.path, | ||
| 932 | }); | ||
| 933 | |||
| 934 | export const toReadStorageFilePayload = (input: { | ||
| 935 | path: string; | ||
| 936 | encoding?: "utf8" | "base64"; | ||
| 937 | }): ReadStorageFilePayload => ({ | ||
| 938 | path: input.path, | ||
| 939 | ...(input.encoding !== undefined ? { encoding: input.encoding } : {}), | ||
| 940 | }); | ||
| 941 | |||
| 942 | export const toListStorageKvPayload = (input: { | ||
| 943 | prefix?: string; | ||
| 944 | limit?: number; | ||
| 945 | }): ListStorageKvPayload => ({ | ||
| 946 | ...(input.prefix !== undefined ? { prefix: input.prefix } : {}), | ||
| 947 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 948 | }); | ||
| 949 | |||
| 950 | export const toQueryStorageSqlPayload = (input: { | ||
| 951 | sql: string; | ||
| 952 | maxRows?: number; | ||
| 953 | }): QueryStorageSqlPayload => ({ | ||
| 954 | sql: input.sql, | ||
| 955 | ...(input.maxRows !== undefined ? { maxRows: input.maxRows } : {}), | ||
| 956 | }); | ||
| 957 | |||
| 958 | export const toStorageDirectoryResult = ( | ||
| 959 | result: ListStorageDirectoryResult, | ||
| 960 | ): ListStorageDirectoryResult => result; | ||
| 961 | |||
| 962 | export const toStorageReadFileResult = ( | ||
| 963 | result: ReadStorageFileResult, | ||
| 964 | ): ReadStorageFileResult => result; | ||
| 965 | |||
| 966 | export const toStorageKvResult = ( | ||
| 967 | result: ListStorageKvResult, | ||
| 968 | ): ListStorageKvResult => result; | ||
| 969 | |||
| 970 | export const toStorageSqlResult = ( | ||
| 971 | result: QueryStorageSqlResult, | ||
| 972 | ): QueryStorageSqlResult => result; | ||
| 973 | |||
| @@ -1,7 +1,5 @@ | |||
| 1 | import { | 1 | import { createControlPlaneAtomClient } from "@executor-v2/management-api/client"; |
| 2 | ControlPlaneAuthHeaders, | 2 | import { ControlPlaneAuthHeaders } from "@executor-v2/management-api/auth/principal"; |
| 3 | createControlPlaneAtomClient, | ||
| 4 | } from "@executor-v2/management-api"; | ||
| 5 | 3 | ||
| 6 | const controlPlaneBaseUrl = | 4 | const controlPlaneBaseUrl = |
| 7 | typeof window === "undefined" | 5 | typeof window === "undefined" |
| @@ -0,0 +1,266 @@ | |||
| 1 | import type { UpsertSourcePayload } from "@executor-v2/management-api"; | ||
| 2 | import type { | ||
| 3 | Source, | ||
| 4 | SourceId, | ||
| 5 | SourceKind, | ||
| 6 | SourceStatus, | ||
| 7 | WorkspaceId, | ||
| 8 | } from "@executor-v2/schema"; | ||
| 9 | |||
| 10 | export type LegacySourceType = "openapi" | "mcp" | "graphql"; | ||
| 11 | export type LegacyAuthType = "none" | "bearer" | "apiKey" | "basic"; | ||
| 12 | export type LegacyAuthMode = "workspace" | "organization" | "account"; | ||
| 13 | |||
| 14 | export type LegacyToolSourceRecord = { | ||
| 15 | id: SourceId; | ||
| 16 | workspaceId: WorkspaceId; | ||
| 17 | name: string; | ||
| 18 | type: LegacySourceType; | ||
| 19 | endpoint: string; | ||
| 20 | enabled: boolean; | ||
| 21 | status: SourceStatus; | ||
| 22 | config: Record<string, unknown>; | ||
| 23 | sourceHash: string | null; | ||
| 24 | lastError: string | null; | ||
| 25 | createdAt: number; | ||
| 26 | updatedAt: number; | ||
| 27 | }; | ||
| 28 | |||
| 29 | export type LegacySourceFormState = { | ||
| 30 | id?: SourceId; | ||
| 31 | name: string; | ||
| 32 | type: LegacySourceType; | ||
| 33 | endpoint: string; | ||
| 34 | baseUrl: string; | ||
| 35 | mcpTransport: "auto" | "streamable-http" | "sse"; | ||
| 36 | authType: LegacyAuthType; | ||
| 37 | authMode: LegacyAuthMode; | ||
| 38 | apiKeyHeader: string; | ||
| 39 | enabled: boolean; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const supportedKinds: ReadonlyArray<LegacySourceType> = [ | ||
| 43 | "openapi", | ||
| 44 | "mcp", | ||
| 45 | "graphql", | ||
| 46 | ]; | ||
| 47 | |||
| 48 | const normalizeString = (value: unknown): string => | ||
| 49 | typeof value === "string" ? value.trim() : ""; | ||
| 50 | |||
| 51 | const normalizeConfig = (configJson: string): Record<string, unknown> => { | ||
| 52 | try { | ||
| 53 | const parsed = JSON.parse(configJson); | ||
| 54 | return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}; | ||
| 55 | } catch { | ||
| 56 | return {}; | ||
| 57 | } | ||
| 58 | }; | ||
| 59 | |||
| 60 | const normalizeSourceType = (kind: SourceKind): LegacySourceType => { | ||
| 61 | if (supportedKinds.includes(kind as LegacySourceType)) { | ||
| 62 | return kind as LegacySourceType; | ||
| 63 | } | ||
| 64 | |||
| 65 | return "openapi"; | ||
| 66 | }; | ||
| 67 | |||
| 68 | const endpointFromConfig = ( | ||
| 69 | type: LegacySourceType, | ||
| 70 | config: Record<string, unknown>, | ||
| 71 | fallback: string, | ||
| 72 | ): string => { | ||
| 73 | if (type === "mcp") { | ||
| 74 | return normalizeString(config.url) || fallback; | ||
| 75 | } | ||
| 76 | |||
| 77 | if (type === "graphql") { | ||
| 78 | return normalizeString(config.endpoint) || fallback; | ||
| 79 | } | ||
| 80 | |||
| 81 | const specUrl = normalizeString(config.specUrl); | ||
| 82 | if (specUrl.length > 0) { | ||
| 83 | return specUrl; | ||
| 84 | } | ||
| 85 | |||
| 86 | const spec = config.spec; | ||
| 87 | if (typeof spec === "string") { | ||
| 88 | const normalizedSpec = spec.trim(); | ||
| 89 | if (normalizedSpec.length > 0) { | ||
| 90 | return normalizedSpec; | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | return fallback; | ||
| 95 | }; | ||
| 96 | |||
| 97 | const authFromConfig = (config: Record<string, unknown>): { | ||
| 98 | authType: LegacyAuthType; | ||
| 99 | authMode: LegacyAuthMode; | ||
| 100 | apiKeyHeader: string; | ||
| 101 | } => { | ||
| 102 | const authValue = config.auth; | ||
| 103 | if (!authValue || typeof authValue !== "object") { | ||
| 104 | return { | ||
| 105 | authType: "none", | ||
| 106 | authMode: "workspace", | ||
| 107 | apiKeyHeader: "Authorization", | ||
| 108 | }; | ||
| 109 | } | ||
| 110 | |||
| 111 | const auth = authValue as Record<string, unknown>; | ||
| 112 | const rawType = normalizeString(auth.type); | ||
| 113 | const rawMode = normalizeString(auth.mode); | ||
| 114 | const rawHeader = normalizeString(auth.header); | ||
| 115 | |||
| 116 | const authType: LegacyAuthType = | ||
| 117 | rawType === "bearer" || rawType === "apiKey" || rawType === "basic" | ||
| 118 | ? rawType | ||
| 119 | : "none"; | ||
| 120 | |||
| 121 | const authMode: LegacyAuthMode = | ||
| 122 | rawMode === "organization" || rawMode === "account" | ||
| 123 | ? rawMode | ||
| 124 | : "workspace"; | ||
| 125 | |||
| 126 | return { | ||
| 127 | authType, | ||
| 128 | authMode, | ||
| 129 | apiKeyHeader: rawHeader || "Authorization", | ||
| 130 | }; | ||
| 131 | }; | ||
| 132 | |||
| 133 | export const sourceToLegacyRecord = (source: Source): LegacyToolSourceRecord => { | ||
| 134 | const type = normalizeSourceType(source.kind); | ||
| 135 | const config = normalizeConfig(source.configJson); | ||
| 136 | const endpoint = endpointFromConfig(type, config, source.endpoint); | ||
| 137 | |||
| 138 | return { | ||
| 139 | id: source.id, | ||
| 140 | workspaceId: source.workspaceId, | ||
| 141 | name: source.name, | ||
| 142 | type, | ||
| 143 | endpoint, | ||
| 144 | enabled: source.enabled, | ||
| 145 | status: source.status, | ||
| 146 | config, | ||
| 147 | sourceHash: source.sourceHash, | ||
| 148 | lastError: source.lastError, | ||
| 149 | createdAt: source.createdAt, | ||
| 150 | updatedAt: source.updatedAt, | ||
| 151 | }; | ||
| 152 | }; | ||
| 153 | |||
| 154 | export const buildSourceConfig = (input: { | ||
| 155 | type: LegacySourceType; | ||
| 156 | endpoint: string; | ||
| 157 | baseUrl: string; | ||
| 158 | mcpTransport: "auto" | "streamable-http" | "sse"; | ||
| 159 | authType: LegacyAuthType; | ||
| 160 | authMode: LegacyAuthMode; | ||
| 161 | apiKeyHeader: string; | ||
| 162 | }): Record<string, unknown> => { | ||
| 163 | const endpoint = input.endpoint.trim(); | ||
| 164 | |||
| 165 | const auth = (() => { | ||
| 166 | if (input.authType === "none") { | ||
| 167 | return undefined; | ||
| 168 | } | ||
| 169 | |||
| 170 | if (input.authType === "apiKey") { | ||
| 171 | return { | ||
| 172 | type: "apiKey", | ||
| 173 | mode: input.authMode, | ||
| 174 | header: input.apiKeyHeader.trim() || "Authorization", | ||
| 175 | }; | ||
| 176 | } | ||
| 177 | |||
| 178 | return { | ||
| 179 | type: input.authType, | ||
| 180 | mode: input.authMode, | ||
| 181 | }; | ||
| 182 | })(); | ||
| 183 | |||
| 184 | if (input.type === "mcp") { | ||
| 185 | return { | ||
| 186 | url: endpoint, | ||
| 187 | ...(input.mcpTransport !== "auto" ? { transport: input.mcpTransport } : {}), | ||
| 188 | ...(auth ? { auth } : {}), | ||
| 189 | ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | ||
| 190 | }; | ||
| 191 | } | ||
| 192 | |||
| 193 | if (input.type === "graphql") { | ||
| 194 | return { | ||
| 195 | endpoint, | ||
| 196 | ...(auth ? { auth } : {}), | ||
| 197 | ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | return { | ||
| 202 | spec: endpoint, | ||
| 203 | specUrl: endpoint, | ||
| 204 | ...(input.baseUrl.trim().length > 0 ? { baseUrl: input.baseUrl.trim() } : {}), | ||
| 205 | ...(auth ? { auth } : {}), | ||
| 206 | ...(input.authType !== "none" ? { useCredentialedFetch: true } : {}), | ||
| 207 | }; | ||
| 208 | }; | ||
| 209 | |||
| 210 | export const formStateFromSource = ( | ||
| 211 | source: LegacyToolSourceRecord, | ||
| 212 | ): LegacySourceFormState => { | ||
| 213 | const config = source.config; | ||
| 214 | const auth = authFromConfig(config); | ||
| 215 | |||
| 216 | return { | ||
| 217 | id: source.id, | ||
| 218 | name: source.name, | ||
| 219 | type: source.type, | ||
| 220 | endpoint: source.endpoint, | ||
| 221 | baseUrl: normalizeString(config.baseUrl), | ||
| 222 | mcpTransport: | ||
| 223 | normalizeString(config.transport) === "streamable-http" | ||
| 224 | || normalizeString(config.transport) === "sse" | ||
| 225 | ? (normalizeString(config.transport) as "streamable-http" | "sse") | ||
| 226 | : "auto", | ||
| 227 | authType: auth.authType, | ||
| 228 | authMode: auth.authMode, | ||
| 229 | apiKeyHeader: auth.apiKeyHeader, | ||
| 230 | enabled: source.enabled, | ||
| 231 | }; | ||
| 232 | }; | ||
| 233 | |||
| 234 | export const upsertPayloadFromForm = (input: { | ||
| 235 | workspaceId: WorkspaceId; | ||
| 236 | form: LegacySourceFormState; | ||
| 237 | sourceId: SourceId; | ||
| 238 | }): UpsertSourcePayload => { | ||
| 239 | const config = buildSourceConfig({ | ||
| 240 | type: input.form.type, | ||
| 241 | endpoint: input.form.endpoint, | ||
| 242 | baseUrl: input.form.baseUrl, | ||
| 243 | mcpTransport: input.form.mcpTransport, | ||
| 244 | authType: input.form.authType, | ||
| 245 | authMode: input.form.authMode, | ||
| 246 | apiKeyHeader: input.form.apiKeyHeader, | ||
| 247 | }); | ||
| 248 | |||
| 249 | const endpoint = endpointFromConfig( | ||
| 250 | input.form.type, | ||
| 251 | config, | ||
| 252 | input.form.endpoint.trim(), | ||
| 253 | ); | ||
| 254 | |||
| 255 | return { | ||
| 256 | id: input.sourceId, | ||
| 257 | name: input.form.name.trim(), | ||
| 258 | kind: input.form.type, | ||
| 259 | endpoint, | ||
| 260 | enabled: input.form.enabled, | ||
| 261 | status: endpoint.length > 0 ? "connected" : "draft", | ||
| 262 | configJson: JSON.stringify(config), | ||
| 263 | sourceHash: null, | ||
| 264 | lastError: null, | ||
| 265 | }; | ||
| 266 | }; | ||
| @@ -0,0 +1,6 @@ | |||
| 1 | import { clsx, type ClassValue } from "clsx"; | ||
| 2 | import { twMerge } from "tailwind-merge"; | ||
| 3 | |||
| 4 | export function cn(...inputs: ReadonlyArray<ClassValue>): string { | ||
| 5 | return twMerge(clsx(inputs)); | ||
| 6 | } | ||
| @@ -3,8 +3,39 @@ const trim = (value: string | undefined): string | undefined => { · 2 unmodified lines | |||
| 3 | return candidate && candidate.length > 0 ? candidate : undefined; | 3 | return candidate && candidate.length > 0 ? candidate : undefined; |
| 4 | }; | 4 | }; |
| 5 | 5 | ||
| 6 | export const isWorkosEnabled = (): boolean => | 6 | const isTruthy = (value: string | undefined): boolean => { |
| 7 | Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY)); | 7 | const normalized = trim(value)?.toLowerCase(); |
| 8 | return normalized === "1" || normalized === "true" || normalized === "yes"; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export const isLocalControlPlaneUpstream = (): boolean => { | ||
| 12 | const upstream = trim(process.env.CONTROL_PLANE_UPSTREAM_URL) ?? "http://127.0.0.1:8787"; | ||
| 13 | |||
| 14 | try { | ||
| 15 | const url = new URL(upstream); | ||
| 16 | return ( | ||
| 17 | url.hostname === "127.0.0.1" | ||
| 18 | || url.hostname === "localhost" | ||
| 19 | || url.hostname === "0.0.0.0" | ||
| 20 | || url.hostname === "::1" | ||
| 21 | || url.hostname === "[::1]" | ||
| 22 | ); | ||
| 23 | } catch { | ||
| 24 | return false; | ||
| 25 | } | ||
| 26 | }; | ||
| 27 | |||
| 28 | export const isWorkosEnabled = (): boolean => { | ||
| 29 | if (isTruthy(process.env.WORKOS_FORCE_ENABLED)) { | ||
| 30 | return Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY)); | ||
| 31 | } | ||
| 32 | |||
| 33 | if (isLocalControlPlaneUpstream()) { | ||
| 34 | return false; | ||
| 35 | } | ||
| 36 | |||
| 37 | return Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY)); | ||
| 38 | }; | ||
| 8 | 39 | ||
| 9 | export const externalOriginFromRequest = (request: Request): string => { | 40 | export const externalOriginFromRequest = (request: Request): string => { |
| 10 | const forwardedHost = trim(request.headers.get("x-forwarded-host") ?? undefined); | 41 | const forwardedHost = trim(request.headers.get("x-forwarded-host") ?? undefined); |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | |||
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "dev": "next dev --port 4312", | 6 | "dev": "next dev --turbopack --port 4312", |
| 7 | "build": "next build", | 7 | "build": "next build", |
| 8 | "start": "next start --port 4312", | 8 | "start": "next start --port 4312", |
| 9 | "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json" | 9 | "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json" |
| @@ -14,13 +14,20 @@ · 4 unmodified lines | |||
| 14 | "@executor-v2/management-api": "workspace:*", | 14 | "@executor-v2/management-api": "workspace:*", |
| 15 | "@executor-v2/schema": "workspace:*", | 15 | "@executor-v2/schema": "workspace:*", |
| 16 | "@workos-inc/authkit-nextjs": "^2.13.0", | 16 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 17 | "class-variance-authority": "^0.7.1", | ||
| 18 | "clsx": "^2.1.1", | ||
| 17 | "effect": "catalog:", | 19 | "effect": "catalog:", |
| 18 | "next": "^15.4.6", | 20 | "next": "^15.4.6", |
| 19 | "react": "^19.1.1", | 21 | "react": "^19.1.1", |
| 20 | "react-dom": "^19.1.1" | 22 | "react-dom": "^19.1.1", |
| 23 | "tailwind-merge": "^3.3.1" | ||
| 21 | }, | 24 | }, |
| 22 | "devDependencies": { | 25 | "devDependencies": { |
| 26 | "@tailwindcss/postcss": "^4.1.12", | ||
| 23 | "@types/react": "^19.1.10", | 27 | "@types/react": "^19.1.10", |
| 24 | "@types/react-dom": "^19.1.7" | 28 | "@types/react-dom": "^19.1.7", |
| 29 | "postcss": "^8.5.6", | ||
| 30 | "tailwindcss": "^4.1.12", | ||
| 31 | "tw-animate-css": "^1.4.0" | ||
| 25 | } | 32 | } |
| 26 | } | 33 | } |
| @@ -0,0 +1,7 @@ | |||
| 1 | const config = { | ||
| 2 | plugins: { | ||
| 3 | "@tailwindcss/postcss": {}, | ||
| 4 | }, | ||
| 5 | }; | ||
| 6 | |||
| 7 | export default config; | ||
| @@ -35,7 +35,7 @@ · 34 unmodified lines | |||
| 35 | }, | 35 | }, |
| 36 | "devDependencies": { | 36 | "devDependencies": { |
| 37 | "@effect/vitest": "catalog:", | 37 | "@effect/vitest": "catalog:", |
| 38 | "convex-test": "^0.0.41", | 38 | "convex-test": "workspace:*", |
| 39 | "vitest": "catalog:", | 39 | "vitest": "catalog:", |
| 40 | }, | 40 | }, |
| 41 | }, | 41 | }, |
| @@ -69,14 +69,21 @@ · 27 unmodified lines | |||
| 69 | "@executor-v2/management-api": "workspace:*", | 69 | "@executor-v2/management-api": "workspace:*", |
| 70 | "@executor-v2/schema": "workspace:*", | 70 | "@executor-v2/schema": "workspace:*", |
| 71 | "@workos-inc/authkit-nextjs": "^2.13.0", | 71 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 72 | "class-variance-authority": "^0.7.1", | ||
| 73 | "clsx": "^2.1.1", | ||
| 72 | "effect": "catalog:", | 74 | "effect": "catalog:", |
| 73 | "next": "^15.4.6", | 75 | "next": "^15.4.6", |
| 74 | "react": "^19.1.1", | 76 | "react": "^19.1.1", |
| 75 | "react-dom": "^19.1.1", | 77 | "react-dom": "^19.1.1", |
| 78 | "tailwind-merge": "^3.3.1", | ||
| 76 | }, | 79 | }, |
| 77 | "devDependencies": { | 80 | "devDependencies": { |
| 81 | "@tailwindcss/postcss": "^4.1.12", | ||
| 78 | "@types/react": "^19.1.10", | 82 | "@types/react": "^19.1.10", |
| 79 | "@types/react-dom": "^19.1.7", | 83 | "@types/react-dom": "^19.1.7", |
| 84 | "postcss": "^8.5.6", | ||
| 85 | "tailwindcss": "^4.1.12", | ||
| 86 | "tw-animate-css": "^1.4.0", | ||
| 80 | }, | 87 | }, |
| 81 | }, | 88 | }, |
| 82 | "apps/www": { | 89 | "apps/www": { |
| @@ -132,6 +139,13 @@ · 49 unmodified lines | |||
| 132 | "vitest": "catalog:", | 139 | "vitest": "catalog:", |
| 133 | }, | 140 | }, |
| 134 | }, | 141 | }, |
| 142 | "packages/convex-test": { | ||
| 143 | "name": "convex-test", | ||
| 144 | "version": "0.0.41-patched", | ||
| 145 | "peerDependencies": { | ||
| 146 | "convex": "^1.32.0", | ||
| 147 | }, | ||
| 148 | }, | ||
| 135 | "packages/domain": { | 149 | "packages/domain": { |
| 136 | "name": "@executor-v2/domain", | 150 | "name": "@executor-v2/domain", |
| 137 | "dependencies": { | 151 | "dependencies": { |
| @@ -167,6 +181,7 @@ · 29 unmodified lines | |||
| 167 | "@executor-v2/persistence-ports": "workspace:*", | 181 | "@executor-v2/persistence-ports": "workspace:*", |
| 168 | "@executor-v2/schema": "workspace:*", | 182 | "@executor-v2/schema": "workspace:*", |
| 169 | "effect": "catalog:", | 183 | "effect": "catalog:", |
| 184 | "yaml": "^2.8.1", | ||
| 170 | }, | 185 | }, |
| 171 | "devDependencies": { | 186 | "devDependencies": { |
| 172 | "@effect/vitest": "catalog:", | 187 | "@effect/vitest": "catalog:", |
| @@ -782,6 +797,36 @@ · 609 unmodified lines | |||
| 782 | 797 | ||
| 783 | "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], | 798 | "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], |
| 784 | 799 | ||
| 800 | "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], | ||
| 801 | |||
| 802 | "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], | ||
| 803 | |||
| 804 | "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], | ||
| 805 | |||
| 806 | "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], | ||
| 807 | |||
| 808 | "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], | ||
| 809 | |||
| 810 | "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], | ||
| 811 | |||
| 812 | "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], | ||
| 813 | |||
| 814 | "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], | ||
| 815 | |||
| 816 | "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], | ||
| 817 | |||
| 818 | "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], | ||
| 819 | |||
| 820 | "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], | ||
| 821 | |||
| 822 | "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], | ||
| 823 | |||
| 824 | "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], | ||
| 825 | |||
| 826 | "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], | ||
| 827 | |||
| 828 | "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], | ||
| 829 | |||
| 785 | "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], | 830 | "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], |
| 786 | 831 | ||
| 787 | "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], | 832 | "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], |
| @@ -1048,6 +1093,8 @@ · 260 unmodified lines | |||
| 1048 | 1093 | ||
| 1049 | "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], | 1094 | "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], |
| 1050 | 1095 | ||
| 1096 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], | ||
| 1097 | |||
| 1051 | "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], | 1098 | "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], |
| 1052 | 1099 | ||
| 1053 | "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], | 1100 | "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], |
| @@ -1084,7 +1131,7 @@ · 30 unmodified lines | |||
| 1084 | 1131 | ||
| 1085 | "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], | 1132 | "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], |
| 1086 | 1133 | ||
| 1087 | "convex-test": ["convex-test@0.0.41", "", { "peerDependencies": { "convex": "^1.16.4" } }, "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ=="], | 1134 | "convex-test": ["convex-test@workspace:packages/convex-test"], |
| 1088 | 1135 | ||
| 1089 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], | 1136 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], |
| 1090 | 1137 | ||
| @@ -1174,6 +1221,8 @@ · 83 unmodified lines | |||
| 1174 | 1221 | ||
| 1175 | "end-of-stream": ["end-of-stream@1.1.0", "", { "dependencies": { "once": "~1.3.0" } }, "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ=="], | 1222 | "end-of-stream": ["end-of-stream@1.1.0", "", { "dependencies": { "once": "~1.3.0" } }, "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ=="], |
| 1176 | 1223 | ||
| 1224 | "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], | ||
| 1225 | |||
| 1177 | "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], | 1226 | "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], |
| 1178 | 1227 | ||
| 1179 | "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], | 1228 | "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], |
| @@ -1412,6 +1461,30 @@ · 232 unmodified lines | |||
| 1412 | 1461 | ||
| 1413 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], | 1462 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], |
| 1414 | 1463 | ||
| 1464 | "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], | ||
| 1465 | |||
| 1466 | "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], | ||
| 1467 | |||
| 1468 | "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], | ||
| 1469 | |||
| 1470 | "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], | ||
| 1471 | |||
| 1472 | "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], | ||
| 1473 | |||
| 1474 | "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], | ||
| 1475 | |||
| 1476 | "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], | ||
| 1477 | |||
| 1478 | "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], | ||
| 1479 | |||
| 1480 | "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], | ||
| 1481 | |||
| 1482 | "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], | ||
| 1483 | |||
| 1484 | "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], | ||
| 1485 | |||
| 1486 | "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], | ||
| 1487 | |||
| 1415 | "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], | 1488 | "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], |
| 1416 | 1489 | ||
| 1417 | "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], | 1490 | "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], |
| @@ -1680,7 +1753,7 @@ · 262 unmodified lines | |||
| 1680 | 1753 | ||
| 1681 | "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], | 1754 | "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], |
| 1682 | 1755 | ||
| 1683 | "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], | 1756 | "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], |
| 1684 | 1757 | ||
| 1685 | "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], | 1758 | "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], |
| 1686 | 1759 | ||
| @@ -1882,7 +1955,11 @@ · 195 unmodified lines | |||
| 1882 | 1955 | ||
| 1883 | "svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="], | 1956 | "svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="], |
| 1884 | 1957 | ||
| 1885 | "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], | 1958 | "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], |
| 1959 | |||
| 1960 | "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], | ||
| 1961 | |||
| 1962 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], | ||
| 1886 | 1963 | ||
| 1887 | "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], | 1964 | "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], |
| 1888 | 1965 | ||
| @@ -1946,6 +2023,8 @@ · 57 unmodified lines | |||
| 1946 | 2023 | ||
| 1947 | "turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="], | 2024 | "turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="], |
| 1948 | 2025 | ||
| 2026 | "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], | ||
| 2027 | |||
| 1949 | "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], | 2028 | "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], |
| 1950 | 2029 | ||
| 1951 | "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], | 2030 | "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], |
| @@ -2132,7 +2211,7 @@ · 180 unmodified lines | |||
| 2132 | 2211 | ||
| 2133 | "@astrojs/react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], | 2212 | "@astrojs/react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], |
| 2134 | 2213 | ||
| 2135 | "@astrojs/tailwind/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], | 2214 | "@astrojs/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], |
| 2136 | 2215 | ||
| 2137 | "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], | 2216 | "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], |
| 2138 | 2217 | ||
| @@ -2146,6 +2225,8 @@ · 7 unmodified lines | |||
| 2146 | 2225 | ||
| 2147 | "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | 2226 | "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], |
| 2148 | 2227 | ||
| 2228 | "@executor-v2/app-www/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], | ||
| 2229 | |||
| 2149 | "@executor-v2/confect/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], | 2230 | "@executor-v2/confect/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], |
| 2150 | 2231 | ||
| 2151 | "@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], | 2232 | "@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], |
| @@ -2156,6 +2237,20 @@ · 4 unmodified lines | |||
| 2156 | 2237 | ||
| 2157 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], | 2238 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| 2158 | 2239 | ||
| 2240 | "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | ||
| 2241 | |||
| 2242 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], | ||
| 2243 | |||
| 2244 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], | ||
| 2245 | |||
| 2246 | "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], | ||
| 2247 | |||
| 2248 | "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], | ||
| 2249 | |||
| 2250 | "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], | ||
| 2251 | |||
| 2252 | "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], | ||
| 2253 | |||
| 2159 | "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], | 2254 | "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], |
| 2160 | 2255 | ||
| 2161 | "@vercel/backends/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], | 2256 | "@vercel/backends/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], |
| @@ -2274,6 +2369,8 @@ · 112 unmodified lines | |||
| 2274 | 2369 | ||
| 2275 | "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], | 2370 | "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], |
| 2276 | 2371 | ||
| 2372 | "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], | ||
| 2373 | |||
| 2277 | "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], | 2374 | "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], |
| 2278 | 2375 | ||
| 2279 | "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], | 2376 | "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], |
| @@ -2288,18 +2385,12 @@ · 8 unmodified lines | |||
| 2288 | 2385 | ||
| 2289 | "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], | 2386 | "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], |
| 2290 | 2387 | ||
| 2291 | "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], | ||
| 2292 | |||
| 2293 | "tailwindcss/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], | ||
| 2294 | |||
| 2295 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | 2388 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], |
| 2296 | 2389 | ||
| 2297 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], | 2390 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], |
| 2298 | 2391 | ||
| 2299 | "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], | 2392 | "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], |
| 2300 | 2393 | ||
| 2301 | "vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], | ||
| 2302 | |||
| 2303 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], | 2394 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 2304 | 2395 | ||
| 2305 | "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], | 2396 | "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], |
| @@ -2318,10 +2409,12 @@ · 12 unmodified lines | |||
| 2318 | 2409 | ||
| 2319 | "@astrojs/react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], | 2410 | "@astrojs/react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], |
| 2320 | 2411 | ||
| 2321 | "@astrojs/react/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], | 2412 | "@astrojs/tailwind/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], |
| 2322 | 2413 | ||
| 2323 | "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], | 2414 | "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], |
| 2324 | 2415 | ||
| 2416 | "@executor-v2/app-www/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], | ||
| 2417 | |||
| 2325 | "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], | 2418 | "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], |
| 2326 | 2419 | ||
| 2327 | "@executor-v2/confect/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], | 2420 | "@executor-v2/confect/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], |
| @@ -2340,6 +2433,8 @@ · 12 unmodified lines | |||
| 2340 | 2433 | ||
| 2341 | "@executor-v2/confect/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], | 2434 | "@executor-v2/confect/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], |
| 2342 | 2435 | ||
| 2436 | "@executor-v2/confect/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], | ||
| 2437 | |||
| 2343 | "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], | 2438 | "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], |
| 2344 | 2439 | ||
| 2345 | "@vercel/express/@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], | 2440 | "@vercel/express/@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| @@ -2420,8 +2515,6 @@ · 74 unmodified lines | |||
| 2420 | 2515 | ||
| 2421 | "astro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], | 2516 | "astro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], |
| 2422 | 2517 | ||
| 2423 | "astro/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], | ||
| 2424 | |||
| 2425 | "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], | 2518 | "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| 2426 | 2519 | ||
| 2427 | "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], | 2520 | "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
| @@ -2440,10 +2533,6 @@ · 12 unmodified lines | |||
| 2440 | 2533 | ||
| 2441 | "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | 2534 | "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], |
| 2442 | 2535 | ||
| 2443 | "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], | ||
| 2444 | |||
| 2445 | "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | ||
| 2446 | |||
| 2447 | "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | 2536 | "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], |
| 2448 | 2537 | ||
| 2449 | "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], | 2538 | "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], |
| @@ -2560,6 +2649,14 @@ · 110 unmodified lines | |||
| 2560 | 2649 | ||
| 2561 | "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], | 2650 | "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], |
| 2562 | 2651 | ||
| 2652 | "@astrojs/tailwind/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], | ||
| 2653 | |||
| 2654 | "@astrojs/tailwind/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | ||
| 2655 | |||
| 2656 | "@executor-v2/app-www/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], | ||
| 2657 | |||
| 2658 | "@executor-v2/app-www/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | ||
| 2659 | |||
| 2563 | "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], | 2660 | "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], |
| 2564 | 2661 | ||
| 2565 | "@vercel/fun/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], | 2662 | "@vercel/fun/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], |
| @@ -2626,8 +2723,10 @@ · 60 unmodified lines | |||
| 2626 | 2723 | ||
| 2627 | "micro/raw-body/http-errors/toidentifier": ["toidentifier@1.0.0", "", {}, "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="], | 2724 | "micro/raw-body/http-errors/toidentifier": ["toidentifier@1.0.0", "", {}, "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="], |
| 2628 | 2725 | ||
| 2629 | "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | ||
| 2630 | |||
| 2631 | "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], | 2726 | "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], |
| 2727 | |||
| 2728 | "@astrojs/tailwind/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | ||
| 2729 | |||
| 2730 | "@executor-v2/app-www/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | ||
| 2632 | } | 2731 | } |
| 2633 | } | 2732 | } |
| @@ -0,0 +1,3 @@ | |||
| 1 | import { Value } from "convex/values"; | ||
| 2 | export declare function compareValues(k1: Value | undefined, k2: Value | undefined): number; | ||
| 3 | //# sourceMappingURL=compare.d.ts.map | ||
| @@ -0,0 +1,80 @@ | |||
| 1 | // Returns -1 if k1 < k2 | ||
| 2 | // Returns 0 if k1 === k2 | ||
| 3 | // Returns 1 if k1 > k2 | ||
| 4 | export function compareValues(k1, k2) { | ||
| 5 | return compareAsTuples(makeComparable(k1), makeComparable(k2)); | ||
| 6 | } | ||
| 7 | function compareAsTuples(a, b) { | ||
| 8 | if (a[0] === b[0]) { | ||
| 9 | return compareSameTypeValues(a[1], b[1]); | ||
| 10 | } | ||
| 11 | else if (a[0] < b[0]) { | ||
| 12 | return -1; | ||
| 13 | } | ||
| 14 | return 1; | ||
| 15 | } | ||
| 16 | function compareSameTypeValues(v1, v2) { | ||
| 17 | if (v1 === undefined || v1 === null) { | ||
| 18 | return 0; | ||
| 19 | } | ||
| 20 | if (typeof v1 === "bigint" || | ||
| 21 | typeof v1 === "number" || | ||
| 22 | typeof v1 === "boolean" || | ||
| 23 | typeof v1 === "string") { | ||
| 24 | return v1 < v2 ? -1 : v1 === v2 ? 0 : 1; | ||
| 25 | } | ||
| 26 | if (!Array.isArray(v1) || !Array.isArray(v2)) { | ||
| 27 | throw new Error(`Unexpected type ${v1}`); | ||
| 28 | } | ||
| 29 | for (let i = 0; i < v1.length && i < v2.length; i++) { | ||
| 30 | const cmp = compareAsTuples(v1[i], v2[i]); | ||
| 31 | if (cmp !== 0) { | ||
| 32 | return cmp; | ||
| 33 | } | ||
| 34 | } | ||
| 35 | if (v1.length < v2.length) { | ||
| 36 | return -1; | ||
| 37 | } | ||
| 38 | if (v1.length > v2.length) { | ||
| 39 | return 1; | ||
| 40 | } | ||
| 41 | return 0; | ||
| 42 | } | ||
| 43 | // Returns an array which can be compared to other arrays as if they were tuples. | ||
| 44 | // For example, [1, null] < [2, 1n] means null sorts before all bigints | ||
| 45 | // And [3, 5] < [3, 6] means floats sort as expected | ||
| 46 | // And [7, [[5, "a"]]] < [7, [[5, "a"], [5, "b"]]] means arrays sort as expected | ||
| 47 | function makeComparable(v) { | ||
| 48 | if (v === undefined) { | ||
| 49 | return [0, undefined]; | ||
| 50 | } | ||
| 51 | if (v === null) { | ||
| 52 | return [1, null]; | ||
| 53 | } | ||
| 54 | if (typeof v === "bigint") { | ||
| 55 | return [2, v]; | ||
| 56 | } | ||
| 57 | if (typeof v === "number") { | ||
| 58 | if (isNaN(v)) { | ||
| 59 | // Consider all NaNs to be equal. | ||
| 60 | return [3.5, 0]; | ||
| 61 | } | ||
| 62 | return [3, v]; | ||
| 63 | } | ||
| 64 | if (typeof v === "boolean") { | ||
| 65 | return [4, v]; | ||
| 66 | } | ||
| 67 | if (typeof v === "string") { | ||
| 68 | return [5, v]; | ||
| 69 | } | ||
| 70 | if (v instanceof ArrayBuffer) { | ||
| 71 | return [6, Array.from(new Uint8Array(v)).map(makeComparable)]; | ||
| 72 | } | ||
| 73 | if (Array.isArray(v)) { | ||
| 74 | return [7, v.map(makeComparable)]; | ||
| 75 | } | ||
| 76 | // Otherwise, it's an POJO. | ||
| 77 | const keys = Object.keys(v).sort(); | ||
| 78 | const pojo = keys.map((k) => [k, v[k]]); | ||
| 79 | return [8, pojo.map(makeComparable)]; | ||
| 80 | } | ||
| @@ -0,0 +1,98 @@ | |||
| 1 | import { DataModelFromSchemaDefinition, FunctionReference, FunctionReturnType, GenericActionCtx, GenericDataModel, GenericMutationCtx, GenericSchema, OptionalRestArgs, SchemaDefinition, UserIdentity } from "convex/server"; | ||
| 2 | export type TestConvex<SchemaDef extends SchemaDefinition<any, boolean>> = TestConvexForDataModelAndIdentity<DataModelFromSchemaDefinition<SchemaDef>>; | ||
| 3 | export type TestConvexForDataModel<DataModel extends GenericDataModel> = { | ||
| 4 | /** | ||
| 5 | * Call a public or internal query. | ||
| 6 | * | ||
| 7 | * @param query A {@link FunctionReference} for the query. | ||
| 8 | * @param args An arguments object for the query. If this is omitted, | ||
| 9 | * the arguments will be `{}`. | ||
| 10 | * @returns A `Promise` of the query's result. | ||
| 11 | */ | ||
| 12 | query: <Query extends FunctionReference<"query", any>>(query: Query, ...args: OptionalRestArgs<Query>) => Promise<FunctionReturnType<Query>>; | ||
| 13 | /** | ||
| 14 | * Call a public or internal mutation. | ||
| 15 | * | ||
| 16 | * @param mutation A {@link FunctionReference} for the mutation. | ||
| 17 | * @param args An arguments object for the mutation. If this is omitted, | ||
| 18 | * the arguments will be `{}`. | ||
| 19 | * @returns A `Promise` of the mutation's result. | ||
| 20 | */ | ||
| 21 | mutation: <Mutation extends FunctionReference<"mutation", any>>(mutation: Mutation, ...args: OptionalRestArgs<Mutation>) => Promise<FunctionReturnType<Mutation>>; | ||
| 22 | /** | ||
| 23 | * Call a public or internal action. | ||
| 24 | * | ||
| 25 | * @param action A {@link FunctionReference} for the action. | ||
| 26 | * @param args An arguments object for the action. If this is omitted, | ||
| 27 | * the arguments will be `{}`. | ||
| 28 | * @returns A `Promise` of the action's result. | ||
| 29 | */ | ||
| 30 | action: <Action extends FunctionReference<"action", any>>(action: Action, ...args: OptionalRestArgs<Action>) => Promise<FunctionReturnType<Action>>; | ||
| 31 | /** | ||
| 32 | * Read from and write to the mock backend. | ||
| 33 | * | ||
| 34 | * @param func The async function that reads or writes to the mock backend. | ||
| 35 | * It receives a ctx as its first argument that conforms to | ||
| 36 | * {@link GenericMutationCtx}, | ||
| 37 | * so it can be passed to functions that expect either context, use file | ||
| 38 | * storage, etc. | ||
| 39 | * @returns A `Promise` of the function's result. | ||
| 40 | */ | ||
| 41 | run: <Output>(func: (ctx: GenericMutationCtx<DataModel> & Pick<GenericActionCtx<DataModel>, "storage">) => Promise<Output>) => Promise<Output>; | ||
| 42 | /** | ||
| 43 | * Call an HTTP action. | ||
| 44 | * | ||
| 45 | * @param path The request URL's path and optional query and fragment. | ||
| 46 | * @param init Standard `fetch` options. | ||
| 47 | */ | ||
| 48 | fetch(pathQueryFragment: string, init?: RequestInit): Promise<Response>; | ||
| 49 | /** | ||
| 50 | * Wait for all scheduled functions currently in the "inProgress" state | ||
| 51 | * to either finish successfully or fail. | ||
| 52 | * | ||
| 53 | * Use in combination with `vi.useFakeTimers()` and `vi.runAllTimers()` | ||
| 54 | * to control precisely the execution of scheduled functions. | ||
| 55 | * | ||
| 56 | * Typically: | ||
| 57 | * 1. Use `vi.runAllTimers()` or similar to advance | ||
| 58 | * time such that a function is scheduled. | ||
| 59 | * 2. Use `finishInProgressScheduledFunctions()` to wait for the function | ||
| 60 | */ | ||
| 61 | finishInProgressScheduledFunctions: () => Promise<void>; | ||
| 62 | /** | ||
| 63 | * Wait for all currently scheduled functions and any functions they | ||
| 64 | * schedule to either finish successfully or fail. | ||
| 65 | * | ||
| 66 | * Use in combination with `vi.useFakeTimers()` to test scheduled functions. | ||
| 67 | * | ||
| 68 | * @param advanceTimers Function that advances timers, | ||
| 69 | * usually `vi.runAllTimers`. This function will be called in a loop | ||
| 70 | * with `finishInProgressScheduledFunctions()`. | ||
| 71 | */ | ||
| 72 | finishAllScheduledFunctions: (advanceTimers: () => void) => Promise<void>; | ||
| 73 | }; | ||
| 74 | export type TestConvexForDataModelAndIdentity<DataModel extends GenericDataModel> = { | ||
| 75 | /** | ||
| 76 | * To test functions which depend on the current authenticated user identity | ||
| 77 | * you can create a version of the `t` accessor with given user identity | ||
| 78 | * attributes. | ||
| 79 | * @param identity A subset of {@link UserIdentity} attributes. If you | ||
| 80 | * don't provide `issuer`, `subject` or `tokenIdentifier` they are | ||
| 81 | * generated automatically. | ||
| 82 | */ | ||
| 83 | withIdentity(identity: Partial<UserIdentity>): TestConvexForDataModel<DataModel>; | ||
| 84 | registerComponent: (componentPath: string, schema: SchemaDefinition<GenericSchema, boolean>, glob: Record<string, () => Promise<any>>) => void; | ||
| 85 | } & TestConvexForDataModel<DataModel>; | ||
| 86 | /** | ||
| 87 | * Call this function at the start of each of your tests. | ||
| 88 | * | ||
| 89 | * @param schema The default export from your "schema.ts" file. | ||
| 90 | * @param modules If you have a custom `functions` path | ||
| 91 | * in convex.json, provide the module map with your functions | ||
| 92 | * by calling `import.meta.glob` with the appropriate glob pattern | ||
| 93 | * for paths relative to the file where you call it. | ||
| 94 | * @returns an object which is by convention stored in the `t` variable | ||
| 95 | * and which provides methods for exercising your Convex functions. | ||
| 96 | */ | ||
| 97 | export declare const convexTest: <Schema extends GenericSchema>(schema?: SchemaDefinition<Schema, boolean>, modules?: Record<string, () => Promise<any>>) => TestConvex<SchemaDefinition<Schema, boolean>>; | ||
| 98 | //# sourceMappingURL=index.d.ts.map | ||
| @@ -0,0 +1,1507 @@ | |||
| 1 | import { actionGeneric, getFunctionAddress, httpActionGeneric, makeFunctionReference, mutationGeneric, queryGeneric, } from "convex/server"; | ||
| 2 | import { convexToJson, jsonToConvex, } from "convex/values"; | ||
| 3 | import { compareValues } from "./compare.js"; | ||
| 4 | const ROOT_COMPONENT_PATH = ""; | ||
| 5 | class DatabaseFake { | ||
| 6 | _componentPath; | ||
| 7 | _documents = {}; | ||
| 8 | _storage = {}; | ||
| 9 | _nextQueryId = 1; | ||
| 10 | _nextDocId = 10000; | ||
| 11 | _lastCreationTime = 0; | ||
| 12 | _queryResults = {}; | ||
| 13 | // TODO: Make this more robust and cleaner | ||
| 14 | jobListener = () => { }; | ||
| 15 | // Pending writes for each level of transaction nesting. | ||
| 16 | // Whenever a child mutation begins, a new level is created for all | ||
| 17 | // components. | ||
| 18 | // - When a mutation performs updates, they are staged in the last | ||
| 19 | // level of writes for the mutation's component. | ||
| 20 | // - When a child mutation commits, the writes are merged up one level. | ||
| 21 | // - When the top-level mutation commits, the writes are applied to the | ||
| 22 | // database. | ||
| 23 | // - When a mutation is rolled back, last level of writes is discarded. | ||
| 24 | _writes = []; | ||
| 25 | _schema; | ||
| 26 | constructor(schema, componentPath) { | ||
| 27 | this._schema = | ||
| 28 | schema === null | ||
| 29 | ? null | ||
| 30 | : { | ||
| 31 | schemaValidation: schema.schemaValidation, | ||
| 32 | tables: new Map(Object.entries(schema.tables).map(([name, tableSchema]) => [ | ||
| 33 | name, | ||
| 34 | tableSchema.export(), | ||
| 35 | ])), | ||
| 36 | }; | ||
| 37 | this._componentPath = componentPath; | ||
| 38 | this.validateSchema(); | ||
| 39 | } | ||
| 40 | // Called by TransactionManager.begin. | ||
| 41 | startTransaction() { | ||
| 42 | this._writes.push({}); | ||
| 43 | } | ||
| 44 | get(tableName, id) { | ||
| 45 | this._validateId(tableName, id); | ||
| 46 | for (let i = this._writes.length - 1; i >= 0; i--) { | ||
| 47 | const write = this._writes[i][id]; | ||
| 48 | if (write !== undefined) { | ||
| 49 | return write; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | return this._documents[id] ?? null; | ||
| 53 | } | ||
| 54 | // Note that this is not the format the real backend | ||
| 55 | // uses for IDs. | ||
| 56 | _generateId(table) { | ||
| 57 | const id = this._nextDocId.toString() + ";" + table; | ||
| 58 | this._nextDocId += 1; | ||
| 59 | return id; | ||
| 60 | } | ||
| 61 | storeFile(storageId, blob) { | ||
| 62 | this._storage[storageId] = blob; | ||
| 63 | } | ||
| 64 | getFile(storageId) { | ||
| 65 | if (this.get("_storage", storageId) === null) { | ||
| 66 | return null; | ||
| 67 | } | ||
| 68 | return this._storage[storageId]; | ||
| 69 | } | ||
| 70 | _addWrite(id, newValue) { | ||
| 71 | if (this._writes.length === 0) { | ||
| 72 | throw new Error(`Write outside of transaction ${id}`); | ||
| 73 | } | ||
| 74 | this._writes[this._writes.length - 1][id] = newValue; | ||
| 75 | } | ||
| 76 | insert(table, value) { | ||
| 77 | this._validate(table, value); | ||
| 78 | const _id = this._generateId(table); | ||
| 79 | const now = Date.now(); | ||
| 80 | const _creationTime = now <= this._lastCreationTime ? this._lastCreationTime + 0.001 : now; | ||
| 81 | this._lastCreationTime = _creationTime; | ||
| 82 | this._addWrite(_id, { ...value, _id, _creationTime }); | ||
| 83 | return _id; | ||
| 84 | } | ||
| 85 | patch(tableName, id, value) { | ||
| 86 | this._validateId(tableName, id); | ||
| 87 | if (typeof value !== "object") { | ||
| 88 | throw new Error(`Invalid argument \`value\` in \`db.patch\`, expected object but got '${typeof value}': ${value}`); | ||
| 89 | } | ||
| 90 | const document = this.get(tableName, id); | ||
| 91 | if (document === null) { | ||
| 92 | throw new Error(`Patch on non-existent document with ID "${id}"`); | ||
| 93 | } | ||
| 94 | const { _id, _creationTime, ...fields } = document; | ||
| 95 | if (value._id !== undefined && value._id !== _id) { | ||
| 96 | throw new Error(`Provided \`_id\` field value "${value._id}" ` + | ||
| 97 | `does not match the document ID "${_id}"`); | ||
| 98 | } | ||
| 99 | if (value._creationTime !== undefined && | ||
| 100 | value._creationTime !== _creationTime) { | ||
| 101 | throw new Error(`Provided \`_creationTime\` field value ${value._creationTime} ` + | ||
| 102 | `does not match the document's creation time ${_creationTime}`); | ||
| 103 | } | ||
| 104 | delete value["_id"]; | ||
| 105 | delete value["_creationTime"]; | ||
| 106 | const convexValue = {}; | ||
| 107 | for (const [key, v] of Object.entries(value)) { | ||
| 108 | convexValue[key] = evaluateValue(v); | ||
| 109 | } | ||
| 110 | const merged = { ...fields, ...convexValue }; | ||
| 111 | this._validate(tableNameFromId(_id), merged); | ||
| 112 | this._addWrite(id, { _id, _creationTime, ...merged }); | ||
| 113 | } | ||
| 114 | replace(tableName, id, value) { | ||
| 115 | this._validateId(tableName, id); | ||
| 116 | if (typeof value !== "object") { | ||
| 117 | throw new Error(`Invalid argument \`value\` in \`db.replace\`, expected object but got '${typeof value}': ${value}`); | ||
| 118 | } | ||
| 119 | const document = this.get(tableName, id); | ||
| 120 | if (document === null) { | ||
| 121 | throw new Error(`Replace on non-existent document with ID "${id}"`); | ||
| 122 | } | ||
| 123 | if (value._id !== undefined && value._id !== document._id) { | ||
| 124 | throw new Error(`Provided \`_id\` field value "${value._id}" ` + | ||
| 125 | `does not match the document ID "${document._id}"`); | ||
| 126 | } | ||
| 127 | if (value._creationTime !== undefined && | ||
| 128 | value._creationTime !== document._creationTime) { | ||
| 129 | throw new Error(`Provided \`_creationTime\` field value ${value._creationTime} ` + | ||
| 130 | `does not match the document's creation time ${document._creationTime}`); | ||
| 131 | } | ||
| 132 | delete value["_id"]; | ||
| 133 | delete value["_creationTime"]; | ||
| 134 | const convexValue = {}; | ||
| 135 | for (const [key, v] of Object.entries(value)) { | ||
| 136 | convexValue[key] = evaluateValue(v); | ||
| 137 | } | ||
| 138 | this._validate(tableNameFromId(document._id), convexValue); | ||
| 139 | this._addWrite(id, { | ||
| 140 | ...convexValue, | ||
| 141 | _id: document._id, | ||
| 142 | _creationTime: document._creationTime, | ||
| 143 | }); | ||
| 144 | } | ||
| 145 | delete(tableName, id) { | ||
| 146 | this._validateId(tableName, id); | ||
| 147 | const document = this.get(tableName, id); | ||
| 148 | if (document === null) { | ||
| 149 | throw new Error("Delete on non-existent doc"); | ||
| 150 | } | ||
| 151 | this._addWrite(id, null); | ||
| 152 | } | ||
| 153 | _validateId(expectedTableName, id) { | ||
| 154 | if (typeof id !== "string") { | ||
| 155 | throw new Error(`Invalid argument \`id\`, expected string but got '${typeof id}': ${id}`); | ||
| 156 | } | ||
| 157 | if (expectedTableName === undefined) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | if (typeof expectedTableName !== "string") { | ||
| 161 | throw new Error(`Invalid argument \`tableName\`, expected string but got '${typeof expectedTableName}': ${expectedTableName}`); | ||
| 162 | } | ||
| 163 | const actualTableName = tableNameFromId(id); | ||
| 164 | if (actualTableName === null) { | ||
| 165 | throw new Error(`Invalid argument \`id\`, expected ID value but got '${id}'`); | ||
| 166 | } | ||
| 167 | if (actualTableName !== expectedTableName) { | ||
| 168 | throw new Error(`Invalid argument \`id\`, expected ID in table '${expectedTableName}' but got ID in table '${actualTableName}'`); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | commit() { | ||
| 172 | const lastWrites = this._writes.pop(); | ||
| 173 | if (!lastWrites) { | ||
| 174 | throw new Error("Transaction already committed or rolled back"); | ||
| 175 | } | ||
| 176 | for (const [id, write] of Object.entries(lastWrites)) { | ||
| 177 | const _id = id; | ||
| 178 | if (this._writes.length === 0) { | ||
| 179 | if (write === null) { | ||
| 180 | delete this._documents[_id]; | ||
| 181 | } | ||
| 182 | else { | ||
| 183 | this._documents[_id] = write; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | else { | ||
| 187 | this._addWrite(_id, write); | ||
| 188 | } | ||
| 189 | } | ||
| 190 | } | ||
| 191 | rollbackWrites() { | ||
| 192 | if (this._writes.length === 0) { | ||
| 193 | throw new Error("Transaction already committed or rolled back"); | ||
| 194 | } | ||
| 195 | this._writes.pop(); | ||
| 196 | } | ||
| 197 | _validate(tableName, doc) { | ||
| 198 | if (this._schema === null || !this._schema.schemaValidation) { | ||
| 199 | return; | ||
| 200 | } | ||
| 201 | const validator = this._schema.tables.get(tableName)?.documentType; | ||
| 202 | if (validator === undefined) { | ||
| 203 | return; | ||
| 204 | } | ||
| 205 | validateValidator(validator, doc); | ||
| 206 | } | ||
| 207 | startQuery(query) { | ||
| 208 | const id = this._nextQueryId; | ||
| 209 | const results = this._evaluateQuery(query); | ||
| 210 | this._queryResults[id] = results; | ||
| 211 | this._nextQueryId += 1; | ||
| 212 | return id; | ||
| 213 | } | ||
| 214 | queryNext(queryId) { | ||
| 215 | const results = this._queryResults[queryId]; | ||
| 216 | if (results === undefined) { | ||
| 217 | throw new Error("Bad queryId"); | ||
| 218 | } | ||
| 219 | if (results.length === 0) { | ||
| 220 | return { value: null, done: true }; | ||
| 221 | } | ||
| 222 | else { | ||
| 223 | return { value: results.shift(), done: false }; | ||
| 224 | } | ||
| 225 | } | ||
| 226 | paginate({ query, cursor, pageSize, }) { | ||
| 227 | const queryId = this.startQuery(query); | ||
| 228 | const page = []; | ||
| 229 | let isInPage = cursor === null; | ||
| 230 | let isDone = false; | ||
| 231 | let continueCursor = null; | ||
| 232 | for (;;) { | ||
| 233 | const { value, done } = this.queryNext(queryId); | ||
| 234 | if (done) { | ||
| 235 | isDone = true; | ||
| 236 | // We have reached the end of the query. Return a cursor that indicates | ||
| 237 | // "end query", which we can do with any string that isn't a valid _id. | ||
| 238 | continueCursor = "_end_cursor"; | ||
| 239 | break; | ||
| 240 | } | ||
| 241 | if (isInPage) { | ||
| 242 | page.push(value); | ||
| 243 | if (page.length >= pageSize) { | ||
| 244 | continueCursor = value._id; | ||
| 245 | break; | ||
| 246 | } | ||
| 247 | } | ||
| 248 | if (value._id === cursor) { | ||
| 249 | isInPage = true; | ||
| 250 | } | ||
| 251 | } | ||
| 252 | return { | ||
| 253 | page, | ||
| 254 | isDone, | ||
| 255 | continueCursor, | ||
| 256 | }; | ||
| 257 | } | ||
| 258 | _iterateDocs(tableName, callback) { | ||
| 259 | const isInTable = (id) => tableNameFromId(id) === tableName; | ||
| 260 | const ids = new Set(Object.keys(this._documents).filter(isInTable)); | ||
| 261 | for (let i = 0; i < this._writes.length; i++) { | ||
| 262 | for (const id of Object.keys(this._writes[i]).filter(isInTable)) { | ||
| 263 | ids.add(id); | ||
| 264 | } | ||
| 265 | } | ||
| 266 | for (const id of ids) { | ||
| 267 | const document = this.get(tableName, id); | ||
| 268 | if (document !== null) { | ||
| 269 | callback(document); | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | _evaluateQuery(query) { | ||
| 274 | const source = query.source; | ||
| 275 | let results = []; | ||
| 276 | let fieldPathsToSortBy; | ||
| 277 | let order; | ||
| 278 | switch (source.type) { | ||
| 279 | case "FullTableScan": { | ||
| 280 | const tableName = source.tableName; | ||
| 281 | this._iterateDocs(tableName, (doc) => { | ||
| 282 | results.push(doc); | ||
| 283 | }); | ||
| 284 | order = source.order ?? "asc"; | ||
| 285 | fieldPathsToSortBy = ["_creationTime"]; | ||
| 286 | break; | ||
| 287 | } | ||
| 288 | case "IndexRange": { | ||
| 289 | const [tableName, indexName] = source.indexName.split("."); | ||
| 290 | order = source.order ?? "asc"; | ||
| 291 | let fields; | ||
| 292 | if (indexName === "by_creation_time") { | ||
| 293 | fields = ["_creationTime", "_id"]; | ||
| 294 | } | ||
| 295 | else if (indexName === "by_id") { | ||
| 296 | fields = ["_id"]; | ||
| 297 | } | ||
| 298 | else { | ||
| 299 | const indexes = this._schema?.tables.get(tableName)?.indexes; | ||
| 300 | const index = indexes?.find(({ indexDescriptor }) => indexDescriptor === indexName); | ||
| 301 | if (index === undefined) { | ||
| 302 | throw new Error(`Cannot use index "${indexName}" for table "${tableName}" because ` + | ||
| 303 | `it is not declared in the schema.`); | ||
| 304 | } | ||
| 305 | fields = index.fields.concat(["_creationTime", "_id"]); | ||
| 306 | } | ||
| 307 | fieldPathsToSortBy = fields; | ||
| 308 | validateIndexRangeExpression(source, fields); | ||
| 309 | this._iterateDocs(tableName, (doc) => { | ||
| 310 | if (source.range.every((filter) => evaluateRangeFilter(doc, filter))) { | ||
| 311 | results.push(doc); | ||
| 312 | } | ||
| 313 | }); | ||
| 314 | break; | ||
| 315 | } | ||
| 316 | case "Search": { | ||
| 317 | const [tableName] = source.indexName.split("."); | ||
| 318 | this._iterateDocs(tableName, (doc) => { | ||
| 319 | if (source.filters.every((filter) => evaluateSearchFilter(doc, filter))) { | ||
| 320 | results.push(doc); | ||
| 321 | } | ||
| 322 | }); | ||
| 323 | fieldPathsToSortBy = []; | ||
| 324 | order = "asc"; | ||
| 325 | break; | ||
| 326 | } | ||
| 327 | } | ||
| 328 | const filters = query.operators | ||
| 329 | .filter((operator) => "filter" in operator) | ||
| 330 | .map((operator) => operator.filter); | ||
| 331 | const limit = query.operators.filter((operator) => "limit" in operator)[0] ?? null; | ||
| 332 | results = results.filter((v) => filters.every((f) => evaluateFilter(v, f))); | ||
| 333 | results.sort((a, b) => { | ||
| 334 | const orderMultiplier = order === "asc" ? 1 : -1; | ||
| 335 | let v = 0; | ||
| 336 | for (const fp of fieldPathsToSortBy) { | ||
| 337 | v = compareValues(evaluateFieldPath(fp, a), evaluateFieldPath(fp, b)); | ||
| 338 | if (v !== 0) { | ||
| 339 | return v * orderMultiplier; | ||
| 340 | } | ||
| 341 | } | ||
| 342 | return v * orderMultiplier; | ||
| 343 | }); | ||
| 344 | if (limit !== null) { | ||
| 345 | return results.slice(0, limit.limit); | ||
| 346 | } | ||
| 347 | return results; | ||
| 348 | } | ||
| 349 | jobFinished(jobId) { | ||
| 350 | this.jobListener(jobId); | ||
| 351 | } | ||
| 352 | vectorSearch(tableAndIndexName, vector, expressions, limit) { | ||
| 353 | const results = []; | ||
| 354 | const [tableName, indexName] = tableAndIndexName.split("."); | ||
| 355 | this._iterateDocs(tableName, (doc) => { | ||
| 356 | if (expressions.every((filter) => evaluateFilter(doc, filter))) { | ||
| 357 | results.push(doc); | ||
| 358 | } | ||
| 359 | }); | ||
| 360 | const vectorIndexes = this._schema?.tables.get(tableName)?.vectorIndexes; | ||
| 361 | const vectorIndex = vectorIndexes?.find(({ indexDescriptor }) => indexDescriptor === indexName); | ||
| 362 | if (vectorIndex === undefined) { | ||
| 363 | throw new Error(`Cannot use vector index "${indexName}" for table "${tableName}" because ` + | ||
| 364 | `it is not declared in the schema.`); | ||
| 365 | } | ||
| 366 | const { vectorField } = vectorIndex; | ||
| 367 | const idsAndScores = results.map((doc) => { | ||
| 368 | const score = cosineSimilarity(vector, doc[vectorField]); | ||
| 369 | return { _id: doc._id, _score: score }; | ||
| 370 | }); | ||
| 371 | idsAndScores.sort((a, b) => b._score - a._score); | ||
| 372 | return idsAndScores.slice(0, limit); | ||
| 373 | } | ||
| 374 | validateSchema() { | ||
| 375 | this._schema?.tables.forEach((table, tableName) => { | ||
| 376 | if (!isValidIdentifier(tableName)) { | ||
| 377 | throw new Error(`Table names must be valid identifiers, got "${tableName}"`); | ||
| 378 | } | ||
| 379 | validateFieldNames(table.documentType); | ||
| 380 | table.indexes.forEach(({ indexDescriptor }) => { | ||
| 381 | if (!isValidIdentifier(indexDescriptor)) { | ||
| 382 | throw new Error(`Index names must be valid identifiers, got "${indexDescriptor}"`); | ||
| 383 | } | ||
| 384 | }); | ||
| 385 | }); | ||
| 386 | } | ||
| 387 | get componentPath() { | ||
| 388 | return this._componentPath; | ||
| 389 | } | ||
| 390 | } | ||
| 391 | function tableNameFromId(id) { | ||
| 392 | const parts = id.split(";"); | ||
| 393 | if (parts.length !== 2) { | ||
| 394 | return null; | ||
| 395 | } | ||
| 396 | return id.split(";")[1]; | ||
| 397 | } | ||
| 398 | function isSimpleObject(value) { | ||
| 399 | const isObject = typeof value === "object"; | ||
| 400 | const prototype = Object.getPrototypeOf(value); | ||
| 401 | const isSimple = prototype === null || | ||
| 402 | prototype === Object.prototype || | ||
| 403 | // Objects generated from other contexts (e.g. across Node.js `vm` modules) will not satisfy the previous | ||
| 404 | // conditions but are still simple objects. | ||
| 405 | prototype?.constructor?.name === "Object"; | ||
| 406 | return isObject && isSimple; | ||
| 407 | } | ||
| 408 | function evaluateFieldPath(fieldPath, document) { | ||
| 409 | const pathParts = fieldPath.split("."); | ||
| 410 | let result = document; | ||
| 411 | for (const p of pathParts) { | ||
| 412 | result = | ||
| 413 | result !== undefined && result !== null && isSimpleObject(result) | ||
| 414 | ? result[p] | ||
| 415 | : undefined; | ||
| 416 | } | ||
| 417 | return result; | ||
| 418 | } | ||
| 419 | function evaluateFilter(document, filter) { | ||
| 420 | if (filter.$eq !== undefined) { | ||
| 421 | return (compareValues(evaluateFilter(document, filter.$eq[0]), evaluateFilter(document, filter.$eq[1])) === 0); | ||
| 422 | } | ||
| 423 | if (filter.$neq !== undefined) { | ||
| 424 | return (compareValues(evaluateFilter(document, filter.$neq[0]), evaluateFilter(document, filter.$neq[1])) !== 0); | ||
| 425 | } | ||
| 426 | if (filter.$and !== undefined) { | ||
| 427 | return filter.$and.every((child) => evaluateFilter(document, child)); | ||
| 428 | } | ||
| 429 | if (filter.$or !== undefined) { | ||
| 430 | return filter.$or.some((child) => evaluateFilter(document, child)); | ||
| 431 | } | ||
| 432 | if (filter.$not !== undefined) { | ||
| 433 | return !evaluateFilter(document, filter.$not); | ||
| 434 | } | ||
| 435 | if (filter.$gt !== undefined) { | ||
| 436 | return (evaluateFilter(document, filter.$gt[0]) > | ||
| 437 | evaluateFilter(document, filter.$gt[1])); | ||
| 438 | } | ||
| 439 | if (filter.$gte !== undefined) { | ||
| 440 | return (evaluateFilter(document, filter.$gte[0]) >= | ||
| 441 | evaluateFilter(document, filter.$gte[1])); | ||
| 442 | } | ||
| 443 | if (filter.$lt !== undefined) { | ||
| 444 | return (evaluateFilter(document, filter.$lt[0]) < | ||
| 445 | evaluateFilter(document, filter.$lt[1])); | ||
| 446 | } | ||
| 447 | if (filter.$lte !== undefined) { | ||
| 448 | return (evaluateFilter(document, filter.$lte[0]) <= | ||
| 449 | evaluateFilter(document, filter.$lte[1])); | ||
| 450 | } | ||
| 451 | if (filter.$add !== undefined) { | ||
| 452 | return (evaluateFilter(document, filter.$add[0]) + | ||
| 453 | evaluateFilter(document, filter.$add[1])); | ||
| 454 | } | ||
| 455 | if (filter.$sub !== undefined) { | ||
| 456 | return (evaluateFilter(document, filter.$sub[0]) - | ||
| 457 | evaluateFilter(document, filter.$sub[1])); | ||
| 458 | } | ||
| 459 | if (filter.$mul !== undefined) { | ||
| 460 | return (evaluateFilter(document, filter.$mul[0]) * | ||
| 461 | evaluateFilter(document, filter.$mul[1])); | ||
| 462 | } | ||
| 463 | if (filter.$div !== undefined) { | ||
| 464 | return (evaluateFilter(document, filter.$div[0]) / | ||
| 465 | evaluateFilter(document, filter.$div[1])); | ||
| 466 | } | ||
| 467 | if (filter.$mod !== undefined) { | ||
| 468 | return (evaluateFilter(document, filter.$mod[0]) % | ||
| 469 | evaluateFilter(document, filter.$mod[1])); | ||
| 470 | } | ||
| 471 | if (filter.$field !== undefined) { | ||
| 472 | return evaluateFieldPath(filter.$field, document); | ||
| 473 | } | ||
| 474 | if (filter.$literal !== undefined) { | ||
| 475 | return evaluateValue(filter.$literal); | ||
| 476 | } | ||
| 477 | throw new Error(`not implemented: ${JSON.stringify(filter)}`); | ||
| 478 | } | ||
| 479 | function evaluateRangeFilter(document, expr) { | ||
| 480 | const result = evaluateFieldPath(expr.fieldPath, document); | ||
| 481 | const value = evaluateValue(expr.value); | ||
| 482 | switch (expr.type) { | ||
| 483 | case "Eq": | ||
| 484 | return compareValues(result, value) === 0; | ||
| 485 | case "Gt": | ||
| 486 | return compareValues(result, value) > 0; | ||
| 487 | case "Gte": | ||
| 488 | return compareValues(result, value) >= 0; | ||
| 489 | case "Lt": | ||
| 490 | return compareValues(result, value) < 0; | ||
| 491 | case "Lte": | ||
| 492 | return compareValues(result, value) <= 0; | ||
| 493 | } | ||
| 494 | } | ||
| 495 | function evaluateValue(value) { | ||
| 496 | if (typeof value === "object" && value !== null && "$undefined" in value) { | ||
| 497 | return undefined; | ||
| 498 | } | ||
| 499 | return jsonToConvex(value); | ||
| 500 | } | ||
| 501 | function evaluateSearchFilter(document, filter) { | ||
| 502 | const result = evaluateFieldPath(filter.fieldPath, document); | ||
| 503 | switch (filter.type) { | ||
| 504 | case "Eq": | ||
| 505 | return compareValues(result, evaluateValue(filter.value)) === 0; | ||
| 506 | case "Search": { | ||
| 507 | const queryTerms = filter.value | ||
| 508 | .toLowerCase() | ||
| 509 | .split(/\s+/) | ||
| 510 | .filter((term) => term.length > 0); | ||
| 511 | const documentWords = result | ||
| 512 | .split(/\s+/) | ||
| 513 | .map((word) => word.toLowerCase()); | ||
| 514 | return queryTerms.some((queryTerm) => documentWords.some((word) => word.startsWith(queryTerm))); | ||
| 515 | } | ||
| 516 | } | ||
| 517 | } | ||
| 518 | function cosineSimilarity(vecA, vecB) { | ||
| 519 | let dotProduct = 0.0; | ||
| 520 | let normA = 0.0; | ||
| 521 | let normB = 0.0; | ||
| 522 | for (let i = 0; i < vecA.length; i++) { | ||
| 523 | dotProduct += vecA[i] * vecB[i]; | ||
| 524 | normA += vecA[i] * vecA[i]; | ||
| 525 | normB += vecB[i] * vecB[i]; | ||
| 526 | } | ||
| 527 | if (normA === 0 || normB === 0) { | ||
| 528 | return 0; | ||
| 529 | } | ||
| 530 | else { | ||
| 531 | return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); | ||
| 532 | } | ||
| 533 | } | ||
| 534 | // An index range expression is always a chained list of: | ||
| 535 | // | ||
| 536 | // 0 or more equality expressions defined with .eq. | ||
| 537 | // [Optionally] A lower bound expression defined with .gt or .gte. | ||
| 538 | // [Optionally] An upper bound expression defined with .lt or .lte. | ||
| 539 | function validateIndexRangeExpression(source, fields) { | ||
| 540 | let fieldIndex = 0; | ||
| 541 | let state = "eq"; | ||
| 542 | for (const [filterIndex, filter] of source.range.entries()) { | ||
| 543 | if (state === "done") { | ||
| 544 | throw new Error(`Incorrect operator used in \`withIndex\`, cannot chain ` + | ||
| 545 | `more operators after both \`.gt\` and \`.lt\` were already used, ` + | ||
| 546 | `got \`${printIndexOperator(filter)}\`.`); | ||
| 547 | } | ||
| 548 | const filterType = filter.type == "Gt" || filter.type == "Gte" | ||
| 549 | ? "gt" | ||
| 550 | : filter.type == "Lt" || filter.type == "Lte" | ||
| 551 | ? "lt" | ||
| 552 | : "eq"; | ||
| 553 | switch (`${state}|${filterType}`) { | ||
| 554 | // Allow to operate on the current indexed field | ||
| 555 | case "eq|eq": | ||
| 556 | case "eq|gt": | ||
| 557 | case "eq|lt": | ||
| 558 | if (filter.fieldPath === fields[fieldIndex]) { | ||
| 559 | fieldIndex += 1; | ||
| 560 | state = filterType; | ||
| 561 | continue; | ||
| 562 | } | ||
| 563 | throw new Error(`Incorrect field used in \`withIndex\`, ` + | ||
| 564 | `expected "${fields[fieldIndex]}", got "${filter.fieldPath}"`); | ||
| 565 | // Allow to operate on the previous field (gt and lt must operate on same field) | ||
| 566 | case "lt|gt": | ||
| 567 | case "gt|lt": | ||
| 568 | if (fieldIndex > 0 && filter.fieldPath === fields[fieldIndex - 1]) { | ||
| 569 | state = "done"; | ||
| 570 | continue; | ||
| 571 | } | ||
| 572 | throw new Error(`Incorrect field used in \`withIndex\`, ` + | ||
| 573 | `\`.gt\` and \`.lt\` must operate on the same field, ` + | ||
| 574 | `expected "${fields[fieldIndex - 1]}", got "${filter.fieldPath}"`); | ||
| 575 | default: | ||
| 576 | throw new Error(`Incorrect operator used in \`withIndex\`, ` + | ||
| 577 | `cannot chain \`.${filter.type.toLowerCase()}()\` ` + | ||
| 578 | `after \`.${source.range[filterIndex - 1].type.toLowerCase()}()\``); | ||
| 579 | } | ||
| 580 | } | ||
| 581 | } | ||
| 582 | function printIndexOperator(filter) { | ||
| 583 | return `.${filter.type.toLowerCase()}(${filter.fieldPath}, ${JSON.stringify(filter.value)})`; | ||
| 584 | } | ||
| 585 | function validateFieldNames(validator) { | ||
| 586 | if (validator.type === "object") { | ||
| 587 | Object.keys(validator.value).forEach((fieldName) => { | ||
| 588 | if (!isValidIdentifier(fieldName)) { | ||
| 589 | throw new Error(`Field names must be valid identifiers, got "${fieldName}"`); | ||
| 590 | } | ||
| 591 | }); | ||
| 592 | } | ||
| 593 | if (validator.type === "union") { | ||
| 594 | validator.value.forEach(validateFieldNames); | ||
| 595 | } | ||
| 596 | } | ||
| 597 | function isValidIdentifier(name) { | ||
| 598 | return /(^_(id|creationTime)$)|^[a-zA-Z][a-zA-Z0-9_]*$/.test(name); | ||
| 599 | } | ||
| 600 | function validateValidator(validator, value) { | ||
| 601 | switch (validator.type) { | ||
| 602 | case "null": { | ||
| 603 | if (value !== null) { | ||
| 604 | throw new Error(`Validator error: Expected \`null\`, got \`${value}\``); | ||
| 605 | } | ||
| 606 | return; | ||
| 607 | } | ||
| 608 | case "number": { | ||
| 609 | if (typeof value !== "number") { | ||
| 610 | throw new Error(`Validator error: Expected \`number\`, got \`${value}\``); | ||
| 611 | } | ||
| 612 | return; | ||
| 613 | } | ||
| 614 | case "bigint": { | ||
| 615 | if (typeof value !== "bigint") { | ||
| 616 | throw new Error(`Validator error: Expected \`bigint\`, got \`${value}\``); | ||
| 617 | } | ||
| 618 | return; | ||
| 619 | } | ||
| 620 | case "boolean": { | ||
| 621 | if (typeof value !== "boolean") { | ||
| 622 | throw new Error(`Validator error: Expected \`boolean\`, got \`${value}\``); | ||
| 623 | } | ||
| 624 | return; | ||
| 625 | } | ||
| 626 | case "string": { | ||
| 627 | if (typeof value !== "string") { | ||
| 628 | throw new Error(`Validator error: Expected \`string\`, got \`${value}\``); | ||
| 629 | } | ||
| 630 | return; | ||
| 631 | } | ||
| 632 | case "bytes": { | ||
| 633 | if (!(value instanceof ArrayBuffer)) { | ||
| 634 | throw new Error(`Validator error: Expected \`ArrayBuffer\`, got \`${value}\``); | ||
| 635 | } | ||
| 636 | return; | ||
| 637 | } | ||
| 638 | case "any": { | ||
| 639 | return; | ||
| 640 | } | ||
| 641 | case "literal": { | ||
| 642 | if (value !== validator.value) { | ||
| 643 | throw new Error(`Validator error: Expected \`${validator.value}\`, got \`${value}\``); | ||
| 644 | } | ||
| 645 | return; | ||
| 646 | } | ||
| 647 | case "id": { | ||
| 648 | if (typeof value !== "string") { | ||
| 649 | throw new Error(`Validator error: Expected \`string\`, got \`${value}\``); | ||
| 650 | } | ||
| 651 | if (tableNameFromId(value) !== validator.tableName) { | ||
| 652 | throw new Error(`Validator error: Expected ID for table "${validator.tableName}", got \`${value}\``); | ||
| 653 | } | ||
| 654 | return; | ||
| 655 | } | ||
| 656 | case "array": { | ||
| 657 | if (!Array.isArray(value)) { | ||
| 658 | throw new Error(`Validator error: Expected \`Array\`, got \`${value}\``); | ||
| 659 | } | ||
| 660 | for (const v of value) { | ||
| 661 | validateValidator(validator.value, v); | ||
| 662 | } | ||
| 663 | return; | ||
| 664 | } | ||
| 665 | case "union": { | ||
| 666 | let isValid = false; | ||
| 667 | for (const v of validator.value) { | ||
| 668 | try { | ||
| 669 | validateValidator(v, value); | ||
| 670 | isValid = true; | ||
| 671 | break; | ||
| 672 | } | ||
| 673 | catch { | ||
| 674 | // Ignore | ||
| 675 | } | ||
| 676 | } | ||
| 677 | if (!isValid) { | ||
| 678 | throw new Error(`Validator error: Expected one of ${validator.value.map((v) => v.type).join(", ")}, got \`${JSON.stringify(convexToJson(value))}\``); | ||
| 679 | } | ||
| 680 | return; | ||
| 681 | } | ||
| 682 | case "object": { | ||
| 683 | if (typeof value !== "object") { | ||
| 684 | throw new Error(`Validator error: Expected \`object\`, got \`${value}\``); | ||
| 685 | } | ||
| 686 | if (!isSimpleObject(value)) { | ||
| 687 | throw new Error(`Validator error: Expected a plain old JavaScript \`object\`, got \`${value}\``); | ||
| 688 | } | ||
| 689 | for (const [k, { fieldType, optional }] of Object.entries(validator.value)) { | ||
| 690 | if (value[k] === undefined) { | ||
| 691 | if (!optional) { | ||
| 692 | throw new Error(`Validator error: Missing required field \`${k}\` in object`); | ||
| 693 | } | ||
| 694 | } | ||
| 695 | else { | ||
| 696 | validateValidator(fieldType, value[k]); | ||
| 697 | } | ||
| 698 | } | ||
| 699 | for (const k of Object.keys(value)) { | ||
| 700 | if (validator.value[k] === undefined) { | ||
| 701 | throw new Error(`Validator error: Unexpected field \`${k}\` in object`); | ||
| 702 | } | ||
| 703 | } | ||
| 704 | return; | ||
| 705 | } | ||
| 706 | } | ||
| 707 | } | ||
| 708 | function syscallImpl() { | ||
| 709 | return (op, jsonArgs) => { | ||
| 710 | const args = JSON.parse(jsonArgs); | ||
| 711 | const db = getDb(); | ||
| 712 | switch (op) { | ||
| 713 | case "1.0/queryStream": { | ||
| 714 | const { query } = args; | ||
| 715 | const queryId = db.startQuery(query); | ||
| 716 | return JSON.stringify({ queryId }); | ||
| 717 | } | ||
| 718 | case "1.0/queryCleanup": { | ||
| 719 | return JSON.stringify({}); | ||
| 720 | } | ||
| 721 | case "1.0/db/normalizeId": { | ||
| 722 | const idString = args.idString; | ||
| 723 | const isInTable = idString.endsWith(`;${args.table}`); | ||
| 724 | return JSON.stringify({ | ||
| 725 | id: isInTable ? idString : null, | ||
| 726 | }); | ||
| 727 | } | ||
| 728 | default: { | ||
| 729 | throw new Error(`\`convexTest\` does not support syscall: "${op}"`); | ||
| 730 | } | ||
| 731 | } | ||
| 732 | }; | ||
| 733 | } | ||
| 734 | class AuthFake { | ||
| 735 | _userIdentity; | ||
| 736 | constructor(_userIdentity = null) { | ||
| 737 | this._userIdentity = _userIdentity; | ||
| 738 | } | ||
| 739 | getUserIdentity() { | ||
| 740 | return Promise.resolve(this._userIdentity); | ||
| 741 | } | ||
| 742 | } | ||
| 743 | function asyncSyscallImpl() { | ||
| 744 | return async (op, jsonArgs) => { | ||
| 745 | const args = JSON.parse(jsonArgs); | ||
| 746 | const db = getDb(); | ||
| 747 | switch (op) { | ||
| 748 | case "1.0/get": { | ||
| 749 | const { table, id } = args; | ||
| 750 | const doc = db.get(table, id); | ||
| 751 | return JSON.stringify(convexToJson(doc)); | ||
| 752 | } | ||
| 753 | case "1.0/queryStreamNext": { | ||
| 754 | const { value, done } = db.queryNext(args.queryId); | ||
| 755 | return JSON.stringify(convexToJson({ value, done })); | ||
| 756 | } | ||
| 757 | case "1.0/queryPage": { | ||
| 758 | const { query, cursor, pageSize } = args; | ||
| 759 | const { page, isDone, continueCursor } = db.paginate({ | ||
| 760 | query, | ||
| 761 | cursor, | ||
| 762 | pageSize, | ||
| 763 | }); | ||
| 764 | return JSON.stringify(convexToJson({ page, isDone, continueCursor })); | ||
| 765 | } | ||
| 766 | case "1.0/insert": { | ||
| 767 | const _id = db.insert(args.table, jsonToConvex(args.value)); | ||
| 768 | return JSON.stringify({ _id }); | ||
| 769 | } | ||
| 770 | case "1.0/shallowMerge": { | ||
| 771 | const { table, id, value } = args; | ||
| 772 | db.patch(table, id, value); | ||
| 773 | return JSON.stringify({}); | ||
| 774 | } | ||
| 775 | case "1.0/replace": { | ||
| 776 | const { table, id, value } = args; | ||
| 777 | db.replace(table, id, value); | ||
| 778 | return JSON.stringify({}); | ||
| 779 | } | ||
| 780 | case "1.0/remove": { | ||
| 781 | const { table, id } = args; | ||
| 782 | db.delete(table, id); | ||
| 783 | return JSON.stringify({}); | ||
| 784 | } | ||
| 785 | case "1.0/actions/query": { | ||
| 786 | const { name, args: queryArgs } = args; | ||
| 787 | return JSON.stringify(convexToJson(await withAuth().query(makeFunctionReference(name), queryArgs))); | ||
| 788 | } | ||
| 789 | case "1.0/actions/mutation": { | ||
| 790 | const { name, args: mutationArgs } = args; | ||
| 791 | return JSON.stringify(convexToJson(await withAuth().mutation(makeFunctionReference(name), mutationArgs))); | ||
| 792 | } | ||
| 793 | case "1.0/actions/action": { | ||
| 794 | const { name, args: actionArgs } = args; | ||
| 795 | return JSON.stringify(convexToJson(await withAuth().action(makeFunctionReference(name), actionArgs))); | ||
| 796 | } | ||
| 797 | case "1.0/runUdf": { | ||
| 798 | const { udfType, name, reference, functionHandle, args: udfArgsJson, } = args; | ||
| 799 | const udfArgs = jsonToConvex(udfArgsJson); | ||
| 800 | const functionPath = await getFunctionPathFromAddress({ | ||
| 801 | name, | ||
| 802 | reference, | ||
| 803 | functionHandle, | ||
| 804 | }); | ||
| 805 | if (udfType === "query") { | ||
| 806 | return JSON.stringify(convexToJson(await withAuth().queryFromPath(functionPath, | ||
| 807 | /* isNested */ true, udfArgs))); | ||
| 808 | } | ||
| 809 | if (udfType === "mutation") { | ||
| 810 | return JSON.stringify(convexToJson(await withAuth().mutationFromPath(functionPath, | ||
| 811 | /* isNested */ true, udfArgs))); | ||
| 812 | } | ||
| 813 | throw new Error(`\`convexTest\` does not support udf type: "${udfType}"`); | ||
| 814 | } | ||
| 815 | case "1.0/createFunctionHandle": { | ||
| 816 | const { name, reference, functionHandle } = args; | ||
| 817 | const functionPath = await getFunctionPathFromAddress({ | ||
| 818 | name, | ||
| 819 | reference, | ||
| 820 | functionHandle, | ||
| 821 | }); | ||
| 822 | const handle = createFunctionHandle(functionPath); | ||
| 823 | return JSON.stringify(handle); | ||
| 824 | } | ||
| 825 | case "1.0/actions/schedule": { | ||
| 826 | return await withAuth().run(async () => { | ||
| 827 | return await getSyscalls().asyncSyscall("1.0/schedule", jsonArgs); | ||
| 828 | }); | ||
| 829 | } | ||
| 830 | case "1.0/schedule": { | ||
| 831 | const { name, reference, functionHandle, args: fnArgs, ts: tsInSecs, } = args; | ||
| 832 | const functionPath = await getFunctionPathFromAddress({ | ||
| 833 | name, | ||
| 834 | reference, | ||
| 835 | functionHandle, | ||
| 836 | }); | ||
| 837 | // Convert args to a Convex value before storing them or passing them to the function | ||
| 838 | const parsedArgs = jsonToConvex(fnArgs); | ||
| 839 | const jobId = db.insert("_scheduled_functions", { | ||
| 840 | args: [parsedArgs], | ||
| 841 | name: functionPath.udfPath, | ||
| 842 | scheduledTime: tsInSecs * 1000, | ||
| 843 | state: { kind: "pending" }, | ||
| 844 | }); | ||
| 845 | const componentPath = getCurrentComponentPath(); | ||
| 846 | setTimeout((async () => { | ||
| 847 | const canceled = await withAuth().runInComponent(componentPath, async () => { | ||
| 848 | const job = db.get("_scheduled_functions", jobId); | ||
| 849 | if (job === null) { | ||
| 850 | return true; | ||
| 851 | } | ||
| 852 | if (job.state.kind === "canceled") { | ||
| 853 | return true; | ||
| 854 | } | ||
| 855 | if (job.state.kind !== "pending") { | ||
| 856 | throw new Error(`\`convexTest\` invariant error: Unexpected scheduled function state when starting it: ${job.state.kind}`); | ||
| 857 | } | ||
| 858 | await writeToDatabase(async (txDb) => { | ||
| 859 | const latestJob = txDb.get("_scheduled_functions", jobId); | ||
| 860 | if (latestJob !== null) { | ||
| 861 | txDb.patch("_scheduled_functions", jobId, { | ||
| 862 | state: { kind: "inProgress" }, | ||
| 863 | }); | ||
| 864 | } | ||
| 865 | }); | ||
| 866 | return false; | ||
| 867 | }); | ||
| 868 | if (canceled) { | ||
| 869 | return; | ||
| 870 | } | ||
| 871 | try { | ||
| 872 | await withAuth().fun(functionPath, parsedArgs); | ||
| 873 | } | ||
| 874 | catch (error) { | ||
| 875 | console.error(`Error when running scheduled function ${name}`, error); | ||
| 876 | await withAuth().runInComponent(componentPath, async () => { | ||
| 877 | await writeToDatabase(async (txDb) => { | ||
| 878 | const latestJob = txDb.get("_scheduled_functions", jobId); | ||
| 879 | if (latestJob !== null) { | ||
| 880 | txDb.patch("_scheduled_functions", jobId, { | ||
| 881 | state: { kind: "failed" }, | ||
| 882 | completedTime: Date.now(), | ||
| 883 | }); | ||
| 884 | } | ||
| 885 | }); | ||
| 886 | }); | ||
| 887 | db.jobFinished(jobId); | ||
| 888 | return; | ||
| 889 | } | ||
| 890 | await withAuth().runInComponent(componentPath, async () => { | ||
| 891 | const job = db.get("_scheduled_functions", jobId); | ||
| 892 | if (job === null) { | ||
| 893 | return; | ||
| 894 | } | ||
| 895 | if (job.state.kind !== "inProgress") { | ||
| 896 | throw new Error(`\`convexTest\` invariant error: Unexpected scheduled function state after it finished running: ${job.state.kind}`); | ||
| 897 | } | ||
| 898 | await writeToDatabase(async (txDb) => { | ||
| 899 | const latestJob = txDb.get("_scheduled_functions", jobId); | ||
| 900 | if (latestJob !== null) { | ||
| 901 | txDb.patch("_scheduled_functions", jobId, { | ||
| 902 | state: { kind: "success" }, | ||
| 903 | }); | ||
| 904 | } | ||
| 905 | }); | ||
| 906 | }); | ||
| 907 | db.jobFinished(jobId); | ||
| 908 | }), tsInSecs * 1000 - Date.now()); | ||
| 909 | return JSON.stringify(convexToJson(jobId)); | ||
| 910 | } | ||
| 911 | case "1.0/actions/cancel_job": { | ||
| 912 | await withAuth().run(async () => { | ||
| 913 | await getSyscalls().asyncSyscall("1.0/cancel_job", jsonArgs); | ||
| 914 | }); | ||
| 915 | return JSON.stringify({}); | ||
| 916 | } | ||
| 917 | case "1.0/actions/vectorSearch": { | ||
| 918 | const { query: { indexName, limit, vector, expressions }, } = args; | ||
| 919 | const results = db.vectorSearch(indexName, vector, | ||
| 920 | // Probably an unintentional implementation in Convex | ||
| 921 | // where expressions is only a single expression | ||
| 922 | expressions === null ? [] : [expressions], limit); | ||
| 923 | return JSON.stringify(convexToJson({ results })); | ||
| 924 | } | ||
| 925 | case "1.0/cancel_job": { | ||
| 926 | const { id } = args; | ||
| 927 | await writeToDatabase(async (txDb) => { | ||
| 928 | const job = txDb.get("_scheduled_functions", id); | ||
| 929 | if (job !== null) { | ||
| 930 | txDb.patch("_scheduled_functions", id, { state: { kind: "canceled" } }); | ||
| 931 | } | ||
| 932 | }); | ||
| 933 | return JSON.stringify({}); | ||
| 934 | } | ||
| 935 | case "1.0/storageDelete": { | ||
| 936 | const { storageId } = args; | ||
| 937 | await writeToDatabase(async (db) => { | ||
| 938 | db.delete("_storage", storageId); | ||
| 939 | }); | ||
| 940 | return JSON.stringify({}); | ||
| 941 | } | ||
| 942 | case "1.0/storageGetUrl": { | ||
| 943 | const { storageId } = args; | ||
| 944 | const metadata = db.get("_storage", storageId); | ||
| 945 | if (metadata === null) { | ||
| 946 | return JSON.stringify(null); | ||
| 947 | } | ||
| 948 | const { sha256 } = metadata; | ||
| 949 | // In the real backend the URL ofc isn't the sha | ||
| 950 | const url = "https://some-deployment.convex.cloud/api/storage/" + | ||
| 951 | sha256; | ||
| 952 | return JSON.stringify(convexToJson(url)); | ||
| 953 | } | ||
| 954 | case "1.0/storageGenerateUploadUrl": { | ||
| 955 | // In the real backend the token is cryptographically secure | ||
| 956 | const url = "https://some-deployment.convex.cloud/api/storage/upload?token=" + | ||
| 957 | Math.random(); | ||
| 958 | return JSON.stringify(convexToJson(url)); | ||
| 959 | } | ||
| 960 | case "1.0/count": { | ||
| 961 | const { table } = args; | ||
| 962 | const queryId = db.startQuery({ | ||
| 963 | source: { type: "FullTableScan", tableName: table, order: "asc" }, | ||
| 964 | operators: [], | ||
| 965 | }); | ||
| 966 | let count = 0; | ||
| 967 | while (true) { | ||
| 968 | const result = db.queryNext(queryId); | ||
| 969 | if (result.done) { | ||
| 970 | break; | ||
| 971 | } | ||
| 972 | count += 1; | ||
| 973 | } | ||
| 974 | return JSON.stringify(count); | ||
| 975 | } | ||
| 976 | default: { | ||
| 977 | throw new Error(`\`convexTest\` does not support async syscall: "${op}"`); | ||
| 978 | } | ||
| 979 | } | ||
| 980 | }; | ||
| 981 | } | ||
| 982 | function jsSyscallImpl() { | ||
| 983 | return async (op, args) => { | ||
| 984 | const db = getDb(); | ||
| 985 | switch (op) { | ||
| 986 | case "storage/storeBlob": { | ||
| 987 | const { blob } = args; | ||
| 988 | const storageId = await writeToDatabase(async (db) => { | ||
| 989 | return db.insert("_storage", { | ||
| 990 | size: blob.size, | ||
| 991 | sha256: await blobSha(blob), | ||
| 992 | }); | ||
| 993 | }); | ||
| 994 | db.storeFile(storageId, blob); | ||
| 995 | return storageId; | ||
| 996 | } | ||
| 997 | case "storage/getBlob": { | ||
| 998 | const { storageId } = args; | ||
| 999 | return db.getFile(storageId); | ||
| 1000 | } | ||
| 1001 | default: { | ||
| 1002 | throw new Error(`\`convexTest\` does not support js syscall: "${op}"`); | ||
| 1003 | } | ||
| 1004 | } | ||
| 1005 | }; | ||
| 1006 | } | ||
| 1007 | // If we're in action, wrap the write in a transaction. | ||
| 1008 | async function writeToDatabase(impl) { | ||
| 1009 | const db = getDb(); | ||
| 1010 | if (!getTransactionManager().isInTransaction()) { | ||
| 1011 | return await withAuth().run(async () => { | ||
| 1012 | return await impl(db); | ||
| 1013 | }); | ||
| 1014 | } | ||
| 1015 | else { | ||
| 1016 | return await impl(db); | ||
| 1017 | } | ||
| 1018 | } | ||
| 1019 | async function blobSha(blob) { | ||
| 1020 | const arrayBuffer = await blob.arrayBuffer(); | ||
| 1021 | const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); | ||
| 1022 | const hashArray = new Uint8Array(hashBuffer); | ||
| 1023 | return btoa(String.fromCharCode(...hashArray)); | ||
| 1024 | } | ||
| 1025 | async function waitForInProgressScheduledFunctions() { | ||
| 1026 | let hadScheduledFunctions = false; | ||
| 1027 | for (const componentPath of Object.keys(getConvexGlobal().components)) { | ||
| 1028 | const inProgressJobs = (await withAuth().runInComponent(componentPath, async (ctx) => { | ||
| 1029 | return (await ctx.db.system.query("_scheduled_functions").collect()).filter((job) => job.state.kind === "inProgress"); | ||
| 1030 | })); | ||
| 1031 | let numRemaining = inProgressJobs.length; | ||
| 1032 | if (numRemaining === 0) { | ||
| 1033 | continue; | ||
| 1034 | } | ||
| 1035 | hadScheduledFunctions = true; | ||
| 1036 | await new Promise((resolve) => { | ||
| 1037 | getDbForComponent(componentPath).jobListener = () => { | ||
| 1038 | numRemaining -= 1; | ||
| 1039 | if (numRemaining === 0) { | ||
| 1040 | resolve(); | ||
| 1041 | } | ||
| 1042 | }; | ||
| 1043 | }); | ||
| 1044 | } | ||
| 1045 | return hadScheduledFunctions; | ||
| 1046 | } | ||
| 1047 | function getComponentInfo(componentPath) { | ||
| 1048 | const convex = getConvexGlobal(); | ||
| 1049 | if (convex.components[componentPath] === undefined) { | ||
| 1050 | throw new Error(`Component "${componentPath}" is not registered. Call "t.registerComponent".`); | ||
| 1051 | } | ||
| 1052 | return convex.components[componentPath]; | ||
| 1053 | } | ||
| 1054 | function getDbForComponent(componentPath) { | ||
| 1055 | return getComponentInfo(componentPath).db; | ||
| 1056 | } | ||
| 1057 | function getDb() { | ||
| 1058 | return getDbForComponent(getCurrentComponentPath()); | ||
| 1059 | } | ||
| 1060 | function getTransactionManager() { | ||
| 1061 | return getConvexGlobal().transactionManager; | ||
| 1062 | } | ||
| 1063 | function getCurrentComponentPath() { | ||
| 1064 | const functionStack = getTransactionManager().functionStack; | ||
| 1065 | const currentFunctionPath = functionStack[functionStack.length - 1]; | ||
| 1066 | return currentFunctionPath?.componentPath ?? ROOT_COMPONENT_PATH; | ||
| 1067 | } | ||
| 1068 | function getModules() { | ||
| 1069 | return getModulesForComponent(getCurrentComponentPath()); | ||
| 1070 | } | ||
| 1071 | function getModulesForComponent(componentPath) { | ||
| 1072 | return getComponentInfo(componentPath).modules; | ||
| 1073 | } | ||
| 1074 | function getSyscalls() { | ||
| 1075 | return global.Convex; | ||
| 1076 | } | ||
| 1077 | function moduleCache(specifiedModules) { | ||
| 1078 | const modules = specifiedModules ?? import.meta.glob("../../../convex/**/*.*s"); | ||
| 1079 | const prefix = findModulesRoot(Object.keys(modules), specifiedModules !== undefined); | ||
| 1080 | const modulesWithoutExtension = Object.fromEntries(Object.entries(modules).map(([path, module]) => [ | ||
| 1081 | path.replace(/\.[^.]+$/, ""), | ||
| 1082 | module, | ||
| 1083 | ])); | ||
| 1084 | return async (path) => { | ||
| 1085 | const module = modulesWithoutExtension[prefix + path]; | ||
| 1086 | if (module === undefined) { | ||
| 1087 | throw new Error(`Could not find module for: "${path}"`); | ||
| 1088 | } | ||
| 1089 | return await module(); | ||
| 1090 | }; | ||
| 1091 | } | ||
| 1092 | function findModulesRoot(modulesPaths, userProvidedModules) { | ||
| 1093 | const generatedFilePath = modulesPaths.find((path) => path.includes("_generated")); | ||
| 1094 | if (generatedFilePath !== undefined) { | ||
| 1095 | return generatedFilePath.split("_generated", 2)[0]; | ||
| 1096 | } | ||
| 1097 | throw new Error('Could not find the "_generated" directory, make sure to run ' + | ||
| 1098 | "`npx convex dev` or `npx convex codegen`. " + | ||
| 1099 | (userProvidedModules | ||
| 1100 | ? "Make sure your `import.meta.glob` includes the files in the " + | ||
| 1101 | '"_generated" directory' | ||
| 1102 | : "If your Convex functions aren't defined in a directory " + | ||
| 1103 | 'called "convex" sibling to your node_modules, ' + | ||
| 1104 | "provide the second argument to `convexTest`")); | ||
| 1105 | } | ||
| 1106 | function getConvexGlobal() { | ||
| 1107 | return global.Convex; | ||
| 1108 | } | ||
| 1109 | function setConvexGlobal(convex) { | ||
| 1110 | if (getConvexGlobal()) { | ||
| 1111 | if (getTransactionManager().isInTransaction()) { | ||
| 1112 | throw new Error("test began while previous transaction was still open"); | ||
| 1113 | } | ||
| 1114 | } | ||
| 1115 | global.Convex = convex; | ||
| 1116 | } | ||
| 1117 | class TransactionManager { | ||
| 1118 | // The DatabaseFake is used in the Convex global, | ||
| 1119 | // and so it restricts `convexTest` to run one function | ||
| 1120 | // at a time. | ||
| 1121 | // We force sequential execution to make sure actions | ||
| 1122 | // can run mutations in parallel. | ||
| 1123 | _waitOnCurrentFunction = null; | ||
| 1124 | _markTransactionDone = null; | ||
| 1125 | functionStack = []; | ||
| 1126 | async begin(functionPath, isNested) { | ||
| 1127 | // Take a lock only for the top-level of each transaction. | ||
| 1128 | // Nested transactions are not isolated so if you `Promise.all` on multiple | ||
| 1129 | // `ctx.runMutation` or `ctx.runQuery` calls, they won't be serialized. | ||
| 1130 | if (!isNested) { | ||
| 1131 | // Note the loop is important, as the current promise might resolve and a | ||
| 1132 | // new transaction could start before we get woken up. | ||
| 1133 | // This is the standard pattern for condition variables: | ||
| 1134 | // https://en.wikipedia.org/wiki/Monitor_(synchronization) | ||
| 1135 | while (this._waitOnCurrentFunction !== null) { | ||
| 1136 | await this._waitOnCurrentFunction; | ||
| 1137 | } | ||
| 1138 | this._waitOnCurrentFunction = new Promise((resolve) => { | ||
| 1139 | this._markTransactionDone = resolve; | ||
| 1140 | }); | ||
| 1141 | } | ||
| 1142 | this.functionStack.push(functionPath); | ||
| 1143 | const convex = getConvexGlobal(); | ||
| 1144 | for (const component of Object.values(convex.components)) { | ||
| 1145 | component.db.startTransaction(); | ||
| 1146 | } | ||
| 1147 | } | ||
| 1148 | beginAction(functionPath) { | ||
| 1149 | this.functionStack.push(functionPath); | ||
| 1150 | } | ||
| 1151 | finishAction() { | ||
| 1152 | this.functionStack.pop(); | ||
| 1153 | } | ||
| 1154 | // Used to distinguish between mutation and action execution | ||
| 1155 | // environment. | ||
| 1156 | isInTransaction() { | ||
| 1157 | return this._waitOnCurrentFunction !== null; | ||
| 1158 | } | ||
| 1159 | commit(isNested) { | ||
| 1160 | const convex = getConvexGlobal(); | ||
| 1161 | for (const component of Object.values(convex.components)) { | ||
| 1162 | component.db.commit(); | ||
| 1163 | } | ||
| 1164 | this._endTransaction(isNested); | ||
| 1165 | } | ||
| 1166 | rollback(isNested) { | ||
| 1167 | const convex = getConvexGlobal(); | ||
| 1168 | for (const component of Object.values(convex.components)) { | ||
| 1169 | component.db.rollbackWrites(); | ||
| 1170 | } | ||
| 1171 | this._endTransaction(isNested); | ||
| 1172 | } | ||
| 1173 | _endTransaction(isNested) { | ||
| 1174 | if (this._markTransactionDone === null) { | ||
| 1175 | throw new Error("Transaction not started"); | ||
| 1176 | } | ||
| 1177 | this.functionStack.pop(); | ||
| 1178 | if (!isNested) { | ||
| 1179 | this._waitOnCurrentFunction = null; | ||
| 1180 | this._markTransactionDone(); | ||
| 1181 | this._markTransactionDone = null; | ||
| 1182 | } | ||
| 1183 | } | ||
| 1184 | } | ||
| 1185 | /** | ||
| 1186 | * Call this function at the start of each of your tests. | ||
| 1187 | * | ||
| 1188 | * @param schema The default export from your "schema.ts" file. | ||
| 1189 | * @param modules If you have a custom `functions` path | ||
| 1190 | * in convex.json, provide the module map with your functions | ||
| 1191 | * by calling `import.meta.glob` with the appropriate glob pattern | ||
| 1192 | * for paths relative to the file where you call it. | ||
| 1193 | * @returns an object which is by convention stored in the `t` variable | ||
| 1194 | * and which provides methods for exercising your Convex functions. | ||
| 1195 | */ | ||
| 1196 | export const convexTest = (schema, | ||
| 1197 | // For example `import.meta.glob("./**/*.*s")` | ||
| 1198 | modules) => { | ||
| 1199 | const rootDb = new DatabaseFake(schema ?? null, ROOT_COMPONENT_PATH); | ||
| 1200 | setConvexGlobal({ | ||
| 1201 | components: { | ||
| 1202 | [ROOT_COMPONENT_PATH]: { | ||
| 1203 | db: rootDb, | ||
| 1204 | modules: moduleCache(modules), | ||
| 1205 | }, | ||
| 1206 | }, | ||
| 1207 | transactionManager: new TransactionManager(), | ||
| 1208 | syscall: syscallImpl(), | ||
| 1209 | asyncSyscall: asyncSyscallImpl(), | ||
| 1210 | jsSyscall: jsSyscallImpl(), | ||
| 1211 | }); | ||
| 1212 | return { | ||
| 1213 | withIdentity(identity) { | ||
| 1214 | const subject = identity.subject ?? "" + simpleHash(JSON.stringify(identity)); | ||
| 1215 | const issuer = identity.issuer ?? "https://convex.test"; | ||
| 1216 | const tokenIdentifier = identity.tokenIdentifier ?? `${issuer}|${subject}`; | ||
| 1217 | return withAuth(new AuthFake({ ...identity, subject, issuer, tokenIdentifier })); | ||
| 1218 | }, | ||
| 1219 | ...withAuth(), | ||
| 1220 | registerComponent(componentPath, schema, glob) { | ||
| 1221 | const componentInfo = { | ||
| 1222 | db: new DatabaseFake(schema, componentPath), | ||
| 1223 | modules: moduleCache(glob), | ||
| 1224 | }; | ||
| 1225 | getConvexGlobal().components[componentPath] = componentInfo; | ||
| 1226 | }, | ||
| 1227 | }; | ||
| 1228 | }; | ||
| 1229 | function withAuth(auth = new AuthFake()) { | ||
| 1230 | const runTransaction = async (handler, args, extraCtx = {}, functionPath, isNested) => { | ||
| 1231 | const m = mutationGeneric({ | ||
| 1232 | handler: (ctx, a) => { | ||
| 1233 | const testCtx = { ...ctx, auth, ...extraCtx }; | ||
| 1234 | return handler(testCtx, a); | ||
| 1235 | }, | ||
| 1236 | }); | ||
| 1237 | const transactionManager = getTransactionManager(); | ||
| 1238 | await transactionManager.begin(functionPath, isNested); | ||
| 1239 | try { | ||
| 1240 | const rawResult = await m.invokeMutation(JSON.stringify(convexToJson([parseArgs(args)]))); | ||
| 1241 | transactionManager.commit(isNested); | ||
| 1242 | return jsonToConvex(JSON.parse(rawResult)); | ||
| 1243 | } | ||
| 1244 | catch (e) { | ||
| 1245 | transactionManager.rollback(isNested); | ||
| 1246 | throw e; | ||
| 1247 | } | ||
| 1248 | }; | ||
| 1249 | const byTypeWithPath = { | ||
| 1250 | queryFromPath: async (functionPath, isNested, args) => { | ||
| 1251 | const func = await getFunctionFromPath(functionPath, "query"); | ||
| 1252 | validateValidator(JSON.parse(func.exportArgs()), args ?? {}); | ||
| 1253 | const q = queryGeneric({ | ||
| 1254 | handler: (ctx, a) => { | ||
| 1255 | const testCtx = { ...ctx, auth }; | ||
| 1256 | return getHandler(func)(testCtx, a); | ||
| 1257 | }, | ||
| 1258 | }); | ||
| 1259 | const transactionManager = getTransactionManager(); | ||
| 1260 | await transactionManager.begin(functionPath, isNested); | ||
| 1261 | try { | ||
| 1262 | const rawResult = await q.invokeQuery(JSON.stringify(convexToJson([parseArgs(args)]))); | ||
| 1263 | return jsonToConvex(JSON.parse(rawResult)); | ||
| 1264 | } | ||
| 1265 | finally { | ||
| 1266 | transactionManager.rollback(isNested); | ||
| 1267 | } | ||
| 1268 | }, | ||
| 1269 | mutationFromPath: async (functionPath, isNested, args) => { | ||
| 1270 | const func = await getFunctionFromPath(functionPath, "mutation"); | ||
| 1271 | validateValidator(JSON.parse(func.exportArgs()), args ?? {}); | ||
| 1272 | return await runTransaction(getHandler(func), args, {}, functionPath, isNested); | ||
| 1273 | }, | ||
| 1274 | actionFromPath: async (functionPath, args) => { | ||
| 1275 | const func = await getFunctionFromPath(functionPath, "action"); | ||
| 1276 | validateValidator(JSON.parse(func.exportArgs()), args ?? {}); | ||
| 1277 | const a = actionGeneric({ | ||
| 1278 | handler: (ctx, a) => { | ||
| 1279 | const testCtx = { | ||
| 1280 | ...ctx, | ||
| 1281 | runQuery: byType.query, | ||
| 1282 | runMutation: byType.mutation, | ||
| 1283 | runAction: byType.action, | ||
| 1284 | auth, | ||
| 1285 | }; | ||
| 1286 | return getHandler(func)(testCtx, a); | ||
| 1287 | }, | ||
| 1288 | }); | ||
| 1289 | getTransactionManager().beginAction(functionPath); | ||
| 1290 | // Real backend uses different ID format | ||
| 1291 | const requestId = "" + Math.random(); | ||
| 1292 | const rawResult = await a.invokeAction(requestId, JSON.stringify(convexToJson([parseArgs(args)]))); | ||
| 1293 | getTransactionManager().finishAction(); | ||
| 1294 | return jsonToConvex(JSON.parse(rawResult)); | ||
| 1295 | }, | ||
| 1296 | }; | ||
| 1297 | const byType = { | ||
| 1298 | query: async (functionReference, args) => { | ||
| 1299 | const functionPath = await getFunctionPathFromReference(functionReference); | ||
| 1300 | return await byTypeWithPath.queryFromPath(functionPath, | ||
| 1301 | /* isNested */ false, args); | ||
| 1302 | }, | ||
| 1303 | mutation: async (functionReference, args) => { | ||
| 1304 | const functionPath = await getFunctionPathFromReference(functionReference); | ||
| 1305 | return await byTypeWithPath.mutationFromPath(functionPath, | ||
| 1306 | /* isNested */ false, args); | ||
| 1307 | }, | ||
| 1308 | action: async (functionReference, args) => { | ||
| 1309 | const functionPath = await getFunctionPathFromReference(functionReference); | ||
| 1310 | return await byTypeWithPath.actionFromPath(functionPath, args); | ||
| 1311 | }, | ||
| 1312 | }; | ||
| 1313 | const run = async (componentPath, handler) => { | ||
| 1314 | // Grab StorageActionWriter from action ctx | ||
| 1315 | const a = actionGeneric({ | ||
| 1316 | handler: async (ctx) => { | ||
| 1317 | return await runTransaction(handler, {}, { | ||
| 1318 | storage: ctx.storage, | ||
| 1319 | // ActionCtx support was added in 0.0.40, removed in 0.0.41, should be added back in the future. | ||
| 1320 | // runAction: ctx.runAction.bind(ctx), | ||
| 1321 | // vectorSearch: ctx.vectorSearch.bind(ctx), | ||
| 1322 | }, | ||
| 1323 | // Fake mutation path. | ||
| 1324 | { | ||
| 1325 | componentPath, | ||
| 1326 | udfPath: "custom", | ||
| 1327 | }, false); | ||
| 1328 | }, | ||
| 1329 | }); | ||
| 1330 | // Real backend uses different ID format | ||
| 1331 | const requestId = "" + Math.random(); | ||
| 1332 | const rawResult = await a.invokeAction(requestId, JSON.stringify(convexToJson([{}]))); | ||
| 1333 | return jsonToConvex(JSON.parse(rawResult)); | ||
| 1334 | }; | ||
| 1335 | return { | ||
| 1336 | ...byType, | ||
| 1337 | ...byTypeWithPath, | ||
| 1338 | runInComponent: run, | ||
| 1339 | run: async (handler) => { | ||
| 1340 | return await run(getCurrentComponentPath(), handler); | ||
| 1341 | }, | ||
| 1342 | fun: async (functionPath, args) => { | ||
| 1343 | const func = await getFunctionFromPath(functionPath, "any"); | ||
| 1344 | if (func.isQuery) { | ||
| 1345 | return await byTypeWithPath.queryFromPath(functionPath, | ||
| 1346 | /* isNested */ false, args); | ||
| 1347 | } | ||
| 1348 | if (func.isMutation) { | ||
| 1349 | return await byTypeWithPath.mutationFromPath(functionPath, | ||
| 1350 | /* isNested */ false, args); | ||
| 1351 | } | ||
| 1352 | if (func.isAction) { | ||
| 1353 | return await byTypeWithPath.actionFromPath(functionPath, args); | ||
| 1354 | } | ||
| 1355 | }, | ||
| 1356 | fetch: async (path, init) => { | ||
| 1357 | const router = (await getModules()("http"))["default"]; | ||
| 1358 | if (!path.startsWith("/")) { | ||
| 1359 | throw new Error(`Path given to \`t.fetch\` must start with a \`/\``); | ||
| 1360 | } | ||
| 1361 | const url = new URL(`https://some.convex.site${path}`); | ||
| 1362 | const found = router.lookup(url.pathname, init?.method ?? "GET"); | ||
| 1363 | if (!found) { | ||
| 1364 | return new Response(`No HttpAction routed for ${url.pathname}`, { | ||
| 1365 | status: 404, | ||
| 1366 | }); | ||
| 1367 | } | ||
| 1368 | const [func] = found; | ||
| 1369 | const a = httpActionGeneric((ctx, a) => { | ||
| 1370 | const testCtx = { | ||
| 1371 | ...ctx, | ||
| 1372 | runQuery: byType.query, | ||
| 1373 | runMutation: byType.mutation, | ||
| 1374 | runAction: byType.action, | ||
| 1375 | auth, | ||
| 1376 | }; | ||
| 1377 | return getHandler(func)(testCtx, a); | ||
| 1378 | }); | ||
| 1379 | const response = await a.invokeHttpAction(new Request(url, init)); | ||
| 1380 | return response; | ||
| 1381 | }, | ||
| 1382 | // This is needed because when we execute functions | ||
| 1383 | // we are performing dynamic `import`s, and those | ||
| 1384 | // are real work that cannot be force-awaited. | ||
| 1385 | finishInProgressScheduledFunctions: async () => { | ||
| 1386 | await waitForInProgressScheduledFunctions(); | ||
| 1387 | }, | ||
| 1388 | finishAllScheduledFunctions: async (advanceTimers, maxIterations = 100) => { | ||
| 1389 | // Wait for all scheduled functions to finish, advancing time in between | ||
| 1390 | // each function. | ||
| 1391 | // Stop after a fixed number of iterations to avoid infinite loops. | ||
| 1392 | for (let i = 0; i < maxIterations; i++) { | ||
| 1393 | advanceTimers(); | ||
| 1394 | const hadScheduledFunctions = await waitForInProgressScheduledFunctions(); | ||
| 1395 | if (!hadScheduledFunctions) { | ||
| 1396 | return; | ||
| 1397 | } | ||
| 1398 | } | ||
| 1399 | throw new Error("finishAllScheduledFunctions: too many iterations. " + | ||
| 1400 | "Check for infinitely recursive scheduled functions, " + | ||
| 1401 | "or increase maxIterations."); | ||
| 1402 | }, | ||
| 1403 | }; | ||
| 1404 | } | ||
| 1405 | function parseArgs(args) { | ||
| 1406 | if (args === undefined) { | ||
| 1407 | return {}; | ||
| 1408 | } | ||
| 1409 | if (!isSimpleObject(args)) { | ||
| 1410 | throw new Error(`The arguments to a Convex function must be an object. Received: ${args}`); | ||
| 1411 | } | ||
| 1412 | return args; | ||
| 1413 | } | ||
| 1414 | function createFunctionHandle(functionPath) { | ||
| 1415 | return `function://${functionPath.componentPath};${functionPath.udfPath}`; | ||
| 1416 | } | ||
| 1417 | function parseFunctionHandle(handle) { | ||
| 1418 | const [componentPath, udfPath] = handle.split("function://")[1].split(";"); | ||
| 1419 | return { componentPath, udfPath }; | ||
| 1420 | } | ||
| 1421 | async function getFunctionPathFromAddress(functionAddress) { | ||
| 1422 | if (functionAddress.functionHandle !== undefined) { | ||
| 1423 | return parseFunctionHandle(functionAddress.functionHandle); | ||
| 1424 | } | ||
| 1425 | if (functionAddress.name !== undefined) { | ||
| 1426 | return { | ||
| 1427 | udfPath: functionAddress.name, | ||
| 1428 | componentPath: getCurrentComponentPath(), | ||
| 1429 | }; | ||
| 1430 | } | ||
| 1431 | if (functionAddress.reference !== undefined) { | ||
| 1432 | // "_reference/childComponent/aggregate/path/to/file/functionName" -> "aggregate/path/to/file" | ||
| 1433 | const childComponentName = functionAddress.reference.split("/")[2]; | ||
| 1434 | let componentPath = childComponentName; | ||
| 1435 | if (getCurrentComponentPath().length > 0) { | ||
| 1436 | componentPath = `${getCurrentComponentPath()}/${componentPath}`; | ||
| 1437 | } | ||
| 1438 | // "_reference/childComponent/aggregate/path/to/file/functionName" -> "path/to/file/functionName" | ||
| 1439 | const functionNameWithSlashes = functionAddress.reference | ||
| 1440 | .split("/") | ||
| 1441 | .slice(3) | ||
| 1442 | .join("/"); | ||
| 1443 | // "path/to/file/functionName" -> "path/to/file:functionName" | ||
| 1444 | const filepath = functionNameWithSlashes.split("/").slice(0, -1).join("/"); | ||
| 1445 | const functionName = functionNameWithSlashes.split("/").pop(); | ||
| 1446 | return { | ||
| 1447 | udfPath: `${filepath}:${functionName}`, | ||
| 1448 | componentPath, | ||
| 1449 | }; | ||
| 1450 | } | ||
| 1451 | throw new Error("Function address not supported"); | ||
| 1452 | } | ||
| 1453 | async function getFunctionPathFromReference(functionReference) { | ||
| 1454 | // { name: "messages:list" } | ||
| 1455 | // { reference: "_reference/childComponent/aggregate/path/to/file/functionName" } | ||
| 1456 | // { functionHandle: "function://<id>/<path>" } | ||
| 1457 | const functionAddress = getFunctionAddress(functionReference); | ||
| 1458 | return await getFunctionPathFromAddress(functionAddress); | ||
| 1459 | } | ||
| 1460 | function getHandler(func) { | ||
| 1461 | return "_handler" in func ? func["_handler"] : func; | ||
| 1462 | } | ||
| 1463 | async function getFunctionFromPath(functionPath, type) { | ||
| 1464 | // "queries/messages:list" -> ["queries/messages", "list"] | ||
| 1465 | const [modulePath, maybeExportName] = functionPath.udfPath.split(":"); | ||
| 1466 | const exportName = maybeExportName === undefined ? "default" : maybeExportName; | ||
| 1467 | const module = await getModulesForComponent(functionPath.componentPath)(modulePath); | ||
| 1468 | const func = module[exportName]; | ||
| 1469 | if (func === undefined) { | ||
| 1470 | throw new Error(`Expected a Convex function exported from module "${modulePath}" as \`${exportName}\`, but there is no such export.`); | ||
| 1471 | } | ||
| 1472 | if (typeof getHandler(func) !== "function") { | ||
| 1473 | throw new Error(`Expected a Convex function exported from module "${modulePath}" as \`${exportName}\`, but got: ${func}`); | ||
| 1474 | } | ||
| 1475 | switch (type) { | ||
| 1476 | case "query": | ||
| 1477 | if (!func.isQuery) { | ||
| 1478 | throw new Error(`Expected a query function, but the function exported from module "${modulePath}" as \`${exportName}\` is not a query.`); | ||
| 1479 | } | ||
| 1480 | break; | ||
| 1481 | case "mutation": | ||
| 1482 | if (!func.isMutation) { | ||
| 1483 | throw new Error(`Expected a mutation function, but the function exported from module "${modulePath}" as \`${exportName}\` is not a mutation.`); | ||
| 1484 | } | ||
| 1485 | break; | ||
| 1486 | case "action": | ||
| 1487 | if (!func.isAction) { | ||
| 1488 | throw new Error(`Expected an action function, but the function exported from module "${modulePath}" as \`${exportName}\` is not an action.`); | ||
| 1489 | } | ||
| 1490 | break; | ||
| 1491 | case "any": | ||
| 1492 | break; | ||
| 1493 | default: | ||
| 1494 | // eslint-disable-next-line @typescript-eslint/only-throw-error | ||
| 1495 | throw type; | ||
| 1496 | } | ||
| 1497 | return func; | ||
| 1498 | } | ||
| 1499 | function simpleHash(string) { | ||
| 1500 | let hash = 0; | ||
| 1501 | for (let i = 0; i < string.length; i++) { | ||
| 1502 | const char = string.charCodeAt(i); | ||
| 1503 | hash = (hash << 5) - hash + char; | ||
| 1504 | hash = hash & hash; // Convert to 32bit integer | ||
| 1505 | } | ||
| 1506 | return hash; | ||
| 1507 | } | ||
| @@ -0,0 +1,21 @@ | |||
| 1 | { | ||
| 2 | "name": "convex-test", | ||
| 3 | "version": "0.0.41-patched", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "main": "./index.js", | ||
| 7 | "types": "./index.d.ts", | ||
| 8 | "exports": { | ||
| 9 | ".": { | ||
| 10 | "types": "./index.d.ts", | ||
| 11 | "default": "./index.js" | ||
| 12 | }, | ||
| 13 | "./compare": { | ||
| 14 | "types": "./compare.d.ts", | ||
| 15 | "default": "./compare.js" | ||
| 16 | } | ||
| 17 | }, | ||
| 18 | "peerDependencies": { | ||
| 19 | "convex": "^1.32.0" | ||
| 20 | } | ||
| 21 | } | ||
| @@ -3,7 +3,18 @@ · 2 unmodified lines | |||
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | ".": "./src/index.ts" | 6 | ".": "./src/index.ts", |
| 7 | "./api": "./src/api.ts", | ||
| 8 | "./approvals/api": "./src/approvals/api.ts", | ||
| 9 | "./auth/principal": "./src/auth/principal.ts", | ||
| 10 | "./client": "./src/client.ts", | ||
| 11 | "./credentials/api": "./src/credentials/api.ts", | ||
| 12 | "./organizations/api": "./src/organizations/api.ts", | ||
| 13 | "./policies/api": "./src/policies/api.ts", | ||
| 14 | "./storage/api": "./src/storage/api.ts", | ||
| 15 | "./sources/api": "./src/sources/api.ts", | ||
| 16 | "./tools/api": "./src/tools/api.ts", | ||
| 17 | "./workspaces/api": "./src/workspaces/api.ts" | ||
| 7 | }, | 18 | }, |
| 8 | "scripts": { | 19 | "scripts": { |
| 9 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" | 20 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| @@ -14,7 +25,8 @@ · 4 unmodified lines | |||
| 14 | "@executor-v2/domain": "workspace:*", | 25 | "@executor-v2/domain": "workspace:*", |
| 15 | "@executor-v2/persistence-ports": "workspace:*", | 26 | "@executor-v2/persistence-ports": "workspace:*", |
| 16 | "@executor-v2/schema": "workspace:*", | 27 | "@executor-v2/schema": "workspace:*", |
| 17 | "effect": "catalog:" | 28 | "effect": "catalog:", |
| 29 | "yaml": "^2.8.1" | ||
| 18 | }, | 30 | }, |
| 19 | "devDependencies": { | 31 | "devDependencies": { |
| 20 | "@effect/vitest": "catalog:", | 32 | "@effect/vitest": "catalog:", |
| @@ -1,7 +1,13 @@ | |||
| 1 | import { HttpApi, OpenApi } from "@effect/platform"; | 1 | import { HttpApi, OpenApi } from "@effect/platform"; |
| 2 | 2 | ||
| 3 | import { ApprovalsApi } from "./approvals/api"; | 3 | import { ApprovalsApi } from "./approvals/api"; |
| 4 | import { CredentialsApi } from "./credentials/api"; | ||
| 5 | import { OrganizationsApi } from "./organizations/api"; | ||
| 6 | import { PoliciesApi } from "./policies/api"; | ||
| 7 | import { StorageApi } from "./storage/api"; | ||
| 4 | import { SourcesApi } from "./sources/api"; | 8 | import { SourcesApi } from "./sources/api"; |
| 9 | import { ToolsApi } from "./tools/api"; | ||
| 10 | import { WorkspacesApi } from "./workspaces/api"; | ||
| 5 | 11 | ||
| 6 | export { | 12 | export { |
| 7 | ControlPlaneBadRequestError, | 13 | ControlPlaneBadRequestError, |
| @@ -17,6 +23,58 @@ export { · 9 unmodified lines | |||
| 17 | type UpsertSourcePayload, | 23 | type UpsertSourcePayload, |
| 18 | } from "./sources/api"; | 24 | } from "./sources/api"; |
| 19 | 25 | ||
| 26 | export { | ||
| 27 | RemoveCredentialBindingResultSchema, | ||
| 28 | UpsertCredentialBindingPayloadSchema, | ||
| 29 | type RemoveCredentialBindingResult, | ||
| 30 | type UpsertCredentialBindingPayload, | ||
| 31 | } from "./credentials/api"; | ||
| 32 | |||
| 33 | export { | ||
| 34 | RemovePolicyResultSchema, | ||
| 35 | UpsertPolicyPayloadSchema, | ||
| 36 | type RemovePolicyResult, | ||
| 37 | type UpsertPolicyPayload, | ||
| 38 | } from "./policies/api"; | ||
| 39 | |||
| 40 | export { | ||
| 41 | UpsertOrganizationPayloadSchema, | ||
| 42 | type UpsertOrganizationPayload, | ||
| 43 | } from "./organizations/api"; | ||
| 44 | |||
| 45 | export { | ||
| 46 | SourceToolSummarySchema, | ||
| 47 | type SourceToolSummary, | ||
| 48 | } from "./tools/api"; | ||
| 49 | |||
| 50 | export { | ||
| 51 | UpsertWorkspacePayloadSchema, | ||
| 52 | type UpsertWorkspacePayload, | ||
| 53 | } from "./workspaces/api"; | ||
| 54 | |||
| 55 | export { | ||
| 56 | ListStorageDirectoryPayloadSchema, | ||
| 57 | ListStorageDirectoryResultSchema, | ||
| 58 | ListStorageKvPayloadSchema, | ||
| 59 | ListStorageKvResultSchema, | ||
| 60 | OpenStorageInstancePayloadSchema, | ||
| 61 | QueryStorageSqlPayloadSchema, | ||
| 62 | QueryStorageSqlResultSchema, | ||
| 63 | ReadStorageFilePayloadSchema, | ||
| 64 | ReadStorageFileResultSchema, | ||
| 65 | RemoveStorageInstanceResultSchema, | ||
| 66 | type ListStorageDirectoryPayload, | ||
| 67 | type ListStorageDirectoryResult, | ||
| 68 | type ListStorageKvPayload, | ||
| 69 | type ListStorageKvResult, | ||
| 70 | type OpenStorageInstancePayload, | ||
| 71 | type QueryStorageSqlPayload, | ||
| 72 | type QueryStorageSqlResult, | ||
| 73 | type ReadStorageFilePayload, | ||
| 74 | type ReadStorageFileResult, | ||
| 75 | type RemoveStorageInstanceResult, | ||
| 76 | } from "./storage/api"; | ||
| 77 | |||
| 20 | export { | 78 | export { |
| 21 | ResolveApprovalPayloadSchema, | 79 | ResolveApprovalPayloadSchema, |
| 22 | ResolveApprovalStatusSchema, | 80 | ResolveApprovalStatusSchema, |
| @@ -25,6 +83,12 @@ export { · 2 unmodified lines | |||
| 25 | 83 | ||
| 26 | export class ControlPlaneApi extends HttpApi.make("controlPlane") | 84 | export class ControlPlaneApi extends HttpApi.make("controlPlane") |
| 27 | .add(SourcesApi) | 85 | .add(SourcesApi) |
| 86 | .add(CredentialsApi) | ||
| 87 | .add(PoliciesApi) | ||
| 88 | .add(OrganizationsApi) | ||
| 89 | .add(WorkspacesApi) | ||
| 90 | .add(ToolsApi) | ||
| 91 | .add(StorageApi) | ||
| 28 | .add(ApprovalsApi) | 92 | .add(ApprovalsApi) |
| 29 | .annotateContext( | 93 | .annotateContext( |
| 30 | OpenApi.annotations({ | 94 | OpenApi.annotations({ |
| @@ -7,12 +7,19 @@ import * as PlatformHeaders from "@effect/platform/Headers"; · 6 unmodified lines | |||
| 7 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | ||
| 10 | export type ResolveActorInput = { | ||
| 11 | headers: PlatformHeaders.Headers; | ||
| 12 | }; | ||
| 13 | |||
| 10 | export type ResolveWorkspaceActorInput = { | 14 | export type ResolveWorkspaceActorInput = { |
| 11 | workspaceId: WorkspaceId; | 15 | workspaceId: WorkspaceId; |
| 12 | headers: PlatformHeaders.Headers; | 16 | headers: PlatformHeaders.Headers; |
| 13 | }; | 17 | }; |
| 14 | 18 | ||
| 15 | export type ControlPlaneActorResolverShape = { | 19 | export type ControlPlaneActorResolverShape = { |
| 20 | resolveActor: ( | ||
| 21 | input: ResolveActorInput, | ||
| 22 | ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>; | ||
| 16 | resolveWorkspaceActor: ( | 23 | resolveWorkspaceActor: ( |
| 17 | input: ResolveWorkspaceActorInput, | 24 | input: ResolveWorkspaceActorInput, |
| 18 | ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>; | 25 | ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>; |
| @@ -0,0 +1,74 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | CredentialBindingIdSchema, | ||
| 4 | SourceCredentialBindingSchema, | ||
| 5 | WorkspaceIdSchema, | ||
| 6 | } from "@executor-v2/schema"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | ControlPlaneBadRequestError, | ||
| 11 | ControlPlaneForbiddenError, | ||
| 12 | ControlPlaneStorageError, | ||
| 13 | ControlPlaneUnauthorizedError, | ||
| 14 | } from "../errors"; | ||
| 15 | |||
| 16 | const RequiredUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSchema.pipe( | ||
| 17 | Schema.pick("credentialId", "scopeType", "sourceKey", "provider", "secretRef"), | ||
| 18 | ); | ||
| 19 | |||
| 20 | const OptionalUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSchema.pipe( | ||
| 21 | Schema.pick("id", "accountId", "additionalHeadersJson", "boundAuthFingerprint"), | ||
| 22 | Schema.partialWith({ exact: true }), | ||
| 23 | ); | ||
| 24 | |||
| 25 | export const UpsertCredentialBindingPayloadSchema = | ||
| 26 | RequiredUpsertCredentialBindingPayloadSchema.pipe( | ||
| 27 | Schema.extend(OptionalUpsertCredentialBindingPayloadSchema), | ||
| 28 | ); | ||
| 29 | |||
| 30 | export type UpsertCredentialBindingPayload = | ||
| 31 | typeof UpsertCredentialBindingPayloadSchema.Type; | ||
| 32 | |||
| 33 | export const RemoveCredentialBindingResultSchema = Schema.Struct({ | ||
| 34 | removed: Schema.Boolean, | ||
| 35 | }); | ||
| 36 | |||
| 37 | export type RemoveCredentialBindingResult = | ||
| 38 | typeof RemoveCredentialBindingResultSchema.Type; | ||
| 39 | |||
| 40 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | ||
| 41 | const credentialBindingIdParam = HttpApiSchema.param( | ||
| 42 | "credentialBindingId", | ||
| 43 | CredentialBindingIdSchema, | ||
| 44 | ); | ||
| 45 | |||
| 46 | export class CredentialsApi extends HttpApiGroup.make("credentials") | ||
| 47 | .add( | ||
| 48 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/credentials` | ||
| 49 | .addSuccess(Schema.Array(SourceCredentialBindingSchema)) | ||
| 50 | .addError(ControlPlaneBadRequestError) | ||
| 51 | .addError(ControlPlaneUnauthorizedError) | ||
| 52 | .addError(ControlPlaneForbiddenError) | ||
| 53 | .addError(ControlPlaneStorageError), | ||
| 54 | ) | ||
| 55 | .add( | ||
| 56 | HttpApiEndpoint.post("upsert")`/workspaces/${workspaceIdParam}/credentials` | ||
| 57 | .setPayload(UpsertCredentialBindingPayloadSchema) | ||
| 58 | .addSuccess(SourceCredentialBindingSchema) | ||
| 59 | .addError(ControlPlaneBadRequestError) | ||
| 60 | .addError(ControlPlaneUnauthorizedError) | ||
| 61 | .addError(ControlPlaneForbiddenError) | ||
| 62 | .addError(ControlPlaneStorageError), | ||
| 63 | ) | ||
| 64 | .add( | ||
| 65 | HttpApiEndpoint.del( | ||
| 66 | "remove", | ||
| 67 | )`/workspaces/${workspaceIdParam}/credentials/${credentialBindingIdParam}` | ||
| 68 | .addSuccess(RemoveCredentialBindingResultSchema) | ||
| 69 | .addError(ControlPlaneBadRequestError) | ||
| 70 | .addError(ControlPlaneUnauthorizedError) | ||
| 71 | .addError(ControlPlaneForbiddenError) | ||
| 72 | .addError(ControlPlaneStorageError), | ||
| 73 | ) | ||
| 74 | .prefix("/v1") {} | ||
| @@ -0,0 +1,146 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | Actor, | ||
| 4 | ActorForbiddenError, | ||
| 5 | ActorUnauthenticatedError, | ||
| 6 | requirePermission, | ||
| 7 | withPolicy, | ||
| 8 | } from "@executor-v2/domain"; | ||
| 9 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 10 | import { type WorkspaceId } from "@executor-v2/schema"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | ControlPlaneForbiddenError, | ||
| 15 | ControlPlaneStorageError, | ||
| 16 | ControlPlaneUnauthorizedError, | ||
| 17 | } from "../errors"; | ||
| 18 | import { ControlPlaneApi } from "../api"; | ||
| 19 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 20 | import { ControlPlaneService } from "../service"; | ||
| 21 | |||
| 22 | const toStorageError = ( | ||
| 23 | operation: string, | ||
| 24 | cause: SourceStoreError, | ||
| 25 | ): ControlPlaneStorageError => | ||
| 26 | new ControlPlaneStorageError({ | ||
| 27 | operation, | ||
| 28 | message: "Control plane operation failed", | ||
| 29 | details: cause.details ?? cause.message, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const toForbiddenError = ( | ||
| 33 | operation: string, | ||
| 34 | cause: ActorForbiddenError, | ||
| 35 | ): ControlPlaneForbiddenError => | ||
| 36 | new ControlPlaneForbiddenError({ | ||
| 37 | operation, | ||
| 38 | message: "Access denied", | ||
| 39 | details: `${cause.permission} on ${cause.scope}`, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const toUnauthorizedError = ( | ||
| 43 | operation: string, | ||
| 44 | cause: ActorUnauthenticatedError, | ||
| 45 | ): ControlPlaneUnauthorizedError => | ||
| 46 | new ControlPlaneUnauthorizedError({ | ||
| 47 | operation, | ||
| 48 | message: cause.message, | ||
| 49 | details: "Authentication required", | ||
| 50 | }); | ||
| 51 | |||
| 52 | const resolveWorkspaceActor = (workspaceId: WorkspaceId) => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 55 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 56 | |||
| 57 | return yield* actorResolver.resolveWorkspaceActor({ | ||
| 58 | workspaceId, | ||
| 59 | headers: request.headers, | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | const requireReadCredentials = (workspaceId: WorkspaceId) => | ||
| 64 | requirePermission({ | ||
| 65 | permission: "credentials:read", | ||
| 66 | workspaceId, | ||
| 67 | }); | ||
| 68 | |||
| 69 | const requireWriteCredentials = (workspaceId: WorkspaceId) => | ||
| 70 | requirePermission({ | ||
| 71 | permission: "credentials:write", | ||
| 72 | workspaceId, | ||
| 73 | }); | ||
| 74 | |||
| 75 | export const ControlPlaneCredentialsLive = HttpApiBuilder.group( | ||
| 76 | ControlPlaneApi, | ||
| 77 | "credentials", | ||
| 78 | (handlers) => | ||
| 79 | handlers | ||
| 80 | .handle("list", ({ path }) => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const service = yield* ControlPlaneService; | ||
| 83 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 84 | |||
| 85 | return yield* withPolicy(requireReadCredentials(path.workspaceId))( | ||
| 86 | service.listCredentialBindings(path.workspaceId), | ||
| 87 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 88 | }).pipe( | ||
| 89 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 90 | toUnauthorizedError("credentials.list", cause), | ||
| 91 | ), | ||
| 92 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 93 | toForbiddenError("credentials.list", cause), | ||
| 94 | ), | ||
| 95 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 96 | toStorageError("credentials.list", cause), | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ) | ||
| 100 | .handle("upsert", ({ path, payload }) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const service = yield* ControlPlaneService; | ||
| 103 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 104 | |||
| 105 | return yield* withPolicy(requireWriteCredentials(path.workspaceId))( | ||
| 106 | service.upsertCredentialBinding({ | ||
| 107 | workspaceId: path.workspaceId, | ||
| 108 | payload, | ||
| 109 | }), | ||
| 110 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 111 | }).pipe( | ||
| 112 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 113 | toUnauthorizedError("credentials.upsert", cause), | ||
| 114 | ), | ||
| 115 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 116 | toForbiddenError("credentials.upsert", cause), | ||
| 117 | ), | ||
| 118 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 119 | toStorageError("credentials.upsert", cause), | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | ) | ||
| 123 | .handle("remove", ({ path }) => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const service = yield* ControlPlaneService; | ||
| 126 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 127 | |||
| 128 | return yield* withPolicy(requireWriteCredentials(path.workspaceId))( | ||
| 129 | service.removeCredentialBinding({ | ||
| 130 | workspaceId: path.workspaceId, | ||
| 131 | credentialBindingId: path.credentialBindingId, | ||
| 132 | }), | ||
| 133 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 134 | }).pipe( | ||
| 135 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 136 | toUnauthorizedError("credentials.remove", cause), | ||
| 137 | ), | ||
| 138 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 139 | toForbiddenError("credentials.remove", cause), | ||
| 140 | ), | ||
| 141 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 142 | toStorageError("credentials.remove", cause), | ||
| 143 | ), | ||
| 144 | ), | ||
| 145 | ), | ||
| 146 | ); | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | export { | ||
| 2 | CredentialsApi, | ||
| 3 | RemoveCredentialBindingResultSchema, | ||
| 4 | UpsertCredentialBindingPayloadSchema, | ||
| 5 | type RemoveCredentialBindingResult, | ||
| 6 | type UpsertCredentialBindingPayload, | ||
| 7 | } from "./api"; | ||
| 8 | |||
| 9 | export { | ||
| 10 | makeControlPlaneCredentialsService, | ||
| 11 | type ControlPlaneCredentialsServiceShape, | ||
| 12 | type RemoveCredentialBindingInput, | ||
| 13 | type UpsertCredentialBindingInput, | ||
| 14 | } from "./service"; | ||
| 15 | |||
| 16 | export { ControlPlaneCredentialsLive } from "./http"; | ||
| @@ -0,0 +1,38 @@ | |||
| 1 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { | ||
| 3 | type CredentialBindingId, | ||
| 4 | type SourceCredentialBinding, | ||
| 5 | type WorkspaceId, | ||
| 6 | } from "@executor-v2/schema"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import type { | ||
| 10 | RemoveCredentialBindingResult, | ||
| 11 | UpsertCredentialBindingPayload, | ||
| 12 | } from "./api"; | ||
| 13 | |||
| 14 | export type UpsertCredentialBindingInput = { | ||
| 15 | workspaceId: WorkspaceId; | ||
| 16 | payload: UpsertCredentialBindingPayload; | ||
| 17 | }; | ||
| 18 | |||
| 19 | export type RemoveCredentialBindingInput = { | ||
| 20 | workspaceId: WorkspaceId; | ||
| 21 | credentialBindingId: CredentialBindingId; | ||
| 22 | }; | ||
| 23 | |||
| 24 | export type ControlPlaneCredentialsServiceShape = { | ||
| 25 | listCredentialBindings: ( | ||
| 26 | workspaceId: WorkspaceId, | ||
| 27 | ) => Effect.Effect<ReadonlyArray<SourceCredentialBinding>, SourceStoreError>; | ||
| 28 | upsertCredentialBinding: ( | ||
| 29 | input: UpsertCredentialBindingInput, | ||
| 30 | ) => Effect.Effect<SourceCredentialBinding, SourceStoreError>; | ||
| 31 | removeCredentialBinding: ( | ||
| 32 | input: RemoveCredentialBindingInput, | ||
| 33 | ) => Effect.Effect<RemoveCredentialBindingResult, SourceStoreError>; | ||
| 34 | }; | ||
| 35 | |||
| 36 | export const makeControlPlaneCredentialsService = ( | ||
| 37 | service: ControlPlaneCredentialsServiceShape, | ||
| 38 | ): ControlPlaneCredentialsServiceShape => service; | ||
| @@ -8,10 +8,22 @@ import { · 7 unmodified lines | |||
| 8 | } from "./auth/actor-resolver"; | 8 | } from "./auth/actor-resolver"; |
| 9 | import { ControlPlaneService } from "./service"; | 9 | import { ControlPlaneService } from "./service"; |
| 10 | import { ControlPlaneApprovalsLive } from "./approvals/http"; | 10 | import { ControlPlaneApprovalsLive } from "./approvals/http"; |
| 11 | import { ControlPlaneCredentialsLive } from "./credentials/http"; | ||
| 12 | import { ControlPlaneOrganizationsLive } from "./organizations/http"; | ||
| 13 | import { ControlPlanePoliciesLive } from "./policies/http"; | ||
| 14 | import { ControlPlaneStorageLive } from "./storage/http"; | ||
| 11 | import { ControlPlaneSourcesLive } from "./sources/http"; | 15 | import { ControlPlaneSourcesLive } from "./sources/http"; |
| 16 | import { ControlPlaneToolsLive } from "./tools/http"; | ||
| 17 | import { ControlPlaneWorkspacesLive } from "./workspaces/http"; | ||
| 12 | 18 | ||
| 13 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( | 19 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( |
| 14 | Layer.provide(ControlPlaneSourcesLive), | 20 | Layer.provide(ControlPlaneSourcesLive), |
| 21 | Layer.provide(ControlPlaneCredentialsLive), | ||
| 22 | Layer.provide(ControlPlanePoliciesLive), | ||
| 23 | Layer.provide(ControlPlaneOrganizationsLive), | ||
| 24 | Layer.provide(ControlPlaneWorkspacesLive), | ||
| 25 | Layer.provide(ControlPlaneToolsLive), | ||
| 26 | Layer.provide(ControlPlaneStorageLive), | ||
| 15 | Layer.provide(ControlPlaneApprovalsLive), | 27 | Layer.provide(ControlPlaneApprovalsLive), |
| 16 | ); | 28 | ); |
| 17 | 29 | ||
| @@ -1,13 +1,47 @@ | |||
| 1 | export { | 1 | export { |
| 2 | ControlPlaneApi, | 2 | ControlPlaneApi, |
| 3 | ListStorageDirectoryPayloadSchema, | ||
| 4 | ListStorageDirectoryResultSchema, | ||
| 5 | ListStorageKvPayloadSchema, | ||
| 6 | ListStorageKvResultSchema, | ||
| 7 | OpenStorageInstancePayloadSchema, | ||
| 8 | QueryStorageSqlPayloadSchema, | ||
| 9 | QueryStorageSqlResultSchema, | ||
| 10 | ReadStorageFilePayloadSchema, | ||
| 11 | ReadStorageFileResultSchema, | ||
| 12 | RemoveCredentialBindingResultSchema, | ||
| 13 | RemovePolicyResultSchema, | ||
| 3 | RemoveSourceResultSchema, | 14 | RemoveSourceResultSchema, |
| 15 | RemoveStorageInstanceResultSchema, | ||
| 4 | ResolveApprovalPayloadSchema, | 16 | ResolveApprovalPayloadSchema, |
| 5 | ResolveApprovalStatusSchema, | 17 | ResolveApprovalStatusSchema, |
| 18 | SourceToolSummarySchema, | ||
| 19 | UpsertCredentialBindingPayloadSchema, | ||
| 20 | UpsertOrganizationPayloadSchema, | ||
| 21 | UpsertPolicyPayloadSchema, | ||
| 6 | UpsertSourcePayloadSchema, | 22 | UpsertSourcePayloadSchema, |
| 23 | UpsertWorkspacePayloadSchema, | ||
| 7 | controlPlaneOpenApiSpec, | 24 | controlPlaneOpenApiSpec, |
| 25 | type ListStorageDirectoryPayload, | ||
| 26 | type ListStorageDirectoryResult, | ||
| 27 | type ListStorageKvPayload, | ||
| 28 | type ListStorageKvResult, | ||
| 29 | type OpenStorageInstancePayload, | ||
| 30 | type QueryStorageSqlPayload, | ||
| 31 | type QueryStorageSqlResult, | ||
| 32 | type ReadStorageFilePayload, | ||
| 33 | type ReadStorageFileResult, | ||
| 34 | type RemoveCredentialBindingResult, | ||
| 35 | type RemovePolicyResult, | ||
| 8 | type RemoveSourceResult, | 36 | type RemoveSourceResult, |
| 37 | type RemoveStorageInstanceResult, | ||
| 9 | type ResolveApprovalPayload, | 38 | type ResolveApprovalPayload, |
| 39 | type SourceToolSummary, | ||
| 40 | type UpsertCredentialBindingPayload, | ||
| 41 | type UpsertOrganizationPayload, | ||
| 42 | type UpsertPolicyPayload, | ||
| 10 | type UpsertSourcePayload, | 43 | type UpsertSourcePayload, |
| 44 | type UpsertWorkspacePayload, | ||
| 11 | } from "./api"; | 45 | } from "./api"; |
| 12 | 46 | ||
| 13 | export { | 47 | export { |
| @@ -32,6 +66,7 @@ export { · 18 unmodified lines | |||
| 32 | export { | 66 | export { |
| 33 | ControlPlaneActorResolver, | 67 | ControlPlaneActorResolver, |
| 34 | type ControlPlaneActorResolverShape, | 68 | type ControlPlaneActorResolverShape, |
| 69 | type ResolveActorInput, | ||
| 35 | type ResolveWorkspaceActorInput, | 70 | type ResolveWorkspaceActorInput, |
| 36 | } from "./auth/actor-resolver"; | 71 | } from "./auth/actor-resolver"; |
| 37 | 72 | ||
| @@ -51,6 +86,52 @@ export { · 13 unmodified lines | |||
| 51 | type UpsertSourceInput, | 86 | type UpsertSourceInput, |
| 52 | } from "./sources"; | 87 | } from "./sources"; |
| 53 | 88 | ||
| 89 | export { | ||
| 90 | ControlPlaneCredentialsLive, | ||
| 91 | makeControlPlaneCredentialsService, | ||
| 92 | type ControlPlaneCredentialsServiceShape, | ||
| 93 | type RemoveCredentialBindingInput, | ||
| 94 | type UpsertCredentialBindingInput, | ||
| 95 | } from "./credentials"; | ||
| 96 | |||
| 97 | export { | ||
| 98 | ControlPlanePoliciesLive, | ||
| 99 | makeControlPlanePoliciesService, | ||
| 100 | type ControlPlanePoliciesServiceShape, | ||
| 101 | type RemovePolicyInput, | ||
| 102 | type UpsertPolicyInput, | ||
| 103 | } from "./policies"; | ||
| 104 | |||
| 105 | export { | ||
| 106 | ControlPlaneOrganizationsLive, | ||
| 107 | makeControlPlaneOrganizationsService, | ||
| 108 | type ControlPlaneOrganizationsServiceShape, | ||
| 109 | type UpsertOrganizationInput, | ||
| 110 | } from "./organizations"; | ||
| 111 | |||
| 112 | export { | ||
| 113 | ControlPlaneWorkspacesLive, | ||
| 114 | makeControlPlaneWorkspacesService, | ||
| 115 | type ControlPlaneWorkspacesServiceShape, | ||
| 116 | type UpsertWorkspaceInput, | ||
| 117 | } from "./workspaces"; | ||
| 118 | |||
| 119 | export { | ||
| 120 | ControlPlaneToolsLive, | ||
| 121 | makeControlPlaneToolsService, | ||
| 122 | type ControlPlaneToolsServiceShape, | ||
| 123 | type ListSourceToolsInput, | ||
| 124 | } from "./tools"; | ||
| 125 | |||
| 126 | export { | ||
| 127 | ControlPlaneStorageLive, | ||
| 128 | makeControlPlaneStorageService, | ||
| 129 | type CloseStorageInstanceInput, | ||
| 130 | type ControlPlaneStorageServiceShape, | ||
| 131 | type OpenStorageInstanceInput, | ||
| 132 | type RemoveStorageInstanceInput, | ||
| 133 | } from "./storage"; | ||
| 134 | |||
| 54 | export { | 135 | export { |
| 55 | ControlPlaneApprovalsLive, | 136 | ControlPlaneApprovalsLive, |
| 56 | makeControlPlaneApprovalsService, | 137 | makeControlPlaneApprovalsService, |
| @@ -90,3 +171,6 @@ export { · 33 unmodified lines | |||
| 90 | type SourceManagerService, | 171 | type SourceManagerService, |
| 91 | type ToolManifestDiff, | 172 | type ToolManifestDiff, |
| 92 | } from "./source-manager"; | 173 | } from "./source-manager"; |
| 174 | |||
| 175 | export { fetchOpenApiDocument, parseOpenApiDocument } from "./openapi-document"; | ||
| 176 | |||
| @@ -0,0 +1,36 @@ | |||
| 1 | import { parse as parseYaml } from "yaml"; | ||
| 2 | |||
| 3 | const parseJsonDocument = (input: string): unknown => JSON.parse(input); | ||
| 4 | |||
| 5 | const parseYamlDocument = (input: string): unknown => parseYaml(input); | ||
| 6 | |||
| 7 | export const parseOpenApiDocument = (input: string): unknown => { | ||
| 8 | const text = input.trim(); | ||
| 9 | if (text.length === 0) { | ||
| 10 | throw new Error("OpenAPI document is empty"); | ||
| 11 | } | ||
| 12 | |||
| 13 | try { | ||
| 14 | return parseJsonDocument(text); | ||
| 15 | } catch { | ||
| 16 | try { | ||
| 17 | return parseYamlDocument(text); | ||
| 18 | } catch (cause) { | ||
| 19 | throw new Error( | ||
| 20 | `Unable to parse OpenAPI document as JSON or YAML: ${ | ||
| 21 | cause instanceof Error ? cause.message : String(cause) | ||
| 22 | }`, | ||
| 23 | ); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | }; | ||
| 27 | |||
| 28 | export const fetchOpenApiDocument = async (url: string): Promise<unknown> => { | ||
| 29 | const response = await fetch(url); | ||
| 30 | if (!response.ok) { | ||
| 31 | throw new Error(`Failed fetching OpenAPI spec (${response.status})`); | ||
| 32 | } | ||
| 33 | |||
| 34 | const bodyText = await response.text(); | ||
| 35 | return parseOpenApiDocument(bodyText); | ||
| 36 | }; | ||
| @@ -1,5 +1,3 @@ | |||
| 1 | import { createHash } from "node:crypto"; | ||
| 2 | |||
| 3 | import { | 1 | import { |
| 4 | ToolArtifactStoreError, | 2 | ToolArtifactStoreError, |
| 5 | type ToolArtifactStore, | 3 | type ToolArtifactStore, |
| @@ -473,8 +471,20 @@ const toStableValue = (value: unknown): unknown => { · 467 unmodified lines | |||
| 473 | return value; | 471 | return value; |
| 474 | }; | 472 | }; |
| 475 | 473 | ||
| 474 | const hashString = (value: string): string => { | ||
| 475 | let hash = 0xcbf29ce484222325n; | ||
| 476 | const prime = 0x100000001b3n; | ||
| 477 | |||
| 478 | for (let index = 0; index < value.length; index += 1) { | ||
| 479 | hash ^= BigInt(value.charCodeAt(index)); | ||
| 480 | hash = BigInt.asUintN(64, hash * prime); | ||
| 481 | } | ||
| 482 | |||
| 483 | return hash.toString(16).padStart(16, "0"); | ||
| 484 | }; | ||
| 485 | |||
| 476 | const hashUnknown = (value: unknown): string => | 486 | const hashUnknown = (value: unknown): string => |
| 477 | createHash("sha256").update(JSON.stringify(toStableValue(value))).digest("hex"); | 487 | hashString(JSON.stringify(toStableValue(value))); |
| 478 | 488 | ||
| 479 | const normalizePathForToolId = (pathValue: string): string => | 489 | const normalizePathForToolId = (pathValue: string): string => |
| 480 | pathValue | 490 | pathValue |
| @@ -0,0 +1,47 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; | ||
| 2 | import { OrganizationSchema } from "@executor-v2/schema"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | ControlPlaneBadRequestError, | ||
| 7 | ControlPlaneForbiddenError, | ||
| 8 | ControlPlaneStorageError, | ||
| 9 | ControlPlaneUnauthorizedError, | ||
| 10 | } from "../errors"; | ||
| 11 | |||
| 12 | const RequiredUpsertOrganizationPayloadSchema = OrganizationSchema.pipe( | ||
| 13 | Schema.pick("slug", "name"), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const OptionalUpsertOrganizationPayloadSchema = OrganizationSchema.pipe( | ||
| 17 | Schema.pick("id", "status"), | ||
| 18 | Schema.partialWith({ exact: true }), | ||
| 19 | ); | ||
| 20 | |||
| 21 | export const UpsertOrganizationPayloadSchema = | ||
| 22 | RequiredUpsertOrganizationPayloadSchema.pipe( | ||
| 23 | Schema.extend(OptionalUpsertOrganizationPayloadSchema), | ||
| 24 | ); | ||
| 25 | |||
| 26 | export type UpsertOrganizationPayload = | ||
| 27 | typeof UpsertOrganizationPayloadSchema.Type; | ||
| 28 | |||
| 29 | export class OrganizationsApi extends HttpApiGroup.make("organizations") | ||
| 30 | .add( | ||
| 31 | HttpApiEndpoint.get("list")`/organizations` | ||
| 32 | .addSuccess(Schema.Array(OrganizationSchema)) | ||
| 33 | .addError(ControlPlaneBadRequestError) | ||
| 34 | .addError(ControlPlaneUnauthorizedError) | ||
| 35 | .addError(ControlPlaneForbiddenError) | ||
| 36 | .addError(ControlPlaneStorageError), | ||
| 37 | ) | ||
| 38 | .add( | ||
| 39 | HttpApiEndpoint.post("upsert")`/organizations` | ||
| 40 | .setPayload(UpsertOrganizationPayloadSchema) | ||
| 41 | .addSuccess(OrganizationSchema) | ||
| 42 | .addError(ControlPlaneBadRequestError) | ||
| 43 | .addError(ControlPlaneUnauthorizedError) | ||
| 44 | .addError(ControlPlaneForbiddenError) | ||
| 45 | .addError(ControlPlaneStorageError), | ||
| 46 | ) | ||
| 47 | .prefix("/v1") {} | ||
| @@ -0,0 +1,142 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | Actor, | ||
| 4 | ActorForbiddenError, | ||
| 5 | ActorUnauthenticatedError, | ||
| 6 | requirePermission, | ||
| 7 | type ActorShape, | ||
| 8 | withPolicy, | ||
| 9 | } from "@executor-v2/domain"; | ||
| 10 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 11 | import { | ||
| 12 | type Organization, | ||
| 13 | type OrganizationId, | ||
| 14 | } from "@executor-v2/schema"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | ControlPlaneBadRequestError, | ||
| 19 | ControlPlaneForbiddenError, | ||
| 20 | ControlPlaneStorageError, | ||
| 21 | ControlPlaneUnauthorizedError, | ||
| 22 | } from "../errors"; | ||
| 23 | import { ControlPlaneApi } from "../api"; | ||
| 24 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 25 | import { ControlPlaneService } from "../service"; | ||
| 26 | |||
| 27 | const toStorageError = ( | ||
| 28 | operation: string, | ||
| 29 | cause: SourceStoreError, | ||
| 30 | ): ControlPlaneStorageError => | ||
| 31 | new ControlPlaneStorageError({ | ||
| 32 | operation, | ||
| 33 | message: "Control plane operation failed", | ||
| 34 | details: cause.details ?? cause.message, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const toForbiddenError = ( | ||
| 38 | operation: string, | ||
| 39 | cause: ActorForbiddenError, | ||
| 40 | ): ControlPlaneForbiddenError => | ||
| 41 | new ControlPlaneForbiddenError({ | ||
| 42 | operation, | ||
| 43 | message: "Access denied", | ||
| 44 | details: `${cause.permission} on ${cause.scope}`, | ||
| 45 | }); | ||
| 46 | |||
| 47 | const toUnauthorizedError = ( | ||
| 48 | operation: string, | ||
| 49 | cause: ActorUnauthenticatedError, | ||
| 50 | ): ControlPlaneUnauthorizedError => | ||
| 51 | new ControlPlaneUnauthorizedError({ | ||
| 52 | operation, | ||
| 53 | message: cause.message, | ||
| 54 | details: "Authentication required", | ||
| 55 | }); | ||
| 56 | |||
| 57 | const resolveActor = Effect.gen(function* () { | ||
| 58 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 59 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 60 | |||
| 61 | return yield* actorResolver.resolveActor({ | ||
| 62 | headers: request.headers, | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | |||
| 66 | const requireReadOrganizations = requirePermission({ | ||
| 67 | permission: "organizations:read", | ||
| 68 | }); | ||
| 69 | |||
| 70 | const requireManageOrganization = (organizationId: OrganizationId) => | ||
| 71 | requirePermission({ | ||
| 72 | permission: "organizations:manage", | ||
| 73 | organizationId, | ||
| 74 | }); | ||
| 75 | |||
| 76 | const filterVisibleOrganizations = ( | ||
| 77 | actor: ActorShape, | ||
| 78 | organizations: ReadonlyArray<Organization>, | ||
| 79 | ): Array<Organization> => | ||
| 80 | organizations.filter((organization) => | ||
| 81 | actor.hasPermission({ | ||
| 82 | permission: "organizations:read", | ||
| 83 | organizationId: organization.id, | ||
| 84 | }) | ||
| 85 | ); | ||
| 86 | |||
| 87 | export const ControlPlaneOrganizationsLive = HttpApiBuilder.group( | ||
| 88 | ControlPlaneApi, | ||
| 89 | "organizations", | ||
| 90 | (handlers) => | ||
| 91 | handlers | ||
| 92 | .handle("list", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const service = yield* ControlPlaneService; | ||
| 95 | const actor = yield* resolveActor; | ||
| 96 | |||
| 97 | const organizations = yield* withPolicy(requireReadOrganizations)( | ||
| 98 | service.listOrganizations(), | ||
| 99 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 100 | |||
| 101 | return filterVisibleOrganizations(actor, organizations); | ||
| 102 | }).pipe( | ||
| 103 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 104 | toUnauthorizedError("organizations.list", cause), | ||
| 105 | ), | ||
| 106 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 107 | toForbiddenError("organizations.list", cause), | ||
| 108 | ), | ||
| 109 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 110 | toStorageError("organizations.list", cause), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | ) | ||
| 114 | .handle("upsert", ({ payload }) => | ||
| 115 | Effect.gen(function* () { | ||
| 116 | const service = yield* ControlPlaneService; | ||
| 117 | const actor = yield* resolveActor; | ||
| 118 | |||
| 119 | if (!payload.id) { | ||
| 120 | return yield* new ControlPlaneBadRequestError({ | ||
| 121 | operation: "organizations.upsert", | ||
| 122 | message: "organization id is required", | ||
| 123 | details: "payload.id must be provided for scoped authorization", | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | |||
| 127 | return yield* withPolicy(requireManageOrganization(payload.id))( | ||
| 128 | service.upsertOrganization({ payload }), | ||
| 129 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 130 | }).pipe( | ||
| 131 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 132 | toUnauthorizedError("organizations.upsert", cause), | ||
| 133 | ), | ||
| 134 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 135 | toForbiddenError("organizations.upsert", cause), | ||
| 136 | ), | ||
| 137 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 138 | toStorageError("organizations.upsert", cause), | ||
| 139 | ), | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | ); | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | export { | ||
| 2 | UpsertOrganizationPayloadSchema, | ||
| 3 | type UpsertOrganizationPayload, | ||
| 4 | } from "./api"; | ||
| 5 | |||
| 6 | export { | ||
| 7 | makeControlPlaneOrganizationsService, | ||
| 8 | type ControlPlaneOrganizationsServiceShape, | ||
| 9 | type UpsertOrganizationInput, | ||
| 10 | } from "./service"; | ||
| 11 | |||
| 12 | export { ControlPlaneOrganizationsLive } from "./http"; | ||
| @@ -0,0 +1,20 @@ | |||
| 1 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { type Organization } from "@executor-v2/schema"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import type { UpsertOrganizationPayload } from "./api"; | ||
| 6 | |||
| 7 | export type UpsertOrganizationInput = { | ||
| 8 | payload: UpsertOrganizationPayload; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export type ControlPlaneOrganizationsServiceShape = { | ||
| 12 | listOrganizations: () => Effect.Effect<ReadonlyArray<Organization>, SourceStoreError>; | ||
| 13 | upsertOrganization: ( | ||
| 14 | input: UpsertOrganizationInput, | ||
| 15 | ) => Effect.Effect<Organization, SourceStoreError>; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export const makeControlPlaneOrganizationsService = ( | ||
| 19 | service: ControlPlaneOrganizationsServiceShape, | ||
| 20 | ): ControlPlaneOrganizationsServiceShape => service; | ||
| @@ -0,0 +1,62 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { PolicyIdSchema, PolicySchema, WorkspaceIdSchema } from "@executor-v2/schema"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | ControlPlaneBadRequestError, | ||
| 7 | ControlPlaneForbiddenError, | ||
| 8 | ControlPlaneStorageError, | ||
| 9 | ControlPlaneUnauthorizedError, | ||
| 10 | } from "../errors"; | ||
| 11 | |||
| 12 | const RequiredUpsertPolicyPayloadSchema = PolicySchema.pipe( | ||
| 13 | Schema.pick("toolPathPattern", "decision"), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const OptionalUpsertPolicyPayloadSchema = PolicySchema.pipe( | ||
| 17 | Schema.pick("id"), | ||
| 18 | Schema.partialWith({ exact: true }), | ||
| 19 | ); | ||
| 20 | |||
| 21 | export const UpsertPolicyPayloadSchema = RequiredUpsertPolicyPayloadSchema.pipe( | ||
| 22 | Schema.extend(OptionalUpsertPolicyPayloadSchema), | ||
| 23 | ); | ||
| 24 | |||
| 25 | export type UpsertPolicyPayload = typeof UpsertPolicyPayloadSchema.Type; | ||
| 26 | |||
| 27 | export const RemovePolicyResultSchema = Schema.Struct({ | ||
| 28 | removed: Schema.Boolean, | ||
| 29 | }); | ||
| 30 | |||
| 31 | export type RemovePolicyResult = typeof RemovePolicyResultSchema.Type; | ||
| 32 | |||
| 33 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | ||
| 34 | const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema); | ||
| 35 | |||
| 36 | export class PoliciesApi extends HttpApiGroup.make("policies") | ||
| 37 | .add( | ||
| 38 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/policies` | ||
| 39 | .addSuccess(Schema.Array(PolicySchema)) | ||
| 40 | .addError(ControlPlaneBadRequestError) | ||
| 41 | .addError(ControlPlaneUnauthorizedError) | ||
| 42 | .addError(ControlPlaneForbiddenError) | ||
| 43 | .addError(ControlPlaneStorageError), | ||
| 44 | ) | ||
| 45 | .add( | ||
| 46 | HttpApiEndpoint.post("upsert")`/workspaces/${workspaceIdParam}/policies` | ||
| 47 | .setPayload(UpsertPolicyPayloadSchema) | ||
| 48 | .addSuccess(PolicySchema) | ||
| 49 | .addError(ControlPlaneBadRequestError) | ||
| 50 | .addError(ControlPlaneUnauthorizedError) | ||
| 51 | .addError(ControlPlaneForbiddenError) | ||
| 52 | .addError(ControlPlaneStorageError), | ||
| 53 | ) | ||
| 54 | .add( | ||
| 55 | HttpApiEndpoint.del("remove")`/workspaces/${workspaceIdParam}/policies/${policyIdParam}` | ||
| 56 | .addSuccess(RemovePolicyResultSchema) | ||
| 57 | .addError(ControlPlaneBadRequestError) | ||
| 58 | .addError(ControlPlaneUnauthorizedError) | ||
| 59 | .addError(ControlPlaneForbiddenError) | ||
| 60 | .addError(ControlPlaneStorageError), | ||
| 61 | ) | ||
| 62 | .prefix("/v1") {} | ||
| @@ -0,0 +1,146 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | Actor, | ||
| 4 | ActorForbiddenError, | ||
| 5 | ActorUnauthenticatedError, | ||
| 6 | requirePermission, | ||
| 7 | withPolicy, | ||
| 8 | } from "@executor-v2/domain"; | ||
| 9 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 10 | import { type WorkspaceId } from "@executor-v2/schema"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | ControlPlaneForbiddenError, | ||
| 15 | ControlPlaneStorageError, | ||
| 16 | ControlPlaneUnauthorizedError, | ||
| 17 | } from "../errors"; | ||
| 18 | import { ControlPlaneApi } from "../api"; | ||
| 19 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 20 | import { ControlPlaneService } from "../service"; | ||
| 21 | |||
| 22 | const toStorageError = ( | ||
| 23 | operation: string, | ||
| 24 | cause: SourceStoreError, | ||
| 25 | ): ControlPlaneStorageError => | ||
| 26 | new ControlPlaneStorageError({ | ||
| 27 | operation, | ||
| 28 | message: "Control plane operation failed", | ||
| 29 | details: cause.details ?? cause.message, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const toForbiddenError = ( | ||
| 33 | operation: string, | ||
| 34 | cause: ActorForbiddenError, | ||
| 35 | ): ControlPlaneForbiddenError => | ||
| 36 | new ControlPlaneForbiddenError({ | ||
| 37 | operation, | ||
| 38 | message: "Access denied", | ||
| 39 | details: `${cause.permission} on ${cause.scope}`, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const toUnauthorizedError = ( | ||
| 43 | operation: string, | ||
| 44 | cause: ActorUnauthenticatedError, | ||
| 45 | ): ControlPlaneUnauthorizedError => | ||
| 46 | new ControlPlaneUnauthorizedError({ | ||
| 47 | operation, | ||
| 48 | message: cause.message, | ||
| 49 | details: "Authentication required", | ||
| 50 | }); | ||
| 51 | |||
| 52 | const resolveWorkspaceActor = (workspaceId: WorkspaceId) => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 55 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 56 | |||
| 57 | return yield* actorResolver.resolveWorkspaceActor({ | ||
| 58 | workspaceId, | ||
| 59 | headers: request.headers, | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | const requireReadPolicies = (workspaceId: WorkspaceId) => | ||
| 64 | requirePermission({ | ||
| 65 | permission: "policies:read", | ||
| 66 | workspaceId, | ||
| 67 | }); | ||
| 68 | |||
| 69 | const requireWritePolicies = (workspaceId: WorkspaceId) => | ||
| 70 | requirePermission({ | ||
| 71 | permission: "policies:write", | ||
| 72 | workspaceId, | ||
| 73 | }); | ||
| 74 | |||
| 75 | export const ControlPlanePoliciesLive = HttpApiBuilder.group( | ||
| 76 | ControlPlaneApi, | ||
| 77 | "policies", | ||
| 78 | (handlers) => | ||
| 79 | handlers | ||
| 80 | .handle("list", ({ path }) => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const service = yield* ControlPlaneService; | ||
| 83 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 84 | |||
| 85 | return yield* withPolicy(requireReadPolicies(path.workspaceId))( | ||
| 86 | service.listPolicies(path.workspaceId), | ||
| 87 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 88 | }).pipe( | ||
| 89 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 90 | toUnauthorizedError("policies.list", cause), | ||
| 91 | ), | ||
| 92 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 93 | toForbiddenError("policies.list", cause), | ||
| 94 | ), | ||
| 95 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 96 | toStorageError("policies.list", cause), | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ) | ||
| 100 | .handle("upsert", ({ path, payload }) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const service = yield* ControlPlaneService; | ||
| 103 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 104 | |||
| 105 | return yield* withPolicy(requireWritePolicies(path.workspaceId))( | ||
| 106 | service.upsertPolicy({ | ||
| 107 | workspaceId: path.workspaceId, | ||
| 108 | payload, | ||
| 109 | }), | ||
| 110 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 111 | }).pipe( | ||
| 112 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 113 | toUnauthorizedError("policies.upsert", cause), | ||
| 114 | ), | ||
| 115 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 116 | toForbiddenError("policies.upsert", cause), | ||
| 117 | ), | ||
| 118 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 119 | toStorageError("policies.upsert", cause), | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | ) | ||
| 123 | .handle("remove", ({ path }) => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const service = yield* ControlPlaneService; | ||
| 126 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 127 | |||
| 128 | return yield* withPolicy(requireWritePolicies(path.workspaceId))( | ||
| 129 | service.removePolicy({ | ||
| 130 | workspaceId: path.workspaceId, | ||
| 131 | policyId: path.policyId, | ||
| 132 | }), | ||
| 133 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 134 | }).pipe( | ||
| 135 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 136 | toUnauthorizedError("policies.remove", cause), | ||
| 137 | ), | ||
| 138 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 139 | toForbiddenError("policies.remove", cause), | ||
| 140 | ), | ||
| 141 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 142 | toStorageError("policies.remove", cause), | ||
| 143 | ), | ||
| 144 | ), | ||
| 145 | ), | ||
| 146 | ); | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | export { | ||
| 2 | PoliciesApi, | ||
| 3 | RemovePolicyResultSchema, | ||
| 4 | UpsertPolicyPayloadSchema, | ||
| 5 | type RemovePolicyResult, | ||
| 6 | type UpsertPolicyPayload, | ||
| 7 | } from "./api"; | ||
| 8 | |||
| 9 | export { | ||
| 10 | makeControlPlanePoliciesService, | ||
| 11 | type ControlPlanePoliciesServiceShape, | ||
| 12 | type RemovePolicyInput, | ||
| 13 | type UpsertPolicyInput, | ||
| 14 | } from "./service"; | ||
| 15 | |||
| 16 | export { ControlPlanePoliciesLive } from "./http"; | ||
| @@ -0,0 +1,31 @@ | |||
| 1 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { type Policy, type PolicyId, type WorkspaceId } from "@executor-v2/schema"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import type { RemovePolicyResult, UpsertPolicyPayload } from "./api"; | ||
| 6 | |||
| 7 | export type UpsertPolicyInput = { | ||
| 8 | workspaceId: WorkspaceId; | ||
| 9 | payload: UpsertPolicyPayload; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export type RemovePolicyInput = { | ||
| 13 | workspaceId: WorkspaceId; | ||
| 14 | policyId: PolicyId; | ||
| 15 | }; | ||
| 16 | |||
| 17 | export type ControlPlanePoliciesServiceShape = { | ||
| 18 | listPolicies: ( | ||
| 19 | workspaceId: WorkspaceId, | ||
| 20 | ) => Effect.Effect<ReadonlyArray<Policy>, SourceStoreError>; | ||
| 21 | upsertPolicy: ( | ||
| 22 | input: UpsertPolicyInput, | ||
| 23 | ) => Effect.Effect<Policy, SourceStoreError>; | ||
| 24 | removePolicy: ( | ||
| 25 | input: RemovePolicyInput, | ||
| 26 | ) => Effect.Effect<RemovePolicyResult, SourceStoreError>; | ||
| 27 | }; | ||
| 28 | |||
| 29 | export const makeControlPlanePoliciesService = ( | ||
| 30 | service: ControlPlanePoliciesServiceShape, | ||
| 31 | ): ControlPlanePoliciesServiceShape => service; | ||
| @@ -1,9 +1,21 @@ | |||
| 1 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | ||
| 3 | import { type ControlPlaneApprovalsServiceShape } from "./approvals/service"; | 3 | import { type ControlPlaneApprovalsServiceShape } from "./approvals/service"; |
| 4 | import { type ControlPlaneCredentialsServiceShape } from "./credentials/service"; | ||
| 5 | import { type ControlPlaneOrganizationsServiceShape } from "./organizations/service"; | ||
| 6 | import { type ControlPlanePoliciesServiceShape } from "./policies/service"; | ||
| 7 | import { type ControlPlaneStorageServiceShape } from "./storage/service"; | ||
| 4 | import { type ControlPlaneSourcesServiceShape } from "./sources/service"; | 8 | import { type ControlPlaneSourcesServiceShape } from "./sources/service"; |
| 9 | import { type ControlPlaneToolsServiceShape } from "./tools/service"; | ||
| 10 | import { type ControlPlaneWorkspacesServiceShape } from "./workspaces/service"; | ||
| 5 | 11 | ||
| 6 | export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape & | 12 | export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape & |
| 13 | ControlPlaneCredentialsServiceShape & | ||
| 14 | ControlPlanePoliciesServiceShape & | ||
| 15 | ControlPlaneOrganizationsServiceShape & | ||
| 16 | ControlPlaneWorkspacesServiceShape & | ||
| 17 | ControlPlaneToolsServiceShape & | ||
| 18 | ControlPlaneStorageServiceShape & | ||
| 7 | ControlPlaneApprovalsServiceShape; | 19 | ControlPlaneApprovalsServiceShape; |
| 8 | 20 | ||
| 9 | export class ControlPlaneService extends Context.Tag("@executor-v2/management-api/ControlPlaneService")< | 21 | export class ControlPlaneService extends Context.Tag("@executor-v2/management-api/ControlPlaneService")< |
| @@ -13,8 +25,20 @@ export class ControlPlaneService extends Context.Tag("@executor-v2/management-ap · 3 unmodified lines | |||
| 13 | 25 | ||
| 14 | export const makeControlPlaneService = (services: { | 26 | export const makeControlPlaneService = (services: { |
| 15 | sources: ControlPlaneSourcesServiceShape; | 27 | sources: ControlPlaneSourcesServiceShape; |
| 28 | credentials: ControlPlaneCredentialsServiceShape; | ||
| 29 | policies: ControlPlanePoliciesServiceShape; | ||
| 30 | organizations: ControlPlaneOrganizationsServiceShape; | ||
| 31 | workspaces: ControlPlaneWorkspacesServiceShape; | ||
| 32 | tools: ControlPlaneToolsServiceShape; | ||
| 33 | storage: ControlPlaneStorageServiceShape; | ||
| 16 | approvals: ControlPlaneApprovalsServiceShape; | 34 | approvals: ControlPlaneApprovalsServiceShape; |
| 17 | }): ControlPlaneServiceShape => ({ | 35 | }): ControlPlaneServiceShape => ({ |
| 18 | ...services.sources, | 36 | ...services.sources, |
| 37 | ...services.credentials, | ||
| 38 | ...services.policies, | ||
| 39 | ...services.organizations, | ||
| 40 | ...services.workspaces, | ||
| 41 | ...services.tools, | ||
| 42 | ...services.storage, | ||
| 19 | ...services.approvals, | 43 | ...services.approvals, |
| 20 | }); | 44 | }); |
| @@ -0,0 +1,235 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | AccountIdSchema, | ||
| 4 | StorageDurabilitySchema, | ||
| 5 | StorageInstanceIdSchema, | ||
| 6 | StorageInstanceSchema, | ||
| 7 | StorageProviderSchema, | ||
| 8 | StorageScopeTypeSchema, | ||
| 9 | WorkspaceIdSchema, | ||
| 10 | } from "@executor-v2/schema"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | ControlPlaneBadRequestError, | ||
| 15 | ControlPlaneForbiddenError, | ||
| 16 | ControlPlaneStorageError, | ||
| 17 | ControlPlaneUnauthorizedError, | ||
| 18 | } from "../errors"; | ||
| 19 | |||
| 20 | const RequiredOpenStorageInstancePayloadSchema = Schema.Struct({ | ||
| 21 | scopeType: StorageScopeTypeSchema, | ||
| 22 | durability: StorageDurabilitySchema, | ||
| 23 | }); | ||
| 24 | |||
| 25 | const OptionalOpenStorageInstancePayloadSchema = Schema.Struct({ | ||
| 26 | provider: StorageProviderSchema, | ||
| 27 | purpose: Schema.String, | ||
| 28 | ttlHours: Schema.Number, | ||
| 29 | accountId: AccountIdSchema, | ||
| 30 | sessionId: Schema.String, | ||
| 31 | }).pipe(Schema.partialWith({ exact: true })); | ||
| 32 | |||
| 33 | export const OpenStorageInstancePayloadSchema = | ||
| 34 | RequiredOpenStorageInstancePayloadSchema.pipe( | ||
| 35 | Schema.extend(OptionalOpenStorageInstancePayloadSchema), | ||
| 36 | ); | ||
| 37 | |||
| 38 | export type OpenStorageInstancePayload = | ||
| 39 | typeof OpenStorageInstancePayloadSchema.Type; | ||
| 40 | |||
| 41 | export const RemoveStorageInstanceResultSchema = Schema.Struct({ | ||
| 42 | removed: Schema.Boolean, | ||
| 43 | }); | ||
| 44 | |||
| 45 | export type RemoveStorageInstanceResult = | ||
| 46 | typeof RemoveStorageInstanceResultSchema.Type; | ||
| 47 | |||
| 48 | export const ListStorageDirectoryPayloadSchema = Schema.Struct({ | ||
| 49 | path: Schema.String, | ||
| 50 | }); | ||
| 51 | |||
| 52 | export type ListStorageDirectoryPayload = | ||
| 53 | typeof ListStorageDirectoryPayloadSchema.Type; | ||
| 54 | |||
| 55 | export const StorageDirectoryEntrySchema = Schema.Struct({ | ||
| 56 | name: Schema.String, | ||
| 57 | path: Schema.String, | ||
| 58 | kind: Schema.Literal("file", "directory"), | ||
| 59 | sizeBytes: Schema.NullOr(Schema.Number), | ||
| 60 | updatedAt: Schema.Number, | ||
| 61 | }); | ||
| 62 | |||
| 63 | export type StorageDirectoryEntry = typeof StorageDirectoryEntrySchema.Type; | ||
| 64 | |||
| 65 | export const ListStorageDirectoryResultSchema = Schema.Struct({ | ||
| 66 | path: Schema.String, | ||
| 67 | entries: Schema.Array(StorageDirectoryEntrySchema), | ||
| 68 | }); | ||
| 69 | |||
| 70 | export type ListStorageDirectoryResult = | ||
| 71 | typeof ListStorageDirectoryResultSchema.Type; | ||
| 72 | |||
| 73 | const RequiredReadStorageFilePayloadSchema = Schema.Struct({ | ||
| 74 | path: Schema.String, | ||
| 75 | }); | ||
| 76 | |||
| 77 | const OptionalReadStorageFilePayloadSchema = Schema.Struct({ | ||
| 78 | encoding: Schema.Literal("utf8", "base64"), | ||
| 79 | }).pipe(Schema.partialWith({ exact: true })); | ||
| 80 | |||
| 81 | export const ReadStorageFilePayloadSchema = | ||
| 82 | RequiredReadStorageFilePayloadSchema.pipe( | ||
| 83 | Schema.extend(OptionalReadStorageFilePayloadSchema), | ||
| 84 | ); | ||
| 85 | |||
| 86 | export type ReadStorageFilePayload = typeof ReadStorageFilePayloadSchema.Type; | ||
| 87 | |||
| 88 | export const ReadStorageFileResultSchema = Schema.Struct({ | ||
| 89 | path: Schema.String, | ||
| 90 | encoding: Schema.Literal("utf8", "base64"), | ||
| 91 | content: Schema.String, | ||
| 92 | bytes: Schema.Number, | ||
| 93 | }); | ||
| 94 | |||
| 95 | export type ReadStorageFileResult = typeof ReadStorageFileResultSchema.Type; | ||
| 96 | |||
| 97 | export const ListStorageKvPayloadSchema = Schema.Struct({ | ||
| 98 | prefix: Schema.String, | ||
| 99 | limit: Schema.Number, | ||
| 100 | }).pipe(Schema.partialWith({ exact: true })); | ||
| 101 | |||
| 102 | export type ListStorageKvPayload = typeof ListStorageKvPayloadSchema.Type; | ||
| 103 | |||
| 104 | export const StorageKvItemSchema = Schema.Struct({ | ||
| 105 | key: Schema.String, | ||
| 106 | value: Schema.Unknown, | ||
| 107 | }); | ||
| 108 | |||
| 109 | export type StorageKvItem = typeof StorageKvItemSchema.Type; | ||
| 110 | |||
| 111 | export const ListStorageKvResultSchema = Schema.Struct({ | ||
| 112 | items: Schema.Array(StorageKvItemSchema), | ||
| 113 | }); | ||
| 114 | |||
| 115 | export type ListStorageKvResult = typeof ListStorageKvResultSchema.Type; | ||
| 116 | |||
| 117 | const RequiredQueryStorageSqlPayloadSchema = Schema.Struct({ | ||
| 118 | sql: Schema.String, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const OptionalQueryStorageSqlPayloadSchema = Schema.Struct({ | ||
| 122 | maxRows: Schema.Number, | ||
| 123 | }).pipe(Schema.partialWith({ exact: true })); | ||
| 124 | |||
| 125 | export const QueryStorageSqlPayloadSchema = | ||
| 126 | RequiredQueryStorageSqlPayloadSchema.pipe( | ||
| 127 | Schema.extend(OptionalQueryStorageSqlPayloadSchema), | ||
| 128 | ); | ||
| 129 | |||
| 130 | export type QueryStorageSqlPayload = typeof QueryStorageSqlPayloadSchema.Type; | ||
| 131 | |||
| 132 | const SqlRowSchema = Schema.Record({ | ||
| 133 | key: Schema.String, | ||
| 134 | value: Schema.Unknown, | ||
| 135 | }); | ||
| 136 | |||
| 137 | export type SqlRow = typeof SqlRowSchema.Type; | ||
| 138 | |||
| 139 | export const QueryStorageSqlResultSchema = Schema.Struct({ | ||
| 140 | rows: Schema.Array(SqlRowSchema), | ||
| 141 | columns: Schema.Array(Schema.String), | ||
| 142 | rowCount: Schema.Number, | ||
| 143 | }); | ||
| 144 | |||
| 145 | export type QueryStorageSqlResult = typeof QueryStorageSqlResultSchema.Type; | ||
| 146 | |||
| 147 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | ||
| 148 | const storageInstanceIdParam = HttpApiSchema.param( | ||
| 149 | "storageInstanceId", | ||
| 150 | StorageInstanceIdSchema, | ||
| 151 | ); | ||
| 152 | |||
| 153 | export class StorageApi extends HttpApiGroup.make("storage") | ||
| 154 | .add( | ||
| 155 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/storage-instances` | ||
| 156 | .addSuccess(Schema.Array(StorageInstanceSchema)) | ||
| 157 | .addError(ControlPlaneBadRequestError) | ||
| 158 | .addError(ControlPlaneUnauthorizedError) | ||
| 159 | .addError(ControlPlaneForbiddenError) | ||
| 160 | .addError(ControlPlaneStorageError), | ||
| 161 | ) | ||
| 162 | .add( | ||
| 163 | HttpApiEndpoint.post("open")`/workspaces/${workspaceIdParam}/storage-instances/open` | ||
| 164 | .setPayload(OpenStorageInstancePayloadSchema) | ||
| 165 | .addSuccess(StorageInstanceSchema) | ||
| 166 | .addError(ControlPlaneBadRequestError) | ||
| 167 | .addError(ControlPlaneUnauthorizedError) | ||
| 168 | .addError(ControlPlaneForbiddenError) | ||
| 169 | .addError(ControlPlaneStorageError), | ||
| 170 | ) | ||
| 171 | .add( | ||
| 172 | HttpApiEndpoint.post( | ||
| 173 | "close", | ||
| 174 | )`/workspaces/${workspaceIdParam}/storage-instances/${storageInstanceIdParam}/close` | ||
| 175 | .addSuccess(StorageInstanceSchema) | ||
| 176 | .addError(ControlPlaneBadRequestError) | ||
| 177 | .addError(ControlPlaneUnauthorizedError) | ||
| 178 | .addError(ControlPlaneForbiddenError) | ||
| 179 | .addError(ControlPlaneStorageError), | ||
| 180 | ) | ||
| 181 | .add( | ||
| 182 | HttpApiEndpoint.del( | ||
| 183 | "remove", | ||
| 184 | )`/workspaces/${workspaceIdParam}/storage-instances/${storageInstanceIdParam}` | ||
| 185 | .addSuccess(RemoveStorageInstanceResultSchema) | ||
| 186 | .addError(ControlPlaneBadRequestError) | ||
| 187 | .addError(ControlPlaneUnauthorizedError) | ||
| 188 | .addError(ControlPlaneForbiddenError) | ||
| 189 | .addError(ControlPlaneStorageError), | ||
| 190 | ) | ||
| 191 | .add( | ||
| 192 | HttpApiEndpoint.post( | ||
| 193 | "listDirectory", | ||
| 194 | )`/workspaces/${workspaceIdParam}/storage-instances/${storageInstanceIdParam}/fs/list` | ||
| 195 | .setPayload(ListStorageDirectoryPayloadSchema) | ||
| 196 | .addSuccess(ListStorageDirectoryResultSchema) | ||
| 197 | .addError(ControlPlaneBadRequestError) | ||
| 198 | .addError(ControlPlaneUnauthorizedError) | ||
| 199 | .addError(ControlPlaneForbiddenError) | ||
| 200 | .addError(ControlPlaneStorageError), | ||
| 201 | ) | ||
| 202 | .add( | ||
| 203 | HttpApiEndpoint.post( | ||
| 204 | "readFile", | ||
| 205 | )`/workspaces/${workspaceIdParam}/storage-instances/${storageInstanceIdParam}/fs/read` | ||
| 206 | .setPayload(ReadStorageFilePayloadSchema) | ||
| 207 | .addSuccess(ReadStorageFileResultSchema) | ||
| 208 | .addError(ControlPlaneBadRequestError) | ||
| 209 | .addError(ControlPlaneUnauthorizedError) | ||
| 210 | .addError(ControlPlaneForbiddenError) | ||
| 211 | .addError(ControlPlaneStorageError), | ||
| 212 | ) | ||
| 213 | .add( | ||
| 214 | HttpApiEndpoint.post( | ||
| 215 | "listKv", | ||
| 216 | )`/workspaces/${workspaceIdParam}/storage-instances/${storageInstanceIdParam}/kv/list` | ||
| 217 | .setPayload(ListStorageKvPayloadSchema) | ||
| 218 | .addSuccess(ListStorageKvResultSchema) | ||
| 219 | .addError(ControlPlaneBadRequestError) | ||
| 220 | .addError(ControlPlaneUnauthorizedError) | ||
| 221 | .addError(ControlPlaneForbiddenError) | ||
| 222 | .addError(ControlPlaneStorageError), | ||
| 223 | ) | ||
| 224 | .add( | ||
| 225 | HttpApiEndpoint.post( | ||
| 226 | "querySql", | ||
| 227 | )`/workspaces/${workspaceIdParam}/storage-instances/${storageInstanceIdParam}/sql/query` | ||
| 228 | .setPayload(QueryStorageSqlPayloadSchema) | ||
| 229 | .addSuccess(QueryStorageSqlResultSchema) | ||
| 230 | .addError(ControlPlaneBadRequestError) | ||
| 231 | .addError(ControlPlaneUnauthorizedError) | ||
| 232 | .addError(ControlPlaneForbiddenError) | ||
| 233 | .addError(ControlPlaneStorageError), | ||
| 234 | ) | ||
| 235 | .prefix("/v1") {} | ||
| @@ -0,0 +1,265 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | Actor, | ||
| 4 | ActorForbiddenError, | ||
| 5 | ActorUnauthenticatedError, | ||
| 6 | requirePermission, | ||
| 7 | withPolicy, | ||
| 8 | } from "@executor-v2/domain"; | ||
| 9 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 10 | import { type WorkspaceId } from "@executor-v2/schema"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | ControlPlaneForbiddenError, | ||
| 15 | ControlPlaneStorageError, | ||
| 16 | ControlPlaneUnauthorizedError, | ||
| 17 | } from "../errors"; | ||
| 18 | import { ControlPlaneApi } from "../api"; | ||
| 19 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 20 | import { ControlPlaneService } from "../service"; | ||
| 21 | |||
| 22 | const toStorageError = ( | ||
| 23 | operation: string, | ||
| 24 | cause: SourceStoreError, | ||
| 25 | ): ControlPlaneStorageError => | ||
| 26 | new ControlPlaneStorageError({ | ||
| 27 | operation, | ||
| 28 | message: "Control plane operation failed", | ||
| 29 | details: cause.details ?? cause.message, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const toForbiddenError = ( | ||
| 33 | operation: string, | ||
| 34 | cause: ActorForbiddenError, | ||
| 35 | ): ControlPlaneForbiddenError => | ||
| 36 | new ControlPlaneForbiddenError({ | ||
| 37 | operation, | ||
| 38 | message: "Access denied", | ||
| 39 | details: `${cause.permission} on ${cause.scope}`, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const toUnauthorizedError = ( | ||
| 43 | operation: string, | ||
| 44 | cause: ActorUnauthenticatedError, | ||
| 45 | ): ControlPlaneUnauthorizedError => | ||
| 46 | new ControlPlaneUnauthorizedError({ | ||
| 47 | operation, | ||
| 48 | message: cause.message, | ||
| 49 | details: "Authentication required", | ||
| 50 | }); | ||
| 51 | |||
| 52 | const resolveWorkspaceActor = (workspaceId: WorkspaceId) => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 55 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 56 | |||
| 57 | return yield* actorResolver.resolveWorkspaceActor({ | ||
| 58 | workspaceId, | ||
| 59 | headers: request.headers, | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | const requireReadStorage = (workspaceId: WorkspaceId) => | ||
| 64 | requirePermission({ | ||
| 65 | permission: "storage:read", | ||
| 66 | workspaceId, | ||
| 67 | }); | ||
| 68 | |||
| 69 | const requireWriteStorage = (workspaceId: WorkspaceId) => | ||
| 70 | requirePermission({ | ||
| 71 | permission: "storage:write", | ||
| 72 | workspaceId, | ||
| 73 | }); | ||
| 74 | |||
| 75 | export const ControlPlaneStorageLive = HttpApiBuilder.group( | ||
| 76 | ControlPlaneApi, | ||
| 77 | "storage", | ||
| 78 | (handlers) => | ||
| 79 | handlers | ||
| 80 | .handle("list", ({ path }) => | ||
| 81 | Effect.gen(function* () { | ||
| 82 | const service = yield* ControlPlaneService; | ||
| 83 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 84 | |||
| 85 | return yield* withPolicy(requireReadStorage(path.workspaceId))( | ||
| 86 | service.listStorageInstances(path.workspaceId), | ||
| 87 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 88 | }).pipe( | ||
| 89 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 90 | toUnauthorizedError("storage.list", cause), | ||
| 91 | ), | ||
| 92 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 93 | toForbiddenError("storage.list", cause), | ||
| 94 | ), | ||
| 95 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 96 | toStorageError("storage.list", cause), | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ) | ||
| 100 | .handle("open", ({ path, payload }) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const service = yield* ControlPlaneService; | ||
| 103 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 104 | |||
| 105 | return yield* withPolicy(requireWriteStorage(path.workspaceId))( | ||
| 106 | service.openStorageInstance({ | ||
| 107 | workspaceId: path.workspaceId, | ||
| 108 | payload, | ||
| 109 | }), | ||
| 110 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 111 | }).pipe( | ||
| 112 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 113 | toUnauthorizedError("storage.open", cause), | ||
| 114 | ), | ||
| 115 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 116 | toForbiddenError("storage.open", cause), | ||
| 117 | ), | ||
| 118 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 119 | toStorageError("storage.open", cause), | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | ) | ||
| 123 | .handle("close", ({ path }) => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const service = yield* ControlPlaneService; | ||
| 126 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 127 | |||
| 128 | return yield* withPolicy(requireWriteStorage(path.workspaceId))( | ||
| 129 | service.closeStorageInstance({ | ||
| 130 | workspaceId: path.workspaceId, | ||
| 131 | storageInstanceId: path.storageInstanceId, | ||
| 132 | }), | ||
| 133 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 134 | }).pipe( | ||
| 135 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 136 | toUnauthorizedError("storage.close", cause), | ||
| 137 | ), | ||
| 138 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 139 | toForbiddenError("storage.close", cause), | ||
| 140 | ), | ||
| 141 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 142 | toStorageError("storage.close", cause), | ||
| 143 | ), | ||
| 144 | ), | ||
| 145 | ) | ||
| 146 | .handle("remove", ({ path }) => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | const service = yield* ControlPlaneService; | ||
| 149 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 150 | |||
| 151 | return yield* withPolicy(requireWriteStorage(path.workspaceId))( | ||
| 152 | service.removeStorageInstance({ | ||
| 153 | workspaceId: path.workspaceId, | ||
| 154 | storageInstanceId: path.storageInstanceId, | ||
| 155 | }), | ||
| 156 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 157 | }).pipe( | ||
| 158 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 159 | toUnauthorizedError("storage.remove", cause), | ||
| 160 | ), | ||
| 161 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 162 | toForbiddenError("storage.remove", cause), | ||
| 163 | ), | ||
| 164 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 165 | toStorageError("storage.remove", cause), | ||
| 166 | ), | ||
| 167 | ), | ||
| 168 | ) | ||
| 169 | .handle("listDirectory", ({ path, payload }) => | ||
| 170 | Effect.gen(function* () { | ||
| 171 | const service = yield* ControlPlaneService; | ||
| 172 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 173 | |||
| 174 | return yield* withPolicy(requireReadStorage(path.workspaceId))( | ||
| 175 | service.listStorageDirectory({ | ||
| 176 | workspaceId: path.workspaceId, | ||
| 177 | storageInstanceId: path.storageInstanceId, | ||
| 178 | payload, | ||
| 179 | }), | ||
| 180 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 181 | }).pipe( | ||
| 182 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 183 | toUnauthorizedError("storage.listDirectory", cause), | ||
| 184 | ), | ||
| 185 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 186 | toForbiddenError("storage.listDirectory", cause), | ||
| 187 | ), | ||
| 188 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 189 | toStorageError("storage.listDirectory", cause), | ||
| 190 | ), | ||
| 191 | ), | ||
| 192 | ) | ||
| 193 | .handle("readFile", ({ path, payload }) => | ||
| 194 | Effect.gen(function* () { | ||
| 195 | const service = yield* ControlPlaneService; | ||
| 196 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 197 | |||
| 198 | return yield* withPolicy(requireReadStorage(path.workspaceId))( | ||
| 199 | service.readStorageFile({ | ||
| 200 | workspaceId: path.workspaceId, | ||
| 201 | storageInstanceId: path.storageInstanceId, | ||
| 202 | payload, | ||
| 203 | }), | ||
| 204 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 205 | }).pipe( | ||
| 206 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 207 | toUnauthorizedError("storage.readFile", cause), | ||
| 208 | ), | ||
| 209 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 210 | toForbiddenError("storage.readFile", cause), | ||
| 211 | ), | ||
| 212 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 213 | toStorageError("storage.readFile", cause), | ||
| 214 | ), | ||
| 215 | ), | ||
| 216 | ) | ||
| 217 | .handle("listKv", ({ path, payload }) => | ||
| 218 | Effect.gen(function* () { | ||
| 219 | const service = yield* ControlPlaneService; | ||
| 220 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 221 | |||
| 222 | return yield* withPolicy(requireReadStorage(path.workspaceId))( | ||
| 223 | service.listStorageKv({ | ||
| 224 | workspaceId: path.workspaceId, | ||
| 225 | storageInstanceId: path.storageInstanceId, | ||
| 226 | payload, | ||
| 227 | }), | ||
| 228 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 229 | }).pipe( | ||
| 230 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 231 | toUnauthorizedError("storage.listKv", cause), | ||
| 232 | ), | ||
| 233 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 234 | toForbiddenError("storage.listKv", cause), | ||
| 235 | ), | ||
| 236 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 237 | toStorageError("storage.listKv", cause), | ||
| 238 | ), | ||
| 239 | ), | ||
| 240 | ) | ||
| 241 | .handle("querySql", ({ path, payload }) => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const service = yield* ControlPlaneService; | ||
| 244 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 245 | |||
| 246 | return yield* withPolicy(requireWriteStorage(path.workspaceId))( | ||
| 247 | service.queryStorageSql({ | ||
| 248 | workspaceId: path.workspaceId, | ||
| 249 | storageInstanceId: path.storageInstanceId, | ||
| 250 | payload, | ||
| 251 | }), | ||
| 252 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 253 | }).pipe( | ||
| 254 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 255 | toUnauthorizedError("storage.querySql", cause), | ||
| 256 | ), | ||
| 257 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 258 | toForbiddenError("storage.querySql", cause), | ||
| 259 | ), | ||
| 260 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 261 | toStorageError("storage.querySql", cause), | ||
| 262 | ), | ||
| 263 | ), | ||
| 264 | ), | ||
| 265 | ); | ||
| @@ -0,0 +1,37 @@ | |||
| 1 | export { | ||
| 2 | ListStorageDirectoryPayloadSchema, | ||
| 3 | ListStorageDirectoryResultSchema, | ||
| 4 | ListStorageKvPayloadSchema, | ||
| 5 | ListStorageKvResultSchema, | ||
| 6 | OpenStorageInstancePayloadSchema, | ||
| 7 | QueryStorageSqlPayloadSchema, | ||
| 8 | QueryStorageSqlResultSchema, | ||
| 9 | ReadStorageFilePayloadSchema, | ||
| 10 | ReadStorageFileResultSchema, | ||
| 11 | RemoveStorageInstanceResultSchema, | ||
| 12 | StorageApi, | ||
| 13 | type ListStorageDirectoryPayload, | ||
| 14 | type ListStorageDirectoryResult, | ||
| 15 | type ListStorageKvPayload, | ||
| 16 | type ListStorageKvResult, | ||
| 17 | type OpenStorageInstancePayload, | ||
| 18 | type QueryStorageSqlPayload, | ||
| 19 | type QueryStorageSqlResult, | ||
| 20 | type ReadStorageFilePayload, | ||
| 21 | type ReadStorageFileResult, | ||
| 22 | type RemoveStorageInstanceResult, | ||
| 23 | } from "./api"; | ||
| 24 | |||
| 25 | export { | ||
| 26 | makeControlPlaneStorageService, | ||
| 27 | type CloseStorageInstanceInput, | ||
| 28 | type ControlPlaneStorageServiceShape, | ||
| 29 | type ListStorageDirectoryInput, | ||
| 30 | type ListStorageKvInput, | ||
| 31 | type OpenStorageInstanceInput, | ||
| 32 | type QueryStorageSqlInput, | ||
| 33 | type ReadStorageFileInput, | ||
| 34 | type RemoveStorageInstanceInput, | ||
| 35 | } from "./service"; | ||
| 36 | |||
| 37 | export { ControlPlaneStorageLive } from "./http"; | ||
| @@ -0,0 +1,90 @@ | |||
| 1 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { | ||
| 3 | type StorageInstance, | ||
| 4 | type StorageInstanceId, | ||
| 5 | type WorkspaceId, | ||
| 6 | } from "@executor-v2/schema"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import type { | ||
| 10 | ListStorageDirectoryPayload, | ||
| 11 | ListStorageDirectoryResult, | ||
| 12 | ListStorageKvPayload, | ||
| 13 | ListStorageKvResult, | ||
| 14 | OpenStorageInstancePayload, | ||
| 15 | QueryStorageSqlPayload, | ||
| 16 | QueryStorageSqlResult, | ||
| 17 | ReadStorageFilePayload, | ||
| 18 | ReadStorageFileResult, | ||
| 19 | RemoveStorageInstanceResult, | ||
| 20 | } from "./api"; | ||
| 21 | |||
| 22 | export type OpenStorageInstanceInput = { | ||
| 23 | workspaceId: WorkspaceId; | ||
| 24 | payload: OpenStorageInstancePayload; | ||
| 25 | }; | ||
| 26 | |||
| 27 | export type CloseStorageInstanceInput = { | ||
| 28 | workspaceId: WorkspaceId; | ||
| 29 | storageInstanceId: StorageInstanceId; | ||
| 30 | }; | ||
| 31 | |||
| 32 | export type RemoveStorageInstanceInput = { | ||
| 33 | workspaceId: WorkspaceId; | ||
| 34 | storageInstanceId: StorageInstanceId; | ||
| 35 | }; | ||
| 36 | |||
| 37 | export type ListStorageDirectoryInput = { | ||
| 38 | workspaceId: WorkspaceId; | ||
| 39 | storageInstanceId: StorageInstanceId; | ||
| 40 | payload: ListStorageDirectoryPayload; | ||
| 41 | }; | ||
| 42 | |||
| 43 | export type ReadStorageFileInput = { | ||
| 44 | workspaceId: WorkspaceId; | ||
| 45 | storageInstanceId: StorageInstanceId; | ||
| 46 | payload: ReadStorageFilePayload; | ||
| 47 | }; | ||
| 48 | |||
| 49 | export type ListStorageKvInput = { | ||
| 50 | workspaceId: WorkspaceId; | ||
| 51 | storageInstanceId: StorageInstanceId; | ||
| 52 | payload: ListStorageKvPayload; | ||
| 53 | }; | ||
| 54 | |||
| 55 | export type QueryStorageSqlInput = { | ||
| 56 | workspaceId: WorkspaceId; | ||
| 57 | storageInstanceId: StorageInstanceId; | ||
| 58 | payload: QueryStorageSqlPayload; | ||
| 59 | }; | ||
| 60 | |||
| 61 | export type ControlPlaneStorageServiceShape = { | ||
| 62 | listStorageInstances: ( | ||
| 63 | workspaceId: WorkspaceId, | ||
| 64 | ) => Effect.Effect<ReadonlyArray<StorageInstance>, SourceStoreError>; | ||
| 65 | openStorageInstance: ( | ||
| 66 | input: OpenStorageInstanceInput, | ||
| 67 | ) => Effect.Effect<StorageInstance, SourceStoreError>; | ||
| 68 | closeStorageInstance: ( | ||
| 69 | input: CloseStorageInstanceInput, | ||
| 70 | ) => Effect.Effect<StorageInstance, SourceStoreError>; | ||
| 71 | removeStorageInstance: ( | ||
| 72 | input: RemoveStorageInstanceInput, | ||
| 73 | ) => Effect.Effect<RemoveStorageInstanceResult, SourceStoreError>; | ||
| 74 | listStorageDirectory: ( | ||
| 75 | input: ListStorageDirectoryInput, | ||
| 76 | ) => Effect.Effect<ListStorageDirectoryResult, SourceStoreError>; | ||
| 77 | readStorageFile: ( | ||
| 78 | input: ReadStorageFileInput, | ||
| 79 | ) => Effect.Effect<ReadStorageFileResult, SourceStoreError>; | ||
| 80 | listStorageKv: ( | ||
| 81 | input: ListStorageKvInput, | ||
| 82 | ) => Effect.Effect<ListStorageKvResult, SourceStoreError>; | ||
| 83 | queryStorageSql: ( | ||
| 84 | input: QueryStorageSqlInput, | ||
| 85 | ) => Effect.Effect<QueryStorageSqlResult, SourceStoreError>; | ||
| 86 | }; | ||
| 87 | |||
| 88 | export const makeControlPlaneStorageService = ( | ||
| 89 | service: ControlPlaneStorageServiceShape, | ||
| 90 | ): ControlPlaneStorageServiceShape => service; | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | OpenApiHttpMethodSchema, | ||
| 4 | SourceIdSchema, | ||
| 5 | SourceKindSchema, | ||
| 6 | WorkspaceIdSchema, | ||
| 7 | } from "@executor-v2/schema"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | ControlPlaneBadRequestError, | ||
| 12 | ControlPlaneForbiddenError, | ||
| 13 | ControlPlaneStorageError, | ||
| 14 | ControlPlaneUnauthorizedError, | ||
| 15 | } from "../errors"; | ||
| 16 | |||
| 17 | export const SourceToolSummarySchema = Schema.Struct({ | ||
| 18 | sourceId: SourceIdSchema, | ||
| 19 | sourceName: Schema.String, | ||
| 20 | sourceKind: SourceKindSchema, | ||
| 21 | toolId: Schema.String, | ||
| 22 | name: Schema.String, | ||
| 23 | description: Schema.NullOr(Schema.String), | ||
| 24 | method: OpenApiHttpMethodSchema, | ||
| 25 | path: Schema.String, | ||
| 26 | operationHash: Schema.String, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export type SourceToolSummary = typeof SourceToolSummarySchema.Type; | ||
| 30 | |||
| 31 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | ||
| 32 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | ||
| 33 | |||
| 34 | export class ToolsApi extends HttpApiGroup.make("tools") | ||
| 35 | .add( | ||
| 36 | HttpApiEndpoint.get("listWorkspaceTools")`/workspaces/${workspaceIdParam}/tools` | ||
| 37 | .addSuccess(Schema.Array(SourceToolSummarySchema)) | ||
| 38 | .addError(ControlPlaneBadRequestError) | ||
| 39 | .addError(ControlPlaneUnauthorizedError) | ||
| 40 | .addError(ControlPlaneForbiddenError) | ||
| 41 | .addError(ControlPlaneStorageError), | ||
| 42 | ) | ||
| 43 | .add( | ||
| 44 | HttpApiEndpoint.get( | ||
| 45 | "listSourceTools", | ||
| 46 | )`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/tools` | ||
| 47 | .addSuccess(Schema.Array(SourceToolSummarySchema)) | ||
| 48 | .addError(ControlPlaneBadRequestError) | ||
| 49 | .addError(ControlPlaneUnauthorizedError) | ||
| 50 | .addError(ControlPlaneForbiddenError) | ||
| 51 | .addError(ControlPlaneStorageError), | ||
| 52 | ) | ||
| 53 | .prefix("/v1") {} | ||
| @@ -0,0 +1,117 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | Actor, | ||
| 4 | ActorForbiddenError, | ||
| 5 | ActorUnauthenticatedError, | ||
| 6 | requirePermission, | ||
| 7 | withPolicy, | ||
| 8 | } from "@executor-v2/domain"; | ||
| 9 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 10 | import { type WorkspaceId } from "@executor-v2/schema"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | ControlPlaneForbiddenError, | ||
| 15 | ControlPlaneStorageError, | ||
| 16 | ControlPlaneUnauthorizedError, | ||
| 17 | } from "../errors"; | ||
| 18 | import { ControlPlaneApi } from "../api"; | ||
| 19 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 20 | import { ControlPlaneService } from "../service"; | ||
| 21 | |||
| 22 | const toStorageError = ( | ||
| 23 | operation: string, | ||
| 24 | cause: SourceStoreError, | ||
| 25 | ): ControlPlaneStorageError => | ||
| 26 | new ControlPlaneStorageError({ | ||
| 27 | operation, | ||
| 28 | message: "Control plane operation failed", | ||
| 29 | details: cause.details ?? cause.message, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const toForbiddenError = ( | ||
| 33 | operation: string, | ||
| 34 | cause: ActorForbiddenError, | ||
| 35 | ): ControlPlaneForbiddenError => | ||
| 36 | new ControlPlaneForbiddenError({ | ||
| 37 | operation, | ||
| 38 | message: "Access denied", | ||
| 39 | details: `${cause.permission} on ${cause.scope}`, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const toUnauthorizedError = ( | ||
| 43 | operation: string, | ||
| 44 | cause: ActorUnauthenticatedError, | ||
| 45 | ): ControlPlaneUnauthorizedError => | ||
| 46 | new ControlPlaneUnauthorizedError({ | ||
| 47 | operation, | ||
| 48 | message: cause.message, | ||
| 49 | details: "Authentication required", | ||
| 50 | }); | ||
| 51 | |||
| 52 | const resolveWorkspaceActor = (workspaceId: WorkspaceId) => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 55 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 56 | |||
| 57 | return yield* actorResolver.resolveWorkspaceActor({ | ||
| 58 | workspaceId, | ||
| 59 | headers: request.headers, | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | const requireReadTools = (workspaceId: WorkspaceId) => | ||
| 64 | requirePermission({ | ||
| 65 | permission: "sources:read", | ||
| 66 | workspaceId, | ||
| 67 | }); | ||
| 68 | |||
| 69 | export const ControlPlaneToolsLive = HttpApiBuilder.group( | ||
| 70 | ControlPlaneApi, | ||
| 71 | "tools", | ||
| 72 | (handlers) => | ||
| 73 | handlers | ||
| 74 | .handle("listWorkspaceTools", ({ path }) => | ||
| 75 | Effect.gen(function* () { | ||
| 76 | const service = yield* ControlPlaneService; | ||
| 77 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 78 | |||
| 79 | return yield* withPolicy(requireReadTools(path.workspaceId))( | ||
| 80 | service.listWorkspaceTools(path.workspaceId), | ||
| 81 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 82 | }).pipe( | ||
| 83 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 84 | toUnauthorizedError("tools.listWorkspace", cause), | ||
| 85 | ), | ||
| 86 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 87 | toForbiddenError("tools.listWorkspace", cause), | ||
| 88 | ), | ||
| 89 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 90 | toStorageError("tools.listWorkspace", cause), | ||
| 91 | ), | ||
| 92 | ), | ||
| 93 | ) | ||
| 94 | .handle("listSourceTools", ({ path }) => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const service = yield* ControlPlaneService; | ||
| 97 | const actor = yield* resolveWorkspaceActor(path.workspaceId); | ||
| 98 | |||
| 99 | return yield* withPolicy(requireReadTools(path.workspaceId))( | ||
| 100 | service.listSourceTools({ | ||
| 101 | workspaceId: path.workspaceId, | ||
| 102 | sourceId: path.sourceId, | ||
| 103 | }), | ||
| 104 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 105 | }).pipe( | ||
| 106 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 107 | toUnauthorizedError("tools.listSource", cause), | ||
| 108 | ), | ||
| 109 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 110 | toForbiddenError("tools.listSource", cause), | ||
| 111 | ), | ||
| 112 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 113 | toStorageError("tools.listSource", cause), | ||
| 114 | ), | ||
| 115 | ), | ||
| 116 | ), | ||
| 117 | ); | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | export { | ||
| 2 | SourceToolSummarySchema, | ||
| 3 | type SourceToolSummary, | ||
| 4 | } from "./api"; | ||
| 5 | |||
| 6 | export { | ||
| 7 | makeControlPlaneToolsService, | ||
| 8 | type ControlPlaneToolsServiceShape, | ||
| 9 | type ListSourceToolsInput, | ||
| 10 | } from "./service"; | ||
| 11 | |||
| 12 | export { ControlPlaneToolsLive } from "./http"; | ||
| @@ -0,0 +1,23 @@ | |||
| 1 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { type SourceId, type WorkspaceId } from "@executor-v2/schema"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import type { SourceToolSummary } from "./api"; | ||
| 6 | |||
| 7 | export type ListSourceToolsInput = { | ||
| 8 | workspaceId: WorkspaceId; | ||
| 9 | sourceId: SourceId; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export type ControlPlaneToolsServiceShape = { | ||
| 13 | listWorkspaceTools: ( | ||
| 14 | workspaceId: WorkspaceId, | ||
| 15 | ) => Effect.Effect<ReadonlyArray<SourceToolSummary>, SourceStoreError>; | ||
| 16 | listSourceTools: ( | ||
| 17 | input: ListSourceToolsInput, | ||
| 18 | ) => Effect.Effect<ReadonlyArray<SourceToolSummary>, SourceStoreError>; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export const makeControlPlaneToolsService = ( | ||
| 22 | service: ControlPlaneToolsServiceShape, | ||
| 23 | ): ControlPlaneToolsServiceShape => service; | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; | ||
| 2 | import { WorkspaceSchema } from "@executor-v2/schema"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | ControlPlaneBadRequestError, | ||
| 7 | ControlPlaneForbiddenError, | ||
| 8 | ControlPlaneStorageError, | ||
| 9 | ControlPlaneUnauthorizedError, | ||
| 10 | } from "../errors"; | ||
| 11 | |||
| 12 | const RequiredUpsertWorkspacePayloadSchema = WorkspaceSchema.pipe( | ||
| 13 | Schema.pick("name"), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const OptionalUpsertWorkspacePayloadSchema = WorkspaceSchema.pipe( | ||
| 17 | Schema.pick("id", "organizationId"), | ||
| 18 | Schema.partialWith({ exact: true }), | ||
| 19 | ); | ||
| 20 | |||
| 21 | export const UpsertWorkspacePayloadSchema = RequiredUpsertWorkspacePayloadSchema.pipe( | ||
| 22 | Schema.extend(OptionalUpsertWorkspacePayloadSchema), | ||
| 23 | ); | ||
| 24 | |||
| 25 | export type UpsertWorkspacePayload = typeof UpsertWorkspacePayloadSchema.Type; | ||
| 26 | |||
| 27 | export class WorkspacesApi extends HttpApiGroup.make("workspaces") | ||
| 28 | .add( | ||
| 29 | HttpApiEndpoint.get("list")`/workspaces` | ||
| 30 | .addSuccess(Schema.Array(WorkspaceSchema)) | ||
| 31 | .addError(ControlPlaneBadRequestError) | ||
| 32 | .addError(ControlPlaneUnauthorizedError) | ||
| 33 | .addError(ControlPlaneForbiddenError) | ||
| 34 | .addError(ControlPlaneStorageError), | ||
| 35 | ) | ||
| 36 | .add( | ||
| 37 | HttpApiEndpoint.post("upsert")`/workspaces` | ||
| 38 | .setPayload(UpsertWorkspacePayloadSchema) | ||
| 39 | .addSuccess(WorkspaceSchema) | ||
| 40 | .addError(ControlPlaneBadRequestError) | ||
| 41 | .addError(ControlPlaneUnauthorizedError) | ||
| 42 | .addError(ControlPlaneForbiddenError) | ||
| 43 | .addError(ControlPlaneStorageError), | ||
| 44 | ) | ||
| 45 | .prefix("/v1") {} | ||
| @@ -0,0 +1,188 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | ||
| 2 | import { | ||
| 3 | Actor, | ||
| 4 | ActorForbiddenError, | ||
| 5 | ActorUnauthenticatedError, | ||
| 6 | any, | ||
| 7 | requirePermission, | ||
| 8 | type ActorShape, | ||
| 9 | withPolicy, | ||
| 10 | } from "@executor-v2/domain"; | ||
| 11 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 12 | import { | ||
| 13 | type OrganizationId, | ||
| 14 | type Workspace, | ||
| 15 | type WorkspaceId, | ||
| 16 | } from "@executor-v2/schema"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | ControlPlaneBadRequestError, | ||
| 21 | ControlPlaneForbiddenError, | ||
| 22 | ControlPlaneStorageError, | ||
| 23 | ControlPlaneUnauthorizedError, | ||
| 24 | } from "../errors"; | ||
| 25 | import { ControlPlaneApi } from "../api"; | ||
| 26 | import { ControlPlaneActorResolver } from "../auth/actor-resolver"; | ||
| 27 | import { ControlPlaneService } from "../service"; | ||
| 28 | |||
| 29 | const toStorageError = ( | ||
| 30 | operation: string, | ||
| 31 | cause: SourceStoreError, | ||
| 32 | ): ControlPlaneStorageError => | ||
| 33 | new ControlPlaneStorageError({ | ||
| 34 | operation, | ||
| 35 | message: "Control plane operation failed", | ||
| 36 | details: cause.details ?? cause.message, | ||
| 37 | }); | ||
| 38 | |||
| 39 | const toForbiddenError = ( | ||
| 40 | operation: string, | ||
| 41 | cause: ActorForbiddenError, | ||
| 42 | ): ControlPlaneForbiddenError => | ||
| 43 | new ControlPlaneForbiddenError({ | ||
| 44 | operation, | ||
| 45 | message: "Access denied", | ||
| 46 | details: `${cause.permission} on ${cause.scope}`, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const toUnauthorizedError = ( | ||
| 50 | operation: string, | ||
| 51 | cause: ActorUnauthenticatedError, | ||
| 52 | ): ControlPlaneUnauthorizedError => | ||
| 53 | new ControlPlaneUnauthorizedError({ | ||
| 54 | operation, | ||
| 55 | message: cause.message, | ||
| 56 | details: "Authentication required", | ||
| 57 | }); | ||
| 58 | |||
| 59 | const resolveActor = Effect.gen(function* () { | ||
| 60 | const actorResolver = yield* ControlPlaneActorResolver; | ||
| 61 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 62 | |||
| 63 | return yield* actorResolver.resolveActor({ | ||
| 64 | headers: request.headers, | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | |||
| 68 | const requireReadWorkspaces = requirePermission({ | ||
| 69 | permission: "workspace:read", | ||
| 70 | }); | ||
| 71 | |||
| 72 | const requireReadWorkspace = (workspaceId: WorkspaceId) => | ||
| 73 | requirePermission({ | ||
| 74 | permission: "workspace:read", | ||
| 75 | workspaceId, | ||
| 76 | }); | ||
| 77 | |||
| 78 | const requireReadWorkspaceInOrganization = (organizationId: OrganizationId) => | ||
| 79 | requirePermission({ | ||
| 80 | permission: "workspace:read", | ||
| 81 | organizationId, | ||
| 82 | }); | ||
| 83 | |||
| 84 | const requireManageWorkspace = (workspaceId: WorkspaceId) => | ||
| 85 | requirePermission({ | ||
| 86 | permission: "workspace:manage", | ||
| 87 | workspaceId, | ||
| 88 | }); | ||
| 89 | |||
| 90 | const requireManageWorkspaceInOrganization = (organizationId: OrganizationId) => | ||
| 91 | requirePermission({ | ||
| 92 | permission: "workspace:manage", | ||
| 93 | organizationId, | ||
| 94 | }); | ||
| 95 | |||
| 96 | const filterVisibleWorkspaces = ( | ||
| 97 | actor: ActorShape, | ||
| 98 | workspaces: ReadonlyArray<Workspace>, | ||
| 99 | ): Array<Workspace> => | ||
| 100 | workspaces.filter((workspace) => | ||
| 101 | actor.hasPermission({ | ||
| 102 | permission: "workspace:read", | ||
| 103 | workspaceId: workspace.id, | ||
| 104 | }) | ||
| 105 | || ( | ||
| 106 | workspace.organizationId !== null | ||
| 107 | && actor.hasPermission({ | ||
| 108 | permission: "workspace:read", | ||
| 109 | organizationId: workspace.organizationId, | ||
| 110 | }) | ||
| 111 | ) | ||
| 112 | ); | ||
| 113 | |||
| 114 | export const ControlPlaneWorkspacesLive = HttpApiBuilder.group( | ||
| 115 | ControlPlaneApi, | ||
| 116 | "workspaces", | ||
| 117 | (handlers) => | ||
| 118 | handlers | ||
| 119 | .handle("list", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const service = yield* ControlPlaneService; | ||
| 122 | const actor = yield* resolveActor; | ||
| 123 | |||
| 124 | const workspaces = yield* withPolicy(requireReadWorkspaces)( | ||
| 125 | service.listWorkspaces(), | ||
| 126 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 127 | |||
| 128 | return filterVisibleWorkspaces(actor, workspaces); | ||
| 129 | }).pipe( | ||
| 130 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 131 | toUnauthorizedError("workspaces.list", cause), | ||
| 132 | ), | ||
| 133 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 134 | toForbiddenError("workspaces.list", cause), | ||
| 135 | ), | ||
| 136 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 137 | toStorageError("workspaces.list", cause), | ||
| 138 | ), | ||
| 139 | ), | ||
| 140 | ) | ||
| 141 | .handle("upsert", ({ payload }) => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const service = yield* ControlPlaneService; | ||
| 144 | const actor = yield* resolveActor; | ||
| 145 | |||
| 146 | const workspacePolicy = (() => { | ||
| 147 | if (payload.id && payload.organizationId !== null && payload.organizationId !== undefined) { | ||
| 148 | return any([ | ||
| 149 | requireManageWorkspace(payload.id), | ||
| 150 | requireManageWorkspaceInOrganization(payload.organizationId), | ||
| 151 | ]); | ||
| 152 | } | ||
| 153 | |||
| 154 | if (payload.id) { | ||
| 155 | return requireManageWorkspace(payload.id); | ||
| 156 | } | ||
| 157 | |||
| 158 | if (payload.organizationId !== null && payload.organizationId !== undefined) { | ||
| 159 | return requireManageWorkspaceInOrganization(payload.organizationId); | ||
| 160 | } | ||
| 161 | |||
| 162 | return null; | ||
| 163 | })(); | ||
| 164 | |||
| 165 | if (!workspacePolicy) { | ||
| 166 | return yield* new ControlPlaneBadRequestError({ | ||
| 167 | operation: "workspaces.upsert", | ||
| 168 | message: "workspace id or organizationId is required", | ||
| 169 | details: "payload.id or payload.organizationId must be provided for scoped authorization", | ||
| 170 | }); | ||
| 171 | } | ||
| 172 | |||
| 173 | return yield* withPolicy(workspacePolicy)( | ||
| 174 | service.upsertWorkspace({ payload }), | ||
| 175 | ).pipe(Effect.provideService(Actor, actor)); | ||
| 176 | }).pipe( | ||
| 177 | Effect.catchTag("ActorUnauthenticatedError", (cause) => | ||
| 178 | toUnauthorizedError("workspaces.upsert", cause), | ||
| 179 | ), | ||
| 180 | Effect.catchTag("ActorForbiddenError", (cause) => | ||
| 181 | toForbiddenError("workspaces.upsert", cause), | ||
| 182 | ), | ||
| 183 | Effect.catchTag("SourceStoreError", (cause) => | ||
| 184 | toStorageError("workspaces.upsert", cause), | ||
| 185 | ), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | ); | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | export { | ||
| 2 | UpsertWorkspacePayloadSchema, | ||
| 3 | type UpsertWorkspacePayload, | ||
| 4 | } from "./api"; | ||
| 5 | |||
| 6 | export { | ||
| 7 | makeControlPlaneWorkspacesService, | ||
| 8 | type ControlPlaneWorkspacesServiceShape, | ||
| 9 | type UpsertWorkspaceInput, | ||
| 10 | } from "./service"; | ||
| 11 | |||
| 12 | export { ControlPlaneWorkspacesLive } from "./http"; | ||
| @@ -0,0 +1,20 @@ | |||
| 1 | import { type SourceStoreError } from "@executor-v2/persistence-ports"; | ||
| 2 | import { type Workspace } from "@executor-v2/schema"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import type { UpsertWorkspacePayload } from "./api"; | ||
| 6 | |||
| 7 | export type UpsertWorkspaceInput = { | ||
| 8 | payload: UpsertWorkspacePayload; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export type ControlPlaneWorkspacesServiceShape = { | ||
| 12 | listWorkspaces: () => Effect.Effect<ReadonlyArray<Workspace>, SourceStoreError>; | ||
| 13 | upsertWorkspace: ( | ||
| 14 | input: UpsertWorkspaceInput, | ||
| 15 | ) => Effect.Effect<Workspace, SourceStoreError>; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export const makeControlPlaneWorkspacesService = ( | ||
| 19 | service: ControlPlaneWorkspacesServiceShape, | ||
| 20 | ): ControlPlaneWorkspacesServiceShape => service; | ||
| @@ -18,6 +18,15 @@ export const CredentialScopeTypeSchema = Schema.Literal( · 17 unmodified lines | |||
| 18 | "organization", | 18 | "organization", |
| 19 | "account", | 19 | "account", |
| 20 | ); | 20 | ); |
| 21 | export const StorageScopeTypeSchema = Schema.Literal( | ||
| 22 | "scratch", | ||
| 23 | "account", | ||
| 24 | "workspace", | ||
| 25 | "organization", | ||
| 26 | ); | ||
| 27 | export const StorageDurabilitySchema = Schema.Literal("ephemeral", "durable"); | ||
| 28 | export const StorageInstanceStatusSchema = Schema.Literal("active", "closed", "deleted"); | ||
| 29 | export const StorageProviderSchema = Schema.Literal("agentfs-local", "agentfs-cloudflare"); | ||
| 21 | export const OrganizationStatusSchema = Schema.Literal("active", "suspended", "archived"); | 30 | export const OrganizationStatusSchema = Schema.Literal("active", "suspended", "archived"); |
| 22 | export const OrganizationMemberStatusSchema = Schema.Literal( | 31 | export const OrganizationMemberStatusSchema = Schema.Literal( |
| 23 | "invited", | 32 | "invited", |
| @@ -36,6 +45,10 @@ export type TaskRunStatus = typeof TaskRunStatusSchema.Type; · 12 unmodified lines | |||
| 36 | export type CredentialMode = typeof CredentialModeSchema.Type; | 45 | export type CredentialMode = typeof CredentialModeSchema.Type; |
| 37 | export type CredentialProvider = typeof CredentialProviderSchema.Type; | 46 | export type CredentialProvider = typeof CredentialProviderSchema.Type; |
| 38 | export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type; | 47 | export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type; |
| 48 | export type StorageScopeType = typeof StorageScopeTypeSchema.Type; | ||
| 49 | export type StorageDurability = typeof StorageDurabilitySchema.Type; | ||
| 50 | export type StorageInstanceStatus = typeof StorageInstanceStatusSchema.Type; | ||
| 51 | export type StorageProvider = typeof StorageProviderSchema.Type; | ||
| 39 | export type OrganizationStatus = typeof OrganizationStatusSchema.Type; | 52 | export type OrganizationStatus = typeof OrganizationStatusSchema.Type; |
| 40 | export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type; | 53 | export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type; |
| 41 | export type SyncTarget = typeof SyncTargetSchema.Type; | 54 | export type SyncTarget = typeof SyncTargetSchema.Type; |
| @@ -18,6 +18,9 @@ export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); · 17 unmodified lines | |||
| 18 | export const ApprovalIdSchema = Schema.String.pipe(Schema.brand("ApprovalId")); | 18 | export const ApprovalIdSchema = Schema.String.pipe(Schema.brand("ApprovalId")); |
| 19 | export const TaskRunIdSchema = Schema.String.pipe(Schema.brand("TaskRunId")); | 19 | export const TaskRunIdSchema = Schema.String.pipe(Schema.brand("TaskRunId")); |
| 20 | export const SyncStateIdSchema = Schema.String.pipe(Schema.brand("SyncStateId")); | 20 | export const SyncStateIdSchema = Schema.String.pipe(Schema.brand("SyncStateId")); |
| 21 | export const StorageInstanceIdSchema = Schema.String.pipe( | ||
| 22 | Schema.brand("StorageInstanceId"), | ||
| 23 | ); | ||
| 21 | export const EventIdSchema = Schema.String.pipe(Schema.brand("EventId")); | 24 | export const EventIdSchema = Schema.String.pipe(Schema.brand("EventId")); |
| 22 | 25 | ||
| 23 | export type AccountId = typeof AccountIdSchema.Type; | 26 | export type AccountId = typeof AccountIdSchema.Type; |
| @@ -34,4 +37,5 @@ export type PolicyId = typeof PolicyIdSchema.Type; · 10 unmodified lines | |||
| 34 | export type ApprovalId = typeof ApprovalIdSchema.Type; | 37 | export type ApprovalId = typeof ApprovalIdSchema.Type; |
| 35 | export type TaskRunId = typeof TaskRunIdSchema.Type; | 38 | export type TaskRunId = typeof TaskRunIdSchema.Type; |
| 36 | export type SyncStateId = typeof SyncStateIdSchema.Type; | 39 | export type SyncStateId = typeof SyncStateIdSchema.Type; |
| 40 | export type StorageInstanceId = typeof StorageInstanceIdSchema.Type; | ||
| 37 | export type EventId = typeof EventIdSchema.Type; | 41 | export type EventId = typeof EventIdSchema.Type; |
| @@ -12,6 +12,7 @@ export * from "./models/policy"; · 11 unmodified lines | |||
| 12 | export * from "./models/approval"; | 12 | export * from "./models/approval"; |
| 13 | export * from "./models/task-run"; | 13 | export * from "./models/task-run"; |
| 14 | export * from "./models/sync-state"; | 14 | export * from "./models/sync-state"; |
| 15 | export * from "./models/storage-instance"; | ||
| 15 | export * from "./models/event-envelope"; | 16 | export * from "./models/event-envelope"; |
| 16 | export * from "./models/runtime-tool"; | 17 | export * from "./models/runtime-tool"; |
| 17 | export * from "./models/auth"; | 18 | export * from "./models/auth"; |
| @@ -6,6 +6,7 @@ const permissionDomainActions = { · 5 unmodified lines | |||
| 6 | approvals: ["read", "resolve", "manage"], | 6 | approvals: ["read", "resolve", "manage"], |
| 7 | runs: ["read", "cancel", "manage"], | 7 | runs: ["read", "cancel", "manage"], |
| 8 | credentials: ["read", "write", "manage"], | 8 | credentials: ["read", "write", "manage"], |
| 9 | storage: ["read", "write", "manage"], | ||
| 9 | organizations: ["read", "manage"], | 10 | organizations: ["read", "manage"], |
| 10 | memberships: ["read", "write", "manage"], | 11 | memberships: ["read", "write", "manage"], |
| 11 | workspace: ["read", "manage"], | 12 | workspace: ["read", "manage"], |
| @@ -0,0 +1,38 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TimestampMsSchema } from "../common"; | ||
| 4 | import { | ||
| 5 | StorageDurabilitySchema, | ||
| 6 | StorageInstanceStatusSchema, | ||
| 7 | StorageProviderSchema, | ||
| 8 | StorageScopeTypeSchema, | ||
| 9 | } from "../enums"; | ||
| 10 | import { | ||
| 11 | AccountIdSchema, | ||
| 12 | OrganizationIdSchema, | ||
| 13 | StorageInstanceIdSchema, | ||
| 14 | WorkspaceIdSchema, | ||
| 15 | } from "../ids"; | ||
| 16 | |||
| 17 | export const StorageInstanceSchema = Schema.Struct({ | ||
| 18 | id: StorageInstanceIdSchema, | ||
| 19 | scopeType: StorageScopeTypeSchema, | ||
| 20 | durability: StorageDurabilitySchema, | ||
| 21 | status: StorageInstanceStatusSchema, | ||
| 22 | provider: StorageProviderSchema, | ||
| 23 | backendKey: Schema.String, | ||
| 24 | organizationId: OrganizationIdSchema, | ||
| 25 | workspaceId: Schema.NullOr(WorkspaceIdSchema), | ||
| 26 | accountId: Schema.NullOr(AccountIdSchema), | ||
| 27 | createdByAccountId: Schema.NullOr(AccountIdSchema), | ||
| 28 | purpose: Schema.NullOr(Schema.String), | ||
| 29 | sizeBytes: Schema.NullOr(Schema.Number), | ||
| 30 | fileCount: Schema.NullOr(Schema.Number), | ||
| 31 | createdAt: TimestampMsSchema, | ||
| 32 | updatedAt: TimestampMsSchema, | ||
| 33 | lastSeenAt: TimestampMsSchema, | ||
| 34 | closedAt: Schema.NullOr(TimestampMsSchema), | ||
| 35 | expiresAt: Schema.NullOr(TimestampMsSchema), | ||
| 36 | }); | ||
| 37 | |||
| 38 | export type StorageInstance = typeof StorageInstanceSchema.Type; | ||
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | SchemaVersionSchema, | 9 | SchemaVersionSchema, |
| 10 | SourceCredentialBindingSchema, | 10 | SourceCredentialBindingSchema, |
| 11 | SourceSchema, | 11 | SourceSchema, |
| 12 | StorageInstanceSchema, | ||
| 12 | SyncStateSchema, | 13 | SyncStateSchema, |
| 13 | TaskRunSchema, | 14 | TaskRunSchema, |
| 14 | TimestampMsSchema, | 15 | TimestampMsSchema, |
| @@ -31,6 +32,7 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 16 unmodified lines | |||
| 31 | policies: Schema.Array(PolicySchema), | 32 | policies: Schema.Array(PolicySchema), |
| 32 | approvals: Schema.Array(ApprovalSchema), | 33 | approvals: Schema.Array(ApprovalSchema), |
| 33 | taskRuns: Schema.Array(TaskRunSchema), | 34 | taskRuns: Schema.Array(TaskRunSchema), |
| 35 | storageInstances: Schema.Array(StorageInstanceSchema), | ||
| 34 | syncStates: Schema.Array(SyncStateSchema), | 36 | syncStates: Schema.Array(SyncStateSchema), |
| 35 | }); | 37 | }); |
| 36 | 38 | ||
| @@ -74,6 +74,7 @@ describe("makeLocalStateStore", () => { · 73 unmodified lines | |||
| 74 | policies: [], | 74 | policies: [], |
| 75 | approvals: [], | 75 | approvals: [], |
| 76 | taskRuns: [], | 76 | taskRuns: [], |
| 77 | storageInstances: [], | ||
| 77 | syncStates: [], | 78 | syncStates: [], |
| 78 | }); | 79 | }); |
| 79 | 80 | ||