Unified · Split
| @@ -17,9 +17,6 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 16 unmodified lines | ||
| 17 | 17 | # When enabled, local Bun execution is allowed. |
| 18 | 18 | # Leave unset in production to default to Cloudflare runtime. |
| 19 | 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 | 21 | # ── Agent storage backend (optional) ── |
| 25 | 22 | # Default: agentfs-local |
| @@ -34,10 +31,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 8 unmodified lines | ||
| 34 | 31 | # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config. |
| 35 | 32 | |
| 36 | 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 | 36 | # WORKOS_CLIENT_ID=client_... |
| 38 | 37 | # WORKOS_API_KEY=sk_test_... |
| 39 | 38 | # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32> |
| 40 | 39 | # WORKOS_WEBHOOK_SECRET=... |
| 40 | +# WORKOS_FORCE_ENABLED=1 | |
| 41 | 41 | |
| 42 | 42 | # ── Anonymous JWT Auth (optional — enables anonymous bearer auth via Convex Auth) ── |
| 43 | 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 | 11 | import type * as controlPlane from "../controlPlane.js"; |
| 12 | 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 | 15 | import type * as control_plane_errors from "../control_plane/errors.js"; |
| 14 | 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 | 21 | import type * as control_plane_service from "../control_plane/service.js"; |
| 16 | 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 | 26 | import type * as credential_resolver from "../credential_resolver.js"; |
| 18 | 27 | import type * as executor from "../executor.js"; |
| 19 | 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 | 31 | import type * as runtimeCallbacks from "../runtimeCallbacks.js"; |
| 23 | 32 | import type * as runtime_adapter from "../runtime_adapter.js"; |
| 24 | 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 | 37 | import type { |
| 27 | 38 | ApiFromModules, |
| @@ -32,10 +43,19 @@ import type { · 4 unmodified lines | ||
| 32 | 43 | declare const fullApi: ApiFromModules<{ |
| 33 | 44 | controlPlane: typeof controlPlane; |
| 34 | 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 | 48 | "control_plane/errors": typeof control_plane_errors; |
| 36 | 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 | 54 | "control_plane/service": typeof control_plane_service; |
| 38 | 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 | 59 | credential_resolver: typeof credential_resolver; |
| 40 | 60 | executor: typeof executor; |
| 41 | 61 | http: typeof http; |
| @@ -44,6 +64,8 @@ declare const fullApi: ApiFromModules<{ · 2 unmodified lines | ||
| 44 | 64 | runtimeCallbacks: typeof runtimeCallbacks; |
| 45 | 65 | runtime_adapter: typeof runtime_adapter; |
| 46 | 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 | 2 | listApprovals as listApprovalsImpl, |
| 3 | 3 | resolveApproval as resolveApprovalImpl, |
| 4 | 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 | 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 | 20 | import { |
| 7 | 21 | listSources as listSourcesImpl, |
| 8 | 22 | removeSource as removeSourceImpl, |
| 9 | 23 | upsertSource as upsertSourceImpl, |
| 10 | 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 | 44 | export const listSources = listSourcesImpl; |
| 13 | 45 | export const upsertSource = upsertSourceImpl; |
| 14 | 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 | 67 | export const listApprovals = listApprovalsImpl; |
| 16 | 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 | 102 | export const ensureWorkspaceForActor = internalMutation({ |
| 75 | 103 | args: { |
| 76 | 104 | workspaceId: v.string(), |
| @@ -139,6 +167,44 @@ const runQueryEffect = <A>( · 62 unmodified lines | ||
| 139 | 167 | |
| 140 | 168 | export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => |
| 141 | 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 | 208 | resolveWorkspaceActor: (input) => |
| 143 | 209 | Effect.gen(function* () { |
| 144 | 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 | 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 | 219 | listApprovals: (workspaceId) => |
| 42 | 220 | Effect.tryPromise({ |
| 43 | 221 | try: () => |
| @@ -3,7 +3,8 @@ import { SourceSchema, type Source } from "@executor-v2/schema"; · 2 unmodified lines | ||
| 3 | 3 | import { v } from "convex/values"; |
| 4 | 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 | 9 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); |
| 9 | 10 | |
| @@ -109,10 +110,63 @@ export const upsertSource = mutation({ · 99 unmodified lines | ||
| 109 | 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 | 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 | 170 | export const removeSource = mutation({ |
| 117 | 171 | args: { |
| 118 | 172 | workspaceId: v.string(), |
| @@ -133,6 +187,17 @@ export const removeSource = mutation({ · 14 unmodified lines | ||
| 133 | 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 | 201 | await ctx.db.delete(existing._id); |
| 137 | 202 | |
| 138 | 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 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { extractOpenApiManifest } from "@executor-v2/management-api"; | |
| 2 | 3 | import { convexTest } from "convex-test"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | |
| @@ -16,6 +17,10 @@ const setup = () => · 11 unmodified lines | ||
| 16 | 17 | "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), |
| 17 | 18 | "./source_tool_registry.ts": () => import("./source_tool_registry"), |
| 18 | 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 | 24 | "./controlPlane.ts": () => import("./controlPlane"), |
| 20 | 25 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 21 | 26 | }); |
| @@ -57,6 +62,8 @@ describe("Convex executor and control-plane", () => { · 35 unmodified lines | ||
| 57 | 62 | name: string; |
| 58 | 63 | }; |
| 59 | 64 | |
| 65 | + yield* Effect.tryPromise(() => t.finishInProgressScheduledFunctions()); | |
| 66 | + | |
| 60 | 67 | expect(added.id).toBe("src_1"); |
| 61 | 68 | expect(added.workspaceId).toBe("ws_1"); |
| 62 | 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 | 552 | it.effect("persists approval state for runtime tool calls", () => |
| 97 | 553 | Effect.gen(function* () { |
| 98 | 554 | const t = setup(); |
| @@ -9,11 +9,70 @@ import { · 8 unmodified lines | ||
| 9 | 9 | ProfileSchema, |
| 10 | 10 | SourceCredentialBindingSchema, |
| 11 | 11 | SourceSchema, |
| 12 | + StorageInstanceSchema, | |
| 12 | 13 | SyncStateSchema, |
| 13 | 14 | TaskRunSchema, |
| 14 | − ToolArtifactSchema, | |
| 15 | 15 | WorkspaceSchema, |
| 16 | 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 | 77 | export const executorConfectSchema = defineSchema({ |
| 19 | 78 | profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]), |
| @@ -27,14 +86,23 @@ export const executorConfectSchema = defineSchema({ · 7 unmodified lines | ||
| 27 | 86 | .index("by_organizationId_accountId", ["organizationId", "accountId"]), |
| 28 | 87 | workspaces: defineTable(WorkspaceSchema) |
| 29 | 88 | .index("by_domainId", ["id"]) |
| 30 | − .index("by_organizationId", ["organizationId"]), | |
| 89 | + .index("by_organizationId", ["organizationId"]) | |
| 90 | + .index("by_createdByAccountId", ["createdByAccountId"]), | |
| 31 | 91 | sources: defineTable(SourceSchema) |
| 32 | 92 | .index("by_domainId", ["id"]) |
| 33 | 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 | 102 | .index("by_domainId", ["id"]) |
| 36 | 103 | .index("by_workspaceId", ["workspaceId"]) |
| 37 | − .index("by_sourceId", ["sourceId"]), | |
| 104 | + .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"]) | |
| 105 | + .index("by_artifactId", ["artifactId"]), | |
| 38 | 106 | sourceCredentialBindings: defineTable(SourceCredentialBindingSchema) |
| 39 | 107 | .index("by_domainId", ["id"]) |
| 40 | 108 | .index("by_credentialId", ["credentialId"]) |
| @@ -51,6 +119,22 @@ export const executorConfectSchema = defineSchema({ · 10 unmodified lines | ||
| 51 | 119 | policies: defineTable(PolicySchema) |
| 52 | 120 | .index("by_domainId", ["id"]) |
| 53 | 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 | 138 | approvals: defineTable(ApprovalSchema) |
| 55 | 139 | .index("by_domainId", ["id"]) |
| 56 | 140 | .index("by_workspaceId", ["workspaceId"]) |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | } from "@executor-v2/persistence-ports"; |
| 17 | 17 | import { |
| 18 | 18 | ApprovalSchema, |
| 19 | + OpenApiToolManifestSchema, | |
| 19 | 20 | SourceSchema, |
| 20 | 21 | ToolArtifactSchema, |
| 21 | 22 | type Approval, |
| @@ -37,9 +38,14 @@ const runtimeInternal = internal as any; · 15 unmodified lines | ||
| 37 | 38 | |
| 38 | 39 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); |
| 39 | 40 | const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema); |
| 41 | +const decodeOpenApiToolManifest = Schema.decodeUnknownSync(OpenApiToolManifestSchema); | |
| 42 | +const encodeOpenApiToolManifestJson = Schema.encodeSync( | |
| 43 | + Schema.parseJson(OpenApiToolManifestSchema), | |
| 44 | +); | |
| 40 | 45 | const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema); |
| 41 | 46 | |
| 42 | 47 | const defaultPendingRetryAfterMs = 1_000; |
| 48 | +const openApiExtractorVersion = "openapi_v2"; | |
| 43 | 49 | |
| 44 | 50 | const readBooleanFlag = (value: string | undefined): boolean => { |
| 45 | 51 | const normalized = value?.trim().toLowerCase(); |
| @@ -100,16 +106,6 @@ const unsupportedSourceStoreMutation = ( · 54 unmodified lines | ||
| 100 | 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 | 109 | const sourceStoreQueryError = ( |
| 114 | 110 | operation: string, |
| 115 | 111 | cause: unknown, |
| @@ -136,6 +132,19 @@ const toolArtifactStoreQueryError = ( · 20 unmodified lines | ||
| 136 | 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 | 148 | export const listSourcesForWorkspace = query({ |
| 140 | 149 | args: { |
| 141 | 150 | workspaceId: v.string(), |
| @@ -158,19 +167,130 @@ export const getToolArtifactBySource = query({ · 16 unmodified lines | ||
| 158 | 167 | sourceId: v.string(), |
| 159 | 168 | }, |
| 160 | 169 | handler: async (ctx, args): Promise<ToolArtifact | null> => { |
| 161 | − const rows = await ctx.db | |
| 162 | − .query("toolArtifacts") | |
| 163 | − .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | |
| 164 | − .collect(); | |
| 170 | + const binding = await ctx.db | |
| 171 | + .query("openApiSourceArtifactBindings") | |
| 172 | + .withIndex("by_workspaceId_sourceId", (q) => | |
| 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; | |
| 167 | − if (!record) { | |
| 177 | + if (!binding) { | |
| 168 | 178 | return null; |
| 169 | 179 | } |
| 170 | 180 | |
| 171 | − return decodeToolArtifact( | |
| 172 | − stripConvexSystemFields(record as unknown as Record<string, unknown>), | |
| 173 | − ); | |
| 181 | + const toolRows = await ctx.db | |
| 182 | + .query("openApiArtifactTools") | |
| 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 | 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 | 475 | const createConvexPersistentToolApprovalPolicy = ( |
| @@ -23,7 +23,7 @@ · 22 unmodified lines | ||
| 23 | 23 | }, |
| 24 | 24 | "devDependencies": { |
| 25 | 25 | "@effect/vitest": "catalog:", |
| 26 | − "convex-test": "^0.0.41", | |
| 26 | + "convex-test": "workspace:*", | |
| 27 | 27 | "vitest": "catalog:" |
| 28 | 28 | } |
| 29 | 29 | } |
| @@ -3,15 +3,38 @@ import { · 2 unmodified lines | ||
| 3 | 3 | deriveWorkspaceMembershipsForPrincipal, |
| 4 | 4 | requirePrincipalFromHeaders, |
| 5 | 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 | 11 | import { |
| 8 | 12 | type LocalStateSnapshot, |
| 9 | 13 | type LocalStateStore, |
| 10 | 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 | 20 | import * as Effect from "effect/Effect"; |
| 13 | 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 | 38 | const organizationMembershipsForAccount = ( |
| 16 | 39 | snapshot: LocalStateSnapshot, |
| 17 | 40 | accountId: OrganizationMembership["accountId"], |
| @@ -20,8 +43,61 @@ const organizationMembershipsForAccount = ( · 2 unmodified lines | ||
| 20 | 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 | 98 | export const PmActorLive = (localStateStore: LocalStateStore) => |
| 24 | 99 | ControlPlaneActorResolverLive({ |
| 100 | + resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers), | |
| 25 | 101 | resolveWorkspaceActor: (input) => |
| 26 | 102 | Effect.gen(function* () { |
| 27 | 103 | const principal = yield* requirePrincipalFromHeaders(input.headers); |
| @@ -37,6 +113,10 @@ export const PmActorLive = (localStateStore: LocalStateStore) => · 9 unmodified lines | ||
| 37 | 113 | |
| 38 | 114 | const snapshot = Option.getOrNull(snapshotOption); |
| 39 | 115 | |
| 116 | + if (snapshot === null && localAdminFallbackEnabled) { | |
| 117 | + return makeAllowAllActor(principal); | |
| 118 | + } | |
| 119 | + | |
| 40 | 120 | const organizationMemberships = |
| 41 | 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 | 3 | ControlPlaneService, |
| 4 | 4 | makeControlPlaneService, |
| 5 | 5 | makeControlPlaneSourcesService, |
| 6 | + fetchOpenApiDocument, | |
| 6 | 7 | makeControlPlaneWebHandler, |
| 7 | 8 | makeSourceCatalogService, |
| 8 | 9 | makeSourceManagerService, |
| @@ -34,6 +35,12 @@ import { · 25 unmodified lines | ||
| 34 | 35 | createPmPersistentToolApprovalPolicy, |
| 35 | 36 | } from "./approvals-service"; |
| 36 | 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 | 44 | import { createPmMcpHandler } from "./mcp-handler"; |
| 38 | 45 | import { createPmExecuteRuntimeRun } from "./runtime-execution-port"; |
| 39 | 46 | import { createPmToolCallHttpHandler } from "./tool-call-handler"; |
| @@ -113,14 +120,7 @@ const sourcesService = { · 73 unmodified lines | ||
| 113 | 120 | } |
| 114 | 121 | |
| 115 | 122 | const openApiSpecResult = yield* Effect.tryPromise({ |
| 116 | − try: async () => { | |
| 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 | − }, | |
| 123 | + try: () => fetchOpenApiDocument(source.endpoint), | |
| 124 | 124 | catch: (cause) => String(cause), |
| 125 | 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 | 150 | const approvalsService = createPmApprovalsService(localStateStore); |
| 143 | 151 | const controlPlaneService = makeControlPlaneService({ |
| 144 | 152 | sources: sourcesService, |
| 153 | + credentials: credentialsService, | |
| 154 | + policies: policiesService, | |
| 155 | + organizations: organizationsService, | |
| 156 | + workspaces: workspacesService, | |
| 157 | + tools: toolsService, | |
| 158 | + storage: storageService, | |
| 145 | 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 | 33 | policies: [], |
| 34 | 34 | approvals: [], |
| 35 | 35 | taskRuns: [], |
| 36 | + storageInstances: [], | |
| 36 | 37 | syncStates: [], |
| 37 | 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 | 2 | import { withAuth } from "@workos-inc/authkit-nextjs"; |
| 3 | 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 | 7 | const controlPlaneUpstream = |
| 8 | 8 | process.env.CONTROL_PLANE_UPSTREAM_URL ?? "http://127.0.0.1:8787"; |
| @@ -217,12 +217,6 @@ const normalizeSuccessResponse = async ( · 208 unmodified lines | ||
| 217 | 217 | upstreamResponse: Response, |
| 218 | 218 | ): Promise<Response> => { |
| 219 | 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 | 220 | const payload = await upstreamResponse.arrayBuffer(); |
| 227 | 221 | |
| 228 | 222 | const headers = new Headers(); |
| @@ -241,14 +235,22 @@ const proxyControlPlaneRequest = async ( · 12 unmodified lines | ||
| 241 | 235 | path: ReadonlyArray<string>, |
| 242 | 236 | ): Promise<Response> => { |
| 243 | 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 | 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 | 254 | if (principalAccountId === null) { |
| 253 | 255 | return unauthorizedResponse(); |
| 254 | 256 | } |
| @@ -1,216 +1,61 @@ | ||
| 1 | −:root { | |
| 2 | − --bg: #f2f0ea; | |
| 3 | − --panel: #fffdf8; | |
| 4 | − --ink: #1b1a16; | |
| 5 | − --muted: #6b675d; | |
| 6 | − --accent: #0a7f64; | |
| 7 | − --accent-ink: #f4fffc; | |
| 8 | − --border: rgba(27, 26, 22, 0.14); | |
| 9 | − --danger: #a22a2a; | |
| 10 | −} | |
| 11 | − | |
| 12 | −* { | |
| 13 | − box-sizing: border-box; | |
| 14 | −} | |
| 15 | − | |
| 16 | −html, | |
| 17 | −body { | |
| 18 | − margin: 0; | |
| 19 | − padding: 0; | |
| 20 | − min-height: 100%; | |
| 21 | −} | |
| 22 | − | |
| 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; | |
| 1 | +@import "tailwindcss"; | |
| 2 | +@import "tw-animate-css"; | |
| 3 | + | |
| 4 | +@theme inline { | |
| 5 | + --font-sans: var(--font-body), "IBM Plex Sans", sans-serif; | |
| 6 | + --font-display: var(--font-display), "Fraunces", serif; | |
| 7 | + --color-background: var(--background); | |
| 8 | + --color-foreground: var(--foreground); | |
| 9 | + --color-card: var(--card); | |
| 10 | + --color-card-foreground: var(--card-foreground); | |
| 11 | + --color-primary: var(--primary); | |
| 12 | + --color-primary-foreground: var(--primary-foreground); | |
| 13 | + --color-secondary: var(--secondary); | |
| 14 | + --color-secondary-foreground: var(--secondary-foreground); | |
| 15 | + --color-muted: var(--muted); | |
| 16 | + --color-muted-foreground: var(--muted-foreground); | |
| 17 | + --color-accent: var(--accent); | |
| 18 | + --color-accent-foreground: var(--accent-foreground); | |
| 19 | + --color-destructive: var(--destructive); | |
| 20 | + --color-border: var(--border); | |
| 21 | + --color-input: var(--input); | |
| 22 | + --color-ring: var(--ring); | |
| 104 | 23 | } |
| 105 | 24 | |
| 106 | −.field { | |
| 107 | − margin-bottom: 0.95rem; | |
| 108 | −} | |
| 109 | − | |
| 110 | −label { | |
| 111 | − display: block; | |
| 112 | − margin-bottom: 0.4rem; | |
| 113 | − color: var(--muted); | |
| 114 | − font-size: 0.9rem; | |
| 115 | −} | |
| 116 | − | |
| 117 | −input, | |
| 118 | −select, | |
| 119 | −button { | |
| 120 | − width: 100%; | |
| 121 | − border-radius: 10px; | |
| 122 | − border: 1px solid var(--border); | |
| 123 | − font: inherit; | |
| 124 | −} | |
| 125 | − | |
| 126 | −input, | |
| 127 | −select { | |
| 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; | |
| 25 | +:root { | |
| 26 | + --background: oklch(0.986 0.01 92); | |
| 27 | + --foreground: oklch(0.22 0.01 96); | |
| 28 | + --card: oklch(0.995 0.004 95); | |
| 29 | + --card-foreground: oklch(0.22 0.01 96); | |
| 30 | + --primary: oklch(0.51 0.13 170); | |
| 31 | + --primary-foreground: oklch(0.98 0.004 120); | |
| 32 | + --secondary: oklch(0.95 0.008 85); | |
| 33 | + --secondary-foreground: oklch(0.32 0.01 92); | |
| 34 | + --muted: oklch(0.95 0.008 90); | |
| 35 | + --muted-foreground: oklch(0.48 0.015 92); | |
| 36 | + --accent: oklch(0.93 0.01 160); | |
| 37 | + --accent-foreground: oklch(0.26 0.02 165); | |
| 38 | + --destructive: oklch(0.56 0.2 25); | |
| 39 | + --border: oklch(0.88 0.01 92); | |
| 40 | + --input: oklch(0.88 0.01 92); | |
| 41 | + --ring: oklch(0.51 0.13 170); | |
| 42 | +} | |
| 43 | + | |
| 44 | +@layer base { | |
| 45 | + * { | |
| 46 | + @apply border-border; | |
| 203 | 47 | } |
| 204 | 48 | |
| 205 | − .card + .card { | |
| 206 | − border-left: 0; | |
| 207 | − border-top: 1px solid var(--border); | |
| 49 | + html, | |
| 50 | + body { | |
| 51 | + min-height: 100%; | |
| 208 | 52 | } |
| 209 | −} | |
| 210 | 53 | |
| 211 | −@media (max-width: 640px) { | |
| 212 | − .hero-top-row { | |
| 213 | − flex-direction: column; | |
| 214 | − align-items: flex-start; | |
| 54 | + body { | |
| 55 | + @apply font-sans text-foreground antialiased; | |
| 56 | + background: | |
| 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; |
| 4 | −import type { UpsertSourcePayload } from "@executor-v2/management-api"; | |
| 5 | −import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema"; | |
| 6 | −import { useState, type FormEvent } from "react"; | |
| 4 | +import type { | |
| 5 | + ListStorageKvResult, | |
| 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 | 28 | import { |
| 29 | + approvalsByWorkspace, | |
| 30 | + approvalsResultByWorkspace, | |
| 31 | + closeStorageInstance, | |
| 32 | + credentialBindingsByWorkspace, | |
| 33 | + credentialBindingsResultByWorkspace, | |
| 34 | + listStorageDirectory, | |
| 35 | + listStorageKv, | |
| 36 | + openStorageInstance, | |
| 37 | + optimisticRemovePolicy, | |
| 9 | 38 | optimisticRemoveSources, |
| 39 | + optimisticResolveApproval, | |
| 10 | 40 | optimisticSourcesByWorkspace, |
| 41 | + optimisticUpsertPolicy, | |
| 11 | 42 | optimisticUpsertSources, |
| 43 | + organizationsResult, | |
| 44 | + organizationsState, | |
| 45 | + policiesByWorkspace, | |
| 46 | + policiesResultByWorkspace, | |
| 47 | + queryStorageSql, | |
| 48 | + readStorageFile, | |
| 49 | + removeCredentialBinding, | |
| 50 | + removePolicy, | |
| 12 | 51 | removeSource, |
| 52 | + removeStorageInstance, | |
| 53 | + resolveApproval, | |
| 13 | 54 | sourcesByWorkspace, |
| 14 | 55 | sourcesPendingByWorkspace, |
| 15 | 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 | 78 | upsertSource, |
| 79 | + upsertWorkspace, | |
| 80 | + workspaceToolsByWorkspace, | |
| 81 | + workspaceToolsResultByWorkspace, | |
| 82 | + workspacesResult, | |
| 83 | + workspacesState, | |
| 17 | 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> = [ | |
| 20 | − "openapi", | |
| 21 | − "mcp", | |
| 22 | − "graphql", | |
| 23 | − "internal", | |
| 24 | −]; | |
| 132 | +const defaultFormState = (): LegacySourceFormState => ({ | |
| 133 | + name: "", | |
| 134 | + type: "openapi", | |
| 135 | + endpoint: "", | |
| 136 | + baseUrl: "", | |
| 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 | 154 | type PageProps = { |
| 27 | 155 | authEnabled: boolean; |
| 28 | 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 | 207 | const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { |
| 208 | + const [activeTab, setActiveTab] = useState<ConsoleTab>("sources"); | |
| 32 | 209 | const [workspaceIdInput, setWorkspaceIdInput] = useState(initialWorkspaceId); |
| 33 | − const [name, setName] = useState("Weather API"); | |
| 34 | − const [kind, setKind] = useState<SourceKind>("openapi"); | |
| 35 | − const [endpoint, setEndpoint] = useState("https://example.com/openapi.json"); | |
| 210 | + const [formState, setFormState] = useState<LegacySourceFormState>(() => | |
| 211 | + defaultFormState(), | |
| 212 | + ); | |
| 213 | + const [searchQuery, setSearchQuery] = useState(""); | |
| 36 | 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 | 306 | const workspaceId = workspaceIdInput as WorkspaceId; |
| 39 | 307 | |
| @@ -41,35 +309,478 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 1 unmodified line | ||
| 41 | 309 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); |
| 42 | 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 | 338 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); |
| 45 | 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 | 574 | const handleWorkspaceChange = (value: string) => { |
| 49 | 575 | setWorkspaceIdInput(value); |
| 50 | 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 | 767 | event.preventDefault(); |
| 55 | 768 | |
| 56 | 769 | if (sourcesPending) { |
| 57 | 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 = { | |
| 63 | − id: sourceId, | |
| 64 | − name, | |
| 65 | − kind, | |
| 66 | − endpoint, | |
| 67 | − enabled: true, | |
| 68 | − configJson: "{}", | |
| 69 | − status: "draft", | |
| 70 | − sourceHash: null, | |
| 71 | − lastError: null, | |
| 72 | − }; | |
| 778 | + const sourceId = formState.id ?? (createLocalId("src_") as SourceId); | |
| 779 | + const payload = upsertPayloadFromForm({ | |
| 780 | + workspaceId, | |
| 781 | + form: formState, | |
| 782 | + sourceId, | |
| 783 | + }); | |
| 73 | 784 | |
| 74 | 785 | const previousSources = sources.items; |
| 75 | 786 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); |
| @@ -87,7 +798,12 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 11 unmodified lines | ||
| 87 | 798 | payload, |
| 88 | 799 | }) |
| 89 | 800 | .then(() => { |
| 90 | − setStatusText("Source saved."); | |
| 801 | + setStatusText( | |
| 802 | + isEditing | |
| 803 | + ? `Updated ${formState.name.trim()}.` | |
| 804 | + : `Saved ${formState.name.trim()}.`, | |
| 805 | + ); | |
| 806 | + resetForm(); | |
| 91 | 807 | refreshSources(); |
| 92 | 808 | }) |
| 93 | 809 | .catch(() => { |
| @@ -118,6 +834,9 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 24 unmodified lines | ||
| 118 | 834 | }) |
| 119 | 835 | .then(() => { |
| 120 | 836 | setStatusText("Source removed."); |
| 837 | + if (formState.id === sourceId) { | |
| 838 | + resetForm(); | |
| 839 | + } | |
| 121 | 840 | refreshSources(); |
| 122 | 841 | }) |
| 123 | 842 | .catch(() => { |
| @@ -127,122 +846,2184 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 3 unmodified lines | ||
| 127 | 846 | }); |
| 128 | 847 | }; |
| 129 | 848 | |
| 130 | − return ( | |
| 131 | − <main> | |
| 132 | − <section className="shell"> | |
| 133 | − <header className="hero"> | |
| 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> | |
| 849 | + const handleRefreshTools = async () => { | |
| 850 | + if (sourcesPending || toolsRefreshPending) { | |
| 851 | + return; | |
| 852 | + } | |
| 161 | 853 | |
| 162 | − <div className="field"> | |
| 163 | − <label htmlFor="source-name">Name</label> | |
| 164 | − <input | |
| 165 | − id="source-name" | |
| 166 | − value={name} | |
| 167 | − onChange={(event) => setName(event.target.value)} | |
| 168 | − required | |
| 169 | − /> | |
| 170 | − </div> | |
| 854 | + const openApiSources = sourceItems.filter((source) => source.type === "openapi"); | |
| 855 | + if (openApiSources.length === 0) { | |
| 856 | + setStatusText("No OpenAPI sources available to refresh."); | |
| 857 | + refreshWorkspaceTools(); | |
| 858 | + return; | |
| 859 | + } | |
| 171 | 860 | |
| 172 | − <div className="field"> | |
| 173 | − <label htmlFor="source-kind">Kind</label> | |
| 174 | − <select | |
| 175 | − id="source-kind" | |
| 176 | − value={kind} | |
| 177 | − onChange={(event) => setKind(event.target.value as SourceKind)} | |
| 178 | − > | |
| 179 | − {kindOptions.map((option) => ( | |
| 180 | − <option key={option} value={option}> | |
| 181 | − {option} | |
| 182 | − </option> | |
| 183 | − ))} | |
| 184 | − </select> | |
| 185 | − </div> | |
| 861 | + setToolsRefreshPending(true); | |
| 862 | + setStatusText( | |
| 863 | + `Refreshing tools for ${openApiSources.length} OpenAPI source${ | |
| 864 | + openApiSources.length === 1 ? "" : "s" | |
| 865 | + }...`, | |
| 866 | + ); | |
| 186 | 867 | |
| 187 | − <div className="field"> | |
| 188 | − <label htmlFor="source-endpoint">Endpoint</label> | |
| 189 | − <input | |
| 190 | − id="source-endpoint" | |
| 191 | − value={endpoint} | |
| 192 | − onChange={(event) => setEndpoint(event.target.value)} | |
| 193 | − required | |
| 194 | − /> | |
| 195 | − </div> | |
| 868 | + try { | |
| 869 | + await Promise.all( | |
| 870 | + openApiSources.map((source) => | |
| 871 | + runUpsertSource({ | |
| 872 | + path: { workspaceId }, | |
| 873 | + payload: upsertPayloadFromForm({ | |
| 874 | + workspaceId, | |
| 875 | + sourceId: source.id, | |
| 876 | + form: formStateFromSource(source), | |
| 877 | + }), | |
| 878 | + }), | |
| 879 | + ), | |
| 880 | + ); | |
| 196 | 881 | |
| 197 | − <button className="primary" type="submit" disabled={sourcesPending}> | |
| 198 | − {sourcesPending ? "Saving..." : "Save Source"} | |
| 199 | − </button> | |
| 200 | − </form> | |
| 882 | + setStatusText( | |
| 883 | + `Refreshed tools for ${openApiSources.length} OpenAPI source${ | |
| 884 | + openApiSources.length === 1 ? "" : "s" | |
| 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"> | |
| 206 | − <h2>Sources</h2> | |
| 897 | + const handleEditCredential = (credentialBindingId: SourceCredentialBinding["id"]) => { | |
| 898 | + const binding = credentialItems.find((item) => item.id === credentialBindingId); | |
| 899 | + if (!binding) { | |
| 900 | + return; | |
| 901 | + } | |
| 207 | 902 | |
| 208 | − {sources.state === "loading" ? ( | |
| 209 | − <p className="status">Loading sources...</p> | |
| 210 | − ) : null} | |
| 903 | + setCredentialEditingId(binding.id); | |
| 904 | + setCredentialSourceKey(binding.sourceKey); | |
| 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" ? ( | |
| 213 | − <p className="status error">{sources.message}</p> | |
| 214 | − ) : null} | |
| 915 | + const handleCancelCredentialEdit = () => { | |
| 916 | + setCredentialEditingId(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 ? ( | |
| 217 | − <p className="status">No sources yet in this workspace.</p> | |
| 218 | − ) : null} | |
| 928 | + const handleUpsertCredential = (event: FormEvent<HTMLFormElement>) => { | |
| 929 | + event.preventDefault(); | |
| 219 | 930 | |
| 220 | − <div className="list"> | |
| 221 | − {sources.items.map((source) => ( | |
| 222 | − <article className="list-item" key={source.id}> | |
| 223 | − <header> | |
| 224 | − <div> | |
| 225 | − <strong>{source.name}</strong> | |
| 226 | − <div className="meta"> | |
| 227 | − {source.kind} · {source.status} | |
| 228 | − </div> | |
| 229 | − <div className="meta">{source.endpoint}</div> | |
| 230 | − </div> | |
| 231 | − <button | |
| 232 | − className="danger" | |
| 233 | − type="button" | |
| 234 | − onClick={() => handleRemoveSource(source.id)} | |
| 235 | − disabled={sourcesPending} | |
| 236 | − > | |
| 237 | − Remove | |
| 238 | − </button> | |
| 239 | − </header> | |
| 240 | − </article> | |
| 241 | − ))} | |
| 242 | − </div> | |
| 243 | − </section> | |
| 244 | − </div> | |
| 245 | − </section> | |
| 931 | + if (credentialBusyId !== null) { | |
| 932 | + return; | |
| 933 | + } | |
| 934 | + | |
| 935 | + const sourceKey = credentialSourceKey.trim(); | |
| 936 | + const credentialId = credentialIdInput.trim(); | |
| 937 | + const secretRef = credentialSecretRef.trim(); | |
| 938 | + const accountId = credentialAccountId.trim(); | |
| 939 | + | |
| 940 | + if (sourceKey.length === 0 || credentialId.length === 0 || secretRef.length === 0) { | |
| 941 | + setCredentialStatusText("Source key, credential id, and secret ref are required."); | |
| 942 | + return; | |
| 943 | + } | |
| 944 | + | |
| 945 | + if (credentialScopeType === "account" && accountId.length === 0) { | |
| 946 | + setCredentialStatusText("Account scope credentials require account id."); | |
| 947 | + return; | |
| 948 | + } | |
| 949 | + | |
| 950 | + const requestId = | |
| 951 | + credentialEditingId | |
| 952 | + ?? (createLocalId("credential_binding_") as SourceCredentialBinding["id"]); | |
| 953 | + | |
| 954 | + setCredentialBusyId(requestId); | |
| 955 | + | |
| 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 | 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 | 1 | import { Atom, Result } from "@effect-atom/atom"; |
| 2 | −import type { UpsertSourcePayload } from "@executor-v2/management-api"; | |
| 3 | −import type { Source, SourceId, WorkspaceId } from "@executor-v2/schema"; | |
| 2 | +import type { ResolveApprovalPayload } from "@executor-v2/management-api/approvals/api"; | |
| 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 | 50 | import * as Cause from "effect/Cause"; |
| 5 | 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 { | |
| 2 | − ControlPlaneAuthHeaders, | |
| 3 | − createControlPlaneAtomClient, | |
| 4 | −} from "@executor-v2/management-api"; | |
| 1 | +import { createControlPlaneAtomClient } from "@executor-v2/management-api/client"; | |
| 2 | +import { ControlPlaneAuthHeaders } from "@executor-v2/management-api/auth/principal"; | |
| 5 | 3 | |
| 6 | 4 | const controlPlaneBaseUrl = |
| 7 | 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 | 3 | return candidate && candidate.length > 0 ? candidate : undefined; |
| 4 | 4 | }; |
| 5 | 5 | |
| 6 | −export const isWorkosEnabled = (): boolean => | |
| 7 | − Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY)); | |
| 6 | +const isTruthy = (value: string | undefined): boolean => { | |
| 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 | 40 | export const externalOriginFromRequest = (request: Request): string => { |
| 10 | 41 | const forwardedHost = trim(request.headers.get("x-forwarded-host") ?? undefined); |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | − "dev": "next dev --port 4312", | |
| 6 | + "dev": "next dev --turbopack --port 4312", | |
| 7 | 7 | "build": "next build", |
| 8 | 8 | "start": "next start --port 4312", |
| 9 | 9 | "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json" |
| @@ -14,13 +14,20 @@ · 4 unmodified lines | ||
| 14 | 14 | "@executor-v2/management-api": "workspace:*", |
| 15 | 15 | "@executor-v2/schema": "workspace:*", |
| 16 | 16 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 17 | + "class-variance-authority": "^0.7.1", | |
| 18 | + "clsx": "^2.1.1", | |
| 17 | 19 | "effect": "catalog:", |
| 18 | 20 | "next": "^15.4.6", |
| 19 | 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 | 25 | "devDependencies": { |
| 26 | + "@tailwindcss/postcss": "^4.1.12", | |
| 23 | 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 | 36 | "devDependencies": { |
| 37 | 37 | "@effect/vitest": "catalog:", |
| 38 | − "convex-test": "^0.0.41", | |
| 38 | + "convex-test": "workspace:*", | |
| 39 | 39 | "vitest": "catalog:", |
| 40 | 40 | }, |
| 41 | 41 | }, |
| @@ -69,14 +69,21 @@ · 27 unmodified lines | ||
| 69 | 69 | "@executor-v2/management-api": "workspace:*", |
| 70 | 70 | "@executor-v2/schema": "workspace:*", |
| 71 | 71 | "@workos-inc/authkit-nextjs": "^2.13.0", |
| 72 | + "class-variance-authority": "^0.7.1", | |
| 73 | + "clsx": "^2.1.1", | |
| 72 | 74 | "effect": "catalog:", |
| 73 | 75 | "next": "^15.4.6", |
| 74 | 76 | "react": "^19.1.1", |
| 75 | 77 | "react-dom": "^19.1.1", |
| 78 | + "tailwind-merge": "^3.3.1", | |
| 76 | 79 | }, |
| 77 | 80 | "devDependencies": { |
| 81 | + "@tailwindcss/postcss": "^4.1.12", | |
| 78 | 82 | "@types/react": "^19.1.10", |
| 79 | 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 | 89 | "apps/www": { |
| @@ -132,6 +139,13 @@ · 49 unmodified lines | ||
| 132 | 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 | 149 | "packages/domain": { |
| 136 | 150 | "name": "@executor-v2/domain", |
| 137 | 151 | "dependencies": { |
| @@ -167,6 +181,7 @@ · 29 unmodified lines | ||
| 167 | 181 | "@executor-v2/persistence-ports": "workspace:*", |
| 168 | 182 | "@executor-v2/schema": "workspace:*", |
| 169 | 183 | "effect": "catalog:", |
| 184 | + "yaml": "^2.8.1", | |
| 170 | 185 | }, |
| 171 | 186 | "devDependencies": { |
| 172 | 187 | "@effect/vitest": "catalog:", |
| @@ -782,6 +797,36 @@ · 609 unmodified lines | ||
| 782 | 797 | |
| 783 | 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 | 830 | "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], |
| 786 | 831 | |
| 787 | 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 | 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 | 1098 | "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], |
| 1052 | 1099 | |
| 1053 | 1100 | "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], |
| @@ -1084,7 +1131,7 @@ · 30 unmodified lines | ||
| 1084 | 1131 | |
| 1085 | 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 | 1136 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], |
| 1090 | 1137 | |
| @@ -1174,6 +1221,8 @@ · 83 unmodified lines | ||
| 1174 | 1221 | |
| 1175 | 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 | 1226 | "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], |
| 1178 | 1227 | |
| 1179 | 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 | 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 | 1488 | "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], |
| 1416 | 1489 | |
| 1417 | 1490 | "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], |
| @@ -1680,7 +1753,7 @@ · 262 unmodified lines | ||
| 1680 | 1753 | |
| 1681 | 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 | 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 | 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 | 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 | 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 | 2028 | "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], |
| 1950 | 2029 | |
| 1951 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 2254 | "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], |
| 2160 | 2255 | |
| 2161 | 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 | 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 | 2374 | "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], |
| 2278 | 2375 | |
| 2279 | 2376 | "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], |
| @@ -2288,18 +2385,12 @@ · 8 unmodified lines | ||
| 2288 | 2385 | |
| 2289 | 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 | 2388 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], |
| 2296 | 2389 | |
| 2297 | 2390 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], |
| 2298 | 2391 | |
| 2299 | 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 | 2394 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 2304 | 2395 | |
| 2305 | 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 | 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 | 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 | 2418 | "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], |
| 2326 | 2419 | |
| 2327 | 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 | 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 | 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 | 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 | 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 | 2518 | "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| 2426 | 2519 | |
| 2427 | 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 | 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 | 2536 | "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], |
| 2448 | 2537 | |
| 2449 | 2538 | "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], |
| @@ -2560,6 +2649,14 @@ · 110 unmodified lines | ||
| 2560 | 2649 | |
| 2561 | 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 | 2660 | "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], |
| 2564 | 2661 | |
| 2565 | 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 | 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 | 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 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 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 | 19 | "scripts": { |
| 9 | 20 | "typecheck": "bunx tsc --noEmit -p tsconfig.json" |
| @@ -14,7 +25,8 @@ · 4 unmodified lines | ||
| 14 | 25 | "@executor-v2/domain": "workspace:*", |
| 15 | 26 | "@executor-v2/persistence-ports": "workspace:*", |
| 16 | 27 | "@executor-v2/schema": "workspace:*", |
| 17 | − "effect": "catalog:" | |
| 28 | + "effect": "catalog:", | |
| 29 | + "yaml": "^2.8.1" | |
| 18 | 30 | }, |
| 19 | 31 | "devDependencies": { |
| 20 | 32 | "@effect/vitest": "catalog:", |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { HttpApi, OpenApi } from "@effect/platform"; |
| 2 | 2 | |
| 3 | 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 | 8 | import { SourcesApi } from "./sources/api"; |
| 9 | +import { ToolsApi } from "./tools/api"; | |
| 10 | +import { WorkspacesApi } from "./workspaces/api"; | |
| 5 | 11 | |
| 6 | 12 | export { |
| 7 | 13 | ControlPlaneBadRequestError, |
| @@ -17,6 +23,58 @@ export { · 9 unmodified lines | ||
| 17 | 23 | type UpsertSourcePayload, |
| 18 | 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 | 78 | export { |
| 21 | 79 | ResolveApprovalPayloadSchema, |
| 22 | 80 | ResolveApprovalStatusSchema, |
| @@ -25,6 +83,12 @@ export { · 2 unmodified lines | ||
| 25 | 83 | |
| 26 | 84 | export class ControlPlaneApi extends HttpApi.make("controlPlane") |
| 27 | 85 | .add(SourcesApi) |
| 86 | + .add(CredentialsApi) | |
| 87 | + .add(PoliciesApi) | |
| 88 | + .add(OrganizationsApi) | |
| 89 | + .add(WorkspacesApi) | |
| 90 | + .add(ToolsApi) | |
| 91 | + .add(StorageApi) | |
| 28 | 92 | .add(ApprovalsApi) |
| 29 | 93 | .annotateContext( |
| 30 | 94 | OpenApi.annotations({ |
| @@ -7,12 +7,19 @@ import * as PlatformHeaders from "@effect/platform/Headers"; · 6 unmodified lines | ||
| 7 | 7 | import * as Context from "effect/Context"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | 9 | |
| 10 | +export type ResolveActorInput = { | |
| 11 | + headers: PlatformHeaders.Headers; | |
| 12 | +}; | |
| 13 | + | |
| 10 | 14 | export type ResolveWorkspaceActorInput = { |
| 11 | 15 | workspaceId: WorkspaceId; |
| 12 | 16 | headers: PlatformHeaders.Headers; |
| 13 | 17 | }; |
| 14 | 18 | |
| 15 | 19 | export type ControlPlaneActorResolverShape = { |
| 20 | + resolveActor: ( | |
| 21 | + input: ResolveActorInput, | |
| 22 | + ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>; | |
| 16 | 23 | resolveWorkspaceActor: ( |
| 17 | 24 | input: ResolveWorkspaceActorInput, |
| 18 | 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 | 8 | } from "./auth/actor-resolver"; |
| 9 | 9 | import { ControlPlaneService } from "./service"; |
| 10 | 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 | 15 | import { ControlPlaneSourcesLive } from "./sources/http"; |
| 16 | +import { ControlPlaneToolsLive } from "./tools/http"; | |
| 17 | +import { ControlPlaneWorkspacesLive } from "./workspaces/http"; | |
| 12 | 18 | |
| 13 | 19 | export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe( |
| 14 | 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 | 27 | Layer.provide(ControlPlaneApprovalsLive), |
| 16 | 28 | ); |
| 17 | 29 | |
| @@ -1,13 +1,47 @@ | ||
| 1 | 1 | export { |
| 2 | 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 | 14 | RemoveSourceResultSchema, |
| 15 | + RemoveStorageInstanceResultSchema, | |
| 4 | 16 | ResolveApprovalPayloadSchema, |
| 5 | 17 | ResolveApprovalStatusSchema, |
| 18 | + SourceToolSummarySchema, | |
| 19 | + UpsertCredentialBindingPayloadSchema, | |
| 20 | + UpsertOrganizationPayloadSchema, | |
| 21 | + UpsertPolicyPayloadSchema, | |
| 6 | 22 | UpsertSourcePayloadSchema, |
| 23 | + UpsertWorkspacePayloadSchema, | |
| 7 | 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 | 36 | type RemoveSourceResult, |
| 37 | + type RemoveStorageInstanceResult, | |
| 9 | 38 | type ResolveApprovalPayload, |
| 39 | + type SourceToolSummary, | |
| 40 | + type UpsertCredentialBindingPayload, | |
| 41 | + type UpsertOrganizationPayload, | |
| 42 | + type UpsertPolicyPayload, | |
| 10 | 43 | type UpsertSourcePayload, |
| 44 | + type UpsertWorkspacePayload, | |
| 11 | 45 | } from "./api"; |
| 12 | 46 | |
| 13 | 47 | export { |
| @@ -32,6 +66,7 @@ export { · 18 unmodified lines | ||
| 32 | 66 | export { |
| 33 | 67 | ControlPlaneActorResolver, |
| 34 | 68 | type ControlPlaneActorResolverShape, |
| 69 | + type ResolveActorInput, | |
| 35 | 70 | type ResolveWorkspaceActorInput, |
| 36 | 71 | } from "./auth/actor-resolver"; |
| 37 | 72 | |
| @@ -51,6 +86,52 @@ export { · 13 unmodified lines | ||
| 51 | 86 | type UpsertSourceInput, |
| 52 | 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 | 135 | export { |
| 55 | 136 | ControlPlaneApprovalsLive, |
| 56 | 137 | makeControlPlaneApprovalsService, |
| @@ -90,3 +171,6 @@ export { · 33 unmodified lines | ||
| 90 | 171 | type SourceManagerService, |
| 91 | 172 | type ToolManifestDiff, |
| 92 | 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 | 1 | import { |
| 4 | 2 | ToolArtifactStoreError, |
| 5 | 3 | type ToolArtifactStore, |
| @@ -473,8 +471,20 @@ const toStableValue = (value: unknown): unknown => { · 467 unmodified lines | ||
| 473 | 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 | 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 | 489 | const normalizePathForToolId = (pathValue: string): string => |
| 480 | 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 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | |
| 3 | 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 | 8 | import { type ControlPlaneSourcesServiceShape } from "./sources/service"; |
| 9 | +import { type ControlPlaneToolsServiceShape } from "./tools/service"; | |
| 10 | +import { type ControlPlaneWorkspacesServiceShape } from "./workspaces/service"; | |
| 5 | 11 | |
| 6 | 12 | export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape & |
| 13 | + ControlPlaneCredentialsServiceShape & | |
| 14 | + ControlPlanePoliciesServiceShape & | |
| 15 | + ControlPlaneOrganizationsServiceShape & | |
| 16 | + ControlPlaneWorkspacesServiceShape & | |
| 17 | + ControlPlaneToolsServiceShape & | |
| 18 | + ControlPlaneStorageServiceShape & | |
| 7 | 19 | ControlPlaneApprovalsServiceShape; |
| 8 | 20 | |
| 9 | 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 | 26 | export const makeControlPlaneService = (services: { |
| 15 | 27 | sources: ControlPlaneSourcesServiceShape; |
| 28 | + credentials: ControlPlaneCredentialsServiceShape; | |
| 29 | + policies: ControlPlanePoliciesServiceShape; | |
| 30 | + organizations: ControlPlaneOrganizationsServiceShape; | |
| 31 | + workspaces: ControlPlaneWorkspacesServiceShape; | |
| 32 | + tools: ControlPlaneToolsServiceShape; | |
| 33 | + storage: ControlPlaneStorageServiceShape; | |
| 16 | 34 | approvals: ControlPlaneApprovalsServiceShape; |
| 17 | 35 | }): ControlPlaneServiceShape => ({ |
| 18 | 36 | ...services.sources, |
| 37 | + ...services.credentials, | |
| 38 | + ...services.policies, | |
| 39 | + ...services.organizations, | |
| 40 | + ...services.workspaces, | |
| 41 | + ...services.tools, | |
| 42 | + ...services.storage, | |
| 19 | 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 | 18 | "organization", |
| 19 | 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 | 30 | export const OrganizationStatusSchema = Schema.Literal("active", "suspended", "archived"); |
| 22 | 31 | export const OrganizationMemberStatusSchema = Schema.Literal( |
| 23 | 32 | "invited", |
| @@ -36,6 +45,10 @@ export type TaskRunStatus = typeof TaskRunStatusSchema.Type; · 12 unmodified lines | ||
| 36 | 45 | export type CredentialMode = typeof CredentialModeSchema.Type; |
| 37 | 46 | export type CredentialProvider = typeof CredentialProviderSchema.Type; |
| 38 | 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 | 52 | export type OrganizationStatus = typeof OrganizationStatusSchema.Type; |
| 40 | 53 | export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type; |
| 41 | 54 | export type SyncTarget = typeof SyncTargetSchema.Type; |
| @@ -18,6 +18,9 @@ export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); · 17 unmodified lines | ||
| 18 | 18 | export const ApprovalIdSchema = Schema.String.pipe(Schema.brand("ApprovalId")); |
| 19 | 19 | export const TaskRunIdSchema = Schema.String.pipe(Schema.brand("TaskRunId")); |
| 20 | 20 | export const SyncStateIdSchema = Schema.String.pipe(Schema.brand("SyncStateId")); |
| 21 | +export const StorageInstanceIdSchema = Schema.String.pipe( | |
| 22 | + Schema.brand("StorageInstanceId"), | |
| 23 | +); | |
| 21 | 24 | export const EventIdSchema = Schema.String.pipe(Schema.brand("EventId")); |
| 22 | 25 | |
| 23 | 26 | export type AccountId = typeof AccountIdSchema.Type; |
| @@ -34,4 +37,5 @@ export type PolicyId = typeof PolicyIdSchema.Type; · 10 unmodified lines | ||
| 34 | 37 | export type ApprovalId = typeof ApprovalIdSchema.Type; |
| 35 | 38 | export type TaskRunId = typeof TaskRunIdSchema.Type; |
| 36 | 39 | export type SyncStateId = typeof SyncStateIdSchema.Type; |
| 40 | +export type StorageInstanceId = typeof StorageInstanceIdSchema.Type; | |
| 37 | 41 | export type EventId = typeof EventIdSchema.Type; |
| @@ -12,6 +12,7 @@ export * from "./models/policy"; · 11 unmodified lines | ||
| 12 | 12 | export * from "./models/approval"; |
| 13 | 13 | export * from "./models/task-run"; |
| 14 | 14 | export * from "./models/sync-state"; |
| 15 | +export * from "./models/storage-instance"; | |
| 15 | 16 | export * from "./models/event-envelope"; |
| 16 | 17 | export * from "./models/runtime-tool"; |
| 17 | 18 | export * from "./models/auth"; |
| @@ -6,6 +6,7 @@ const permissionDomainActions = { · 5 unmodified lines | ||
| 6 | 6 | approvals: ["read", "resolve", "manage"], |
| 7 | 7 | runs: ["read", "cancel", "manage"], |
| 8 | 8 | credentials: ["read", "write", "manage"], |
| 9 | + storage: ["read", "write", "manage"], | |
| 9 | 10 | organizations: ["read", "manage"], |
| 10 | 11 | memberships: ["read", "write", "manage"], |
| 11 | 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 | 9 | SchemaVersionSchema, |
| 10 | 10 | SourceCredentialBindingSchema, |
| 11 | 11 | SourceSchema, |
| 12 | + StorageInstanceSchema, | |
| 12 | 13 | SyncStateSchema, |
| 13 | 14 | TaskRunSchema, |
| 14 | 15 | TimestampMsSchema, |
| @@ -31,6 +32,7 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 16 unmodified lines | ||
| 31 | 32 | policies: Schema.Array(PolicySchema), |
| 32 | 33 | approvals: Schema.Array(ApprovalSchema), |
| 33 | 34 | taskRuns: Schema.Array(TaskRunSchema), |
| 35 | + storageInstances: Schema.Array(StorageInstanceSchema), | |
| 34 | 36 | syncStates: Schema.Array(SyncStateSchema), |
| 35 | 37 | }); |
| 36 | 38 | |
| @@ -74,6 +74,7 @@ describe("makeLocalStateStore", () => { · 73 unmodified lines | ||
| 74 | 74 | policies: [], |
| 75 | 75 | approvals: [], |
| 76 | 76 | taskRuns: [], |
| 77 | + storageInstances: [], | |
| 77 | 78 | syncStates: [], |
| 78 | 79 | }); |
| 79 | 80 | |