History

migrate a bunch of the old stuff

Rhys Sullivan committed 8 months ago

Commit d31184e1 parent c3b899889 files · +13660 −426Browse files

Unified · Split

89 files
  1. .env.example +3 −3
  2. v2/apps/convex/convex/_generated/api.d.ts +22 −0
  3. v2/apps/convex/convex/controlPlane.ts +52 −0
  4. v2/apps/convex/convex/control_plane/actor.ts +66 −0
  5. v2/apps/convex/convex/control_plane/credentials.ts +231 −0
  6. v2/apps/convex/convex/control_plane/openapi_ingest.ts +113 −0
  7. v2/apps/convex/convex/control_plane/openapi_ingest_mvp.ts +345 −0
  8. v2/apps/convex/convex/control_plane/organizations.ts +88 −0
  9. v2/apps/convex/convex/control_plane/policies.ts +120 −0
  10. v2/apps/convex/convex/control_plane/service.ts +178 −0
  11. v2/apps/convex/convex/control_plane/sources.ts +66 −1
  12. v2/apps/convex/convex/control_plane/storage.ts +1069 −0
  13. v2/apps/convex/convex/control_plane/tools.ts +168 −0
  14. v2/apps/convex/convex/control_plane/workspaces.ts +81 −0
  15. v2/apps/convex/convex/executor.test.ts +456 −0
  16. v2/apps/convex/convex/schema.ts +88 −4
  17. v2/apps/convex/convex/source_tool_registry.ts +147 −20
  18. v2/apps/convex/package.json +1 −1
  19. v2/apps/pm/src/actor.ts +82 −2
  20. v2/apps/pm/src/credentials-service.ts +260 −0
  21. v2/apps/pm/src/main.ts +22 −8
  22. v2/apps/pm/src/organizations-service.ts +142 −0
  23. v2/apps/pm/src/persistent-approval-policy.test.ts +1 −0
  24. v2/apps/pm/src/policies-service.ts +208 −0
  25. v2/apps/pm/src/storage-service.test.ts +161 −0
  26. v2/apps/pm/src/storage-service.ts +940 −0
  27. v2/apps/pm/src/tools-service.ts +179 −0
  28. v2/apps/pm/src/workspaces-service.ts +138 −0
  29. v2/apps/web/app/api/control-plane/[...path]/route.ts +12 −10
  30. v2/apps/web/app/globals.css +53 −208
  31. v2/apps/web/app/page-content.tsx +2914 −133
  32. v2/apps/web/components.json +20 −0
  33. v2/apps/web/components/ui/badge.tsx +29 −0
  34. v2/apps/web/components/ui/button.tsx +40 −0
  35. v2/apps/web/components/ui/card.tsx +41 −0
  36. v2/apps/web/components/ui/input.tsx +16 −0
  37. v2/apps/web/components/ui/select.tsx +17 −0
  38. v2/apps/web/lib/control-plane/atoms.ts +778 −2
  39. v2/apps/web/lib/control-plane/client.ts +2 −4
  40. v2/apps/web/lib/control-plane/legacy-source.ts +266 −0
  41. v2/apps/web/lib/utils.ts +6 −0
  42. v2/apps/web/lib/workos.ts +33 −2
  43. v2/apps/web/package.json +10 −3
  44. v2/apps/web/postcss.config.mjs +7 −0
  45. v2/bun.lock +119 −20
  46. v2/packages/convex-test/compare.d.ts +3 −0
  47. v2/packages/convex-test/compare.js +80 −0
  48. v2/packages/convex-test/index.d.ts +98 −0
  49. v2/packages/convex-test/index.js +1507 −0
  50. v2/packages/convex-test/package.json +21 −0
  51. v2/packages/management-api/package.json +14 −2
  52. v2/packages/management-api/src/api.ts +64 −0
  53. v2/packages/management-api/src/auth/actor-resolver.ts +7 −0
  54. v2/packages/management-api/src/credentials/api.ts +74 −0
  55. v2/packages/management-api/src/credentials/http.ts +146 −0
  56. v2/packages/management-api/src/credentials/index.ts +16 −0
  57. v2/packages/management-api/src/credentials/service.ts +38 −0
  58. v2/packages/management-api/src/http.ts +12 −0
  59. v2/packages/management-api/src/index.ts +84 −0
  60. v2/packages/management-api/src/openapi-document.ts +36 −0
  61. v2/packages/management-api/src/openapi-extraction.ts +13 −3
  62. v2/packages/management-api/src/organizations/api.ts +47 −0
  63. v2/packages/management-api/src/organizations/http.ts +142 −0
  64. v2/packages/management-api/src/organizations/index.ts +12 −0
  65. v2/packages/management-api/src/organizations/service.ts +20 −0
  66. v2/packages/management-api/src/policies/api.ts +62 −0
  67. v2/packages/management-api/src/policies/http.ts +146 −0
  68. v2/packages/management-api/src/policies/index.ts +16 −0
  69. v2/packages/management-api/src/policies/service.ts +31 −0
  70. v2/packages/management-api/src/service.ts +24 −0
  71. v2/packages/management-api/src/storage/api.ts +235 −0
  72. v2/packages/management-api/src/storage/http.ts +265 −0
  73. v2/packages/management-api/src/storage/index.ts +37 −0
  74. v2/packages/management-api/src/storage/service.ts +90 −0
  75. v2/packages/management-api/src/tools/api.ts +53 −0
  76. v2/packages/management-api/src/tools/http.ts +117 −0
  77. v2/packages/management-api/src/tools/index.ts +12 −0
  78. v2/packages/management-api/src/tools/service.ts +23 −0
  79. v2/packages/management-api/src/workspaces/api.ts +45 −0
  80. v2/packages/management-api/src/workspaces/http.ts +188 −0
  81. v2/packages/management-api/src/workspaces/index.ts +12 −0
  82. v2/packages/management-api/src/workspaces/service.ts +20 −0
  83. v2/packages/schema/src/enums.ts +13 −0
  84. v2/packages/schema/src/ids.ts +4 −0
  85. v2/packages/schema/src/index.ts +1 −0
  86. v2/packages/schema/src/models/auth/permission.ts +1 −0
  87. v2/packages/schema/src/models/storage-instance.ts +38 −0
  88. v2/packages/state-local-file/src/local-state-snapshot.ts +2 −0
  89. v2/packages/state-local-file/src/state-store.test.ts +1 −0
.env.example+3−3
@@ -17,9 +17,6 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 16 unmodified lines
1717 # When enabled, local Bun execution is allowed.
1818 # Leave unset in production to default to Cloudflare runtime.
1919 # 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
2320
2421 # ── Agent storage backend (optional) ──
2522 # Default: agentfs-local
@@ -34,10 +31,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 8 unmodified lines
3431 # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.
3532
3633 # ── 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.
3736 # WORKOS_CLIENT_ID=client_...
3837 # WORKOS_API_KEY=sk_test_...
3938 # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>
4039 # WORKOS_WEBHOOK_SECRET=...
40+# WORKOS_FORCE_ENABLED=1
4141
4242 # ── Anonymous JWT Auth (optional — enables anonymous bearer auth via Convex Auth) ──
4343 # Generate an ES256 key pair (PKCS8 private + SPKI public), then set both PEM values.
v2/apps/convex/convex/_generated/api.d.ts+22−0
@@ -10,10 +10,19 @@ · 9 unmodified lines
1010
1111 import type * as controlPlane from "../controlPlane.js";
1212 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";
1315 import type * as control_plane_errors from "../control_plane/errors.js";
1416 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";
1521 import type * as control_plane_service from "../control_plane/service.js";
1622 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";
1726 import type * as credential_resolver from "../credential_resolver.js";
1827 import type * as executor from "../executor.js";
1928 import type * as http from "../http.js";
@@ -22,6 +31,8 @@ import type * as run_executor from "../run_executor.js"; · 2 unmodified lines
2231 import type * as runtimeCallbacks from "../runtimeCallbacks.js";
2332 import type * as runtime_adapter from "../runtime_adapter.js";
2433 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";
2536
2637 import type {
2738 ApiFromModules,
@@ -32,10 +43,19 @@ import type { · 4 unmodified lines
3243 declare const fullApi: ApiFromModules<{
3344 controlPlane: typeof controlPlane;
3445 "control_plane/actor": typeof control_plane_actor;
46+ "control_plane/approvals": typeof control_plane_approvals;
47+ "control_plane/credentials": typeof control_plane_credentials;
3548 "control_plane/errors": typeof control_plane_errors;
3649 "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;
3754 "control_plane/service": typeof control_plane_service;
3855 "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;
3959 credential_resolver: typeof credential_resolver;
4060 executor: typeof executor;
4161 http: typeof http;
@@ -44,6 +64,8 @@ declare const fullApi: ApiFromModules<{ · 2 unmodified lines
4464 runtimeCallbacks: typeof runtimeCallbacks;
4565 runtime_adapter: typeof runtime_adapter;
4666 runtime_execution_port: typeof runtime_execution_port;
67+ source_tool_registry: typeof source_tool_registry;
68+ task_runs: typeof task_runs;
4769 }>;
4870
4971 /**
v2/apps/convex/convex/controlPlane.ts+52−0
@@ -2,16 +2,68 @@ import { · 1 unmodified line
22 listApprovals as listApprovalsImpl,
33 resolveApproval as resolveApprovalImpl,
44 } from "./control_plane/approvals";
5+import {
6+ listCredentialBindings as listCredentialBindingsImpl,
7+ removeCredentialBinding as removeCredentialBindingImpl,
8+ upsertCredentialBinding as upsertCredentialBindingImpl,
9+} from "./control_plane/credentials";
510 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";
620 import {
721 listSources as listSourcesImpl,
822 removeSource as removeSourceImpl,
923 upsertSource as upsertSourceImpl,
1024 } 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";
1143
1244 export const listSources = listSourcesImpl;
1345 export const upsertSource = upsertSourceImpl;
1446 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;
1567 export const listApprovals = listApprovalsImpl;
1668 export const resolveApproval = resolveApprovalImpl;
1769
v2/apps/convex/convex/control_plane/actor.ts+66−0
@@ -71,6 +71,34 @@ export const listOrganizationMembershipsForActor = internalQuery({ · 70 unmodified lines
7171 },
7272 });
7373
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+
74102 export const ensureWorkspaceForActor = internalMutation({
75103 args: {
76104 workspaceId: v.string(),
@@ -139,6 +167,44 @@ const runQueryEffect = <A>( · 62 unmodified lines
139167
140168 export const ConvexControlPlaneActorLive = (ctx: ActionCtx) =>
141169 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+ }),
142208 resolveWorkspaceActor: (input) =>
143209 Effect.gen(function* () {
144210 const principal = yield* requirePrincipalFromHeaders(input.headers);
v2/apps/convex/convex/control_plane/credentials.ts+231−0
@@ -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+});
v2/apps/convex/convex/control_plane/openapi_ingest.ts+113−0
@@ -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+});
v2/apps/convex/convex/control_plane/openapi_ingest_mvp.ts+345−0
@@ -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+});
v2/apps/convex/convex/control_plane/organizations.ts+88−0
@@ -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+});
v2/apps/convex/convex/control_plane/policies.ts+120−0
@@ -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+});
v2/apps/convex/convex/control_plane/service.ts+178−0
@@ -38,6 +38,184 @@ export const makeConvexControlPlaneService = ( · 37 unmodified lines
3838 }),
3939 catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause),
4040 }),
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+ }),
41219 listApprovals: (workspaceId) =>
42220 Effect.tryPromise({
43221 try: () =>
v2/apps/convex/convex/control_plane/sources.ts+66−1
@@ -3,7 +3,8 @@ import { SourceSchema, type Source } from "@executor-v2/schema"; · 2 unmodified lines
33 import { v } from "convex/values";
44 import * as Schema from "effect/Schema";
55
6−import { mutation, query } from "../_generated/server";
6+import { api } from "../_generated/api";
7+import { internalQuery, internalMutation, mutation, query } from "../_generated/server";
78
89 const decodeSource = Schema.decodeUnknownSync(SourceSchema);
910
@@ -109,10 +110,63 @@ export const upsertSource = mutation({ · 99 unmodified lines
109110 await ctx.db.insert("sources", source);
110111 }
111112
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+
112122 return source;
113123 },
114124 });
115125
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+
116170 export const removeSource = mutation({
117171 args: {
118172 workspaceId: v.string(),
@@ -133,6 +187,17 @@ export const removeSource = mutation({ · 14 unmodified lines
133187 return { removed: false };
134188 }
135189
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+
136201 await ctx.db.delete(existing._id);
137202
138203 return { removed: true };
v2/apps/convex/convex/control_plane/storage.ts+1069−0
@@ -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+});
v2/apps/convex/convex/control_plane/tools.ts+168−0
@@ -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+});
v2/apps/convex/convex/control_plane/workspaces.ts+81−0
@@ -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+});
v2/apps/convex/convex/executor.test.ts+456−0
@@ -1,4 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { extractOpenApiManifest } from "@executor-v2/management-api";
23 import { convexTest } from "convex-test";
34 import * as Effect from "effect/Effect";
45
@@ -16,6 +17,10 @@ const setup = () => · 11 unmodified lines
1617 "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),
1718 "./source_tool_registry.ts": () => import("./source_tool_registry"),
1819 "./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"),
1924 "./controlPlane.ts": () => import("./controlPlane"),
2025 "./_generated/api.js": () => import("./_generated/api.js"),
2126 });
@@ -57,6 +62,8 @@ describe("Convex executor and control-plane", () => { · 35 unmodified lines
5762 name: string;
5863 };
5964
65+ yield* Effect.tryPromise(() => t.finishInProgressScheduledFunctions());
66+
6067 expect(added.id).toBe("src_1");
6168 expect(added.workspaceId).toBe("ws_1");
6269 expect(added.name).toBe("Weather");
@@ -93,6 +100,455 @@ describe("Convex executor and control-plane", () => { · 30 unmodified lines
93100 }),
94101 );
95102
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+
96552 it.effect("persists approval state for runtime tool calls", () =>
97553 Effect.gen(function* () {
98554 const t = setup();
v2/apps/convex/convex/schema.ts+88−4
@@ -9,11 +9,70 @@ import { · 8 unmodified lines
99 ProfileSchema,
1010 SourceCredentialBindingSchema,
1111 SourceSchema,
12+ StorageInstanceSchema,
1213 SyncStateSchema,
1314 TaskRunSchema,
14− ToolArtifactSchema,
1515 WorkspaceSchema,
1616 } 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+});
1776
1877 export const executorConfectSchema = defineSchema({
1978 profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]),
@@ -27,14 +86,23 @@ export const executorConfectSchema = defineSchema({ · 7 unmodified lines
2786 .index("by_organizationId_accountId", ["organizationId", "accountId"]),
2887 workspaces: defineTable(WorkspaceSchema)
2988 .index("by_domainId", ["id"])
30− .index("by_organizationId", ["organizationId"]),
89+ .index("by_organizationId", ["organizationId"])
90+ .index("by_createdByAccountId", ["createdByAccountId"]),
3191 sources: defineTable(SourceSchema)
3292 .index("by_domainId", ["id"])
3393 .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)
35102 .index("by_domainId", ["id"])
36103 .index("by_workspaceId", ["workspaceId"])
37− .index("by_sourceId", ["sourceId"]),
104+ .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"])
105+ .index("by_artifactId", ["artifactId"]),
38106 sourceCredentialBindings: defineTable(SourceCredentialBindingSchema)
39107 .index("by_domainId", ["id"])
40108 .index("by_credentialId", ["credentialId"])
@@ -51,6 +119,22 @@ export const executorConfectSchema = defineSchema({ · 10 unmodified lines
51119 policies: defineTable(PolicySchema)
52120 .index("by_domainId", ["id"])
53121 .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"]),
54138 approvals: defineTable(ApprovalSchema)
55139 .index("by_domainId", ["id"])
56140 .index("by_workspaceId", ["workspaceId"])
v2/apps/convex/convex/source_tool_registry.ts+147−20
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 } from "@executor-v2/persistence-ports";
1717 import {
1818 ApprovalSchema,
19+ OpenApiToolManifestSchema,
1920 SourceSchema,
2021 ToolArtifactSchema,
2122 type Approval,
@@ -37,9 +38,14 @@ const runtimeInternal = internal as any; · 15 unmodified lines
3738
3839 const decodeSource = Schema.decodeUnknownSync(SourceSchema);
3940 const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema);
41+const decodeOpenApiToolManifest = Schema.decodeUnknownSync(OpenApiToolManifestSchema);
42+const encodeOpenApiToolManifestJson = Schema.encodeSync(
43+ Schema.parseJson(OpenApiToolManifestSchema),
44+);
4045 const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);
4146
4247 const defaultPendingRetryAfterMs = 1_000;
48+const openApiExtractorVersion = "openapi_v2";
4349
4450 const readBooleanFlag = (value: string | undefined): boolean => {
4551 const normalized = value?.trim().toLowerCase();
@@ -100,16 +106,6 @@ const unsupportedSourceStoreMutation = ( · 54 unmodified lines
100106 details: null,
101107 });
102108
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−
113109 const sourceStoreQueryError = (
114110 operation: string,
115111 cause: unknown,
@@ -136,6 +132,19 @@ const toolArtifactStoreQueryError = ( · 20 unmodified lines
136132 details: String(cause),
137133 });
138134
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+
139148 export const listSourcesForWorkspace = query({
140149 args: {
141150 workspaceId: v.string(),
@@ -158,19 +167,130 @@ export const getToolArtifactBySource = query({ · 16 unmodified lines
158167 sourceId: v.string(),
159168 },
160169 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();
165176
166− const record = rows.find((row) => row.workspaceId === args.workspaceId) ?? null;
167− if (!record) {
177+ if (!binding) {
168178 return null;
169179 }
170180
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;
174294 },
175295 });
176296
@@ -342,7 +462,14 @@ const createConvexToolArtifactStore = (ctx: ActionCtx): ToolArtifactStore => ({ · 165 unmodified lines
342462 catch: (cause) => toolArtifactStoreQueryError("getBySource", cause),
343463 }),
344464
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),
346473 });
347474
348475 const createConvexPersistentToolApprovalPolicy = (
v2/apps/convex/package.json+1−1
@@ -23,7 +23,7 @@ · 22 unmodified lines
2323 },
2424 "devDependencies": {
2525 "@effect/vitest": "catalog:",
26− "convex-test": "^0.0.41",
26+ "convex-test": "workspace:*",
2727 "vitest": "catalog:"
2828 }
2929 }
v2/apps/pm/src/actor.ts+82−2
@@ -3,15 +3,38 @@ import { · 2 unmodified lines
33 deriveWorkspaceMembershipsForPrincipal,
44 requirePrincipalFromHeaders,
55 } 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";
711 import {
812 type LocalStateSnapshot,
913 type LocalStateStore,
1014 } 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";
1220 import * as Effect from "effect/Effect";
1321 import * as Option from "effect/Option";
1422
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+
1538 const organizationMembershipsForAccount = (
1639 snapshot: LocalStateSnapshot,
1740 accountId: OrganizationMembership["accountId"],
@@ -20,8 +43,61 @@ const organizationMembershipsForAccount = ( · 2 unmodified lines
2043 (membership) => membership.accountId === accountId,
2144 );
2245
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+
2398 export const PmActorLive = (localStateStore: LocalStateStore) =>
2499 ControlPlaneActorResolverLive({
100+ resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers),
25101 resolveWorkspaceActor: (input) =>
26102 Effect.gen(function* () {
27103 const principal = yield* requirePrincipalFromHeaders(input.headers);
@@ -37,6 +113,10 @@ export const PmActorLive = (localStateStore: LocalStateStore) => · 9 unmodified lines
37113
38114 const snapshot = Option.getOrNull(snapshotOption);
39115
116+ if (snapshot === null && localAdminFallbackEnabled) {
117+ return makeAllowAllActor(principal);
118+ }
119+
40120 const organizationMemberships =
41121 snapshot === null
42122 ? []
v2/apps/pm/src/credentials-service.ts+260−0
@@ -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+ });
v2/apps/pm/src/main.ts+22−8
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 ControlPlaneService,
44 makeControlPlaneService,
55 makeControlPlaneSourcesService,
6+ fetchOpenApiDocument,
67 makeControlPlaneWebHandler,
78 makeSourceCatalogService,
89 makeSourceManagerService,
@@ -34,6 +35,12 @@ import { · 25 unmodified lines
3435 createPmPersistentToolApprovalPolicy,
3536 } from "./approvals-service";
3637 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";
3744 import { createPmMcpHandler } from "./mcp-handler";
3845 import { createPmExecuteRuntimeRun } from "./runtime-execution-port";
3946 import { createPmToolCallHttpHandler } from "./tool-call-handler";
@@ -113,14 +120,7 @@ const sourcesService = { · 73 unmodified lines
113120 }
114121
115122 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),
124124 catch: (cause) => String(cause),
125125 }).pipe(Effect.either);
126126
@@ -139,9 +139,23 @@ const sourcesService = { · 12 unmodified lines
139139 }),
140140 };
141141
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+});
142150 const approvalsService = createPmApprovalsService(localStateStore);
143151 const controlPlaneService = makeControlPlaneService({
144152 sources: sourcesService,
153+ credentials: credentialsService,
154+ policies: policiesService,
155+ organizations: organizationsService,
156+ workspaces: workspacesService,
157+ tools: toolsService,
158+ storage: storageService,
145159 approvals: approvalsService,
146160 });
147161
v2/apps/pm/src/organizations-service.ts+142−0
@@ -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+ });
v2/apps/pm/src/persistent-approval-policy.test.ts+1−0
@@ -33,6 +33,7 @@ const createTestSnapshot = (): LocalStateSnapshot => · 32 unmodified lines
3333 policies: [],
3434 approvals: [],
3535 taskRuns: [],
36+ storageInstances: [],
3637 syncStates: [],
3738 }) as unknown as LocalStateSnapshot;
3839
v2/apps/pm/src/policies-service.ts+208−0
@@ -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+ });
v2/apps/pm/src/storage-service.test.ts+161−0
@@ -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+});
v2/apps/pm/src/storage-service.ts+940−0
@@ -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+ });
v2/apps/pm/src/tools-service.ts+179−0
@@ -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+ });
v2/apps/pm/src/workspaces-service.ts+138−0
@@ -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+ });
v2/apps/web/app/api/control-plane/[...path]/route.ts+12−10
@@ -2,7 +2,7 @@ import { ControlPlaneAuthHeaders } from "@executor-v2/management-api"; · 1 unmodified line
22 import { withAuth } from "@workos-inc/authkit-nextjs";
33 import { type NextRequest } from "next/server";
44
5−import { isWorkosEnabled } from "../../../../lib/workos";
5+import { isLocalControlPlaneUpstream, isWorkosEnabled } from "../../../../lib/workos";
66
77 const controlPlaneUpstream =
88 process.env.CONTROL_PLANE_UPSTREAM_URL ?? "http://127.0.0.1:8787";
@@ -217,12 +217,6 @@ const normalizeSuccessResponse = async ( · 208 unmodified lines
217217 upstreamResponse: Response,
218218 ): Promise<Response> => {
219219 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−
226220 const payload = await upstreamResponse.arrayBuffer();
227221
228222 const headers = new Headers();
@@ -241,14 +235,22 @@ const proxyControlPlaneRequest = async ( · 12 unmodified lines
241235 path: ReadonlyArray<string>,
242236 ): Promise<Response> => {
243237 try {
244− const { user } = await withAuth();
238+ const workosEnabled = isWorkosEnabled();
239+ const user = workosEnabled ? (await withAuth()).user : null;
245240
246− const principalAccountId = resolvePrincipalAccountId(request, user?.id ?? null);
241+ let principalAccountId = resolvePrincipalAccountId(request, user?.id ?? null);
247242
248− if (isWorkosEnabled() && user === null) {
243+ if (workosEnabled && user === null) {
249244 return unauthorizedResponse();
250245 }
251246
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+
252254 if (principalAccountId === null) {
253255 return unauthorizedResponse();
254256 }
v2/apps/web/app/globals.css+53−208
@@ -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);
10423 }
10524
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;
20347 }
20448
205− .card + .card {
206− border-left: 0;
207− border-top: 1px solid var(--border);
49+ html,
50+ body {
51+ min-height: 100%;
20852 }
209−}
21053
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%);
21560 }
21661 }
v2/apps/web/app/page-content.tsx+2914−133
@@ -1,39 +1,307 @@
11 "use client";
22
33 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";
727
828 import {
29+ approvalsByWorkspace,
30+ approvalsResultByWorkspace,
31+ closeStorageInstance,
32+ credentialBindingsByWorkspace,
33+ credentialBindingsResultByWorkspace,
34+ listStorageDirectory,
35+ listStorageKv,
36+ openStorageInstance,
37+ optimisticRemovePolicy,
938 optimisticRemoveSources,
39+ optimisticResolveApproval,
1040 optimisticSourcesByWorkspace,
41+ optimisticUpsertPolicy,
1142 optimisticUpsertSources,
43+ organizationsResult,
44+ organizationsState,
45+ policiesByWorkspace,
46+ policiesResultByWorkspace,
47+ queryStorageSql,
48+ readStorageFile,
49+ removeCredentialBinding,
50+ removePolicy,
1251 removeSource,
52+ removeStorageInstance,
53+ resolveApproval,
1354 sourcesByWorkspace,
1455 sourcesPendingByWorkspace,
1556 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,
1678 upsertSource,
79+ upsertWorkspace,
80+ workspaceToolsByWorkspace,
81+ workspaceToolsResultByWorkspace,
82+ workspacesResult,
83+ workspacesState,
1784 } 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;
18131
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+};
25153
26154 type PageProps = {
27155 authEnabled: boolean;
28156 initialWorkspaceId: string;
29157 };
30158
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+
31207 const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => {
208+ const [activeTab, setActiveTab] = useState<ConsoleTab>("sources");
32209 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("");
36214 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);
37305
38306 const workspaceId = workspaceIdInput as WorkspaceId;
39307
@@ -41,35 +309,478 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 1 unmodified line
41309 const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId));
42310 const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId));
43311
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+ );
44338 const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" });
45339 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);
47573
48574 const handleWorkspaceChange = (value: string) => {
49575 setWorkspaceIdInput(value);
50576 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}.`);
51764 };
52765
53− const handleAddSource = (event: FormEvent<HTMLFormElement>) => {
766+ const handleUpsertSource = (event: FormEvent<HTMLFormElement>) => {
54767 event.preventDefault();
55768
56769 if (sourcesPending) {
57770 return;
58771 }
59772
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+ }
61777
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+ });
73784
74785 const previousSources = sources.items;
75786 const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload);
@@ -87,7 +798,12 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 11 unmodified lines
87798 payload,
88799 })
89800 .then(() => {
90− setStatusText("Source saved.");
801+ setStatusText(
802+ isEditing
803+ ? `Updated ${formState.name.trim()}.`
804+ : `Saved ${formState.name.trim()}.`,
805+ );
806+ resetForm();
91807 refreshSources();
92808 })
93809 .catch(() => {
@@ -118,6 +834,9 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 24 unmodified lines
118834 })
119835 .then(() => {
120836 setStatusText("Source removed.");
837+ if (formState.id === sourceId) {
838+ resetForm();
839+ }
121840 refreshSources();
122841 })
123842 .catch(() => {
@@ -127,122 +846,2184 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 3 unmodified lines
127846 });
128847 };
129848
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+ }
161853
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+ }
171860
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+ );
186867
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+ );
196881
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+ };
201895
202− {statusText ? <p className="status">{statusText}</p> : null}
203− </section>
204896
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+ }
207902
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+ };
211914
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+ };
215927
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();
219930
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>
2463027 </main>
2473028 );
2483029 };
v2/apps/web/components.json+20−0
@@ -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+}
v2/apps/web/components/ui/badge.tsx+29−0
@@ -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+}
v2/apps/web/components/ui/button.tsx+40−0
@@ -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+}
v2/apps/web/components/ui/card.tsx+41−0
@@ -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+}
v2/apps/web/components/ui/input.tsx+16−0
@@ -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+}
v2/apps/web/components/ui/select.tsx+17−0
@@ -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+}
v2/apps/web/lib/control-plane/atoms.ts+778−2
@@ -1,6 +1,52 @@
11 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";
450 import * as Cause from "effect/Cause";
551 import * as Option from "effect/Option";
652
@@ -195,3 +241,733 @@ export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => · 188 unmodified lines
195241 });
196242 }),
197243 );
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+
v2/apps/web/lib/control-plane/client.ts+2−4
@@ -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";
53
64 const controlPlaneBaseUrl =
75 typeof window === "undefined"
v2/apps/web/lib/control-plane/legacy-source.ts+266−0
@@ -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+};
v2/apps/web/lib/utils.ts+6−0
@@ -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+}
v2/apps/web/lib/workos.ts+33−2
@@ -3,8 +3,39 @@ const trim = (value: string | undefined): string | undefined => { · 2 unmodified lines
33 return candidate && candidate.length > 0 ? candidate : undefined;
44 };
55
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+};
839
940 export const externalOriginFromRequest = (request: Request): string => {
1041 const forwardedHost = trim(request.headers.get("x-forwarded-host") ?? undefined);
v2/apps/web/package.json+10−3
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "scripts": {
6− "dev": "next dev --port 4312",
6+ "dev": "next dev --turbopack --port 4312",
77 "build": "next build",
88 "start": "next start --port 4312",
99 "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json"
@@ -14,13 +14,20 @@ · 4 unmodified lines
1414 "@executor-v2/management-api": "workspace:*",
1515 "@executor-v2/schema": "workspace:*",
1616 "@workos-inc/authkit-nextjs": "^2.13.0",
17+ "class-variance-authority": "^0.7.1",
18+ "clsx": "^2.1.1",
1719 "effect": "catalog:",
1820 "next": "^15.4.6",
1921 "react": "^19.1.1",
20− "react-dom": "^19.1.1"
22+ "react-dom": "^19.1.1",
23+ "tailwind-merge": "^3.3.1"
2124 },
2225 "devDependencies": {
26+ "@tailwindcss/postcss": "^4.1.12",
2327 "@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"
2532 }
2633 }
v2/apps/web/postcss.config.mjs+7−0
@@ -0,0 +1,7 @@
1+const config = {
2+ plugins: {
3+ "@tailwindcss/postcss": {},
4+ },
5+};
6+
7+export default config;
v2/bun.lock+119−20
@@ -35,7 +35,7 @@ · 34 unmodified lines
3535 },
3636 "devDependencies": {
3737 "@effect/vitest": "catalog:",
38− "convex-test": "^0.0.41",
38+ "convex-test": "workspace:*",
3939 "vitest": "catalog:",
4040 },
4141 },
@@ -69,14 +69,21 @@ · 27 unmodified lines
6969 "@executor-v2/management-api": "workspace:*",
7070 "@executor-v2/schema": "workspace:*",
7171 "@workos-inc/authkit-nextjs": "^2.13.0",
72+ "class-variance-authority": "^0.7.1",
73+ "clsx": "^2.1.1",
7274 "effect": "catalog:",
7375 "next": "^15.4.6",
7476 "react": "^19.1.1",
7577 "react-dom": "^19.1.1",
78+ "tailwind-merge": "^3.3.1",
7679 },
7780 "devDependencies": {
81+ "@tailwindcss/postcss": "^4.1.12",
7882 "@types/react": "^19.1.10",
7983 "@types/react-dom": "^19.1.7",
84+ "postcss": "^8.5.6",
85+ "tailwindcss": "^4.1.12",
86+ "tw-animate-css": "^1.4.0",
8087 },
8188 },
8289 "apps/www": {
@@ -132,6 +139,13 @@ · 49 unmodified lines
132139 "vitest": "catalog:",
133140 },
134141 },
142+ "packages/convex-test": {
143+ "name": "convex-test",
144+ "version": "0.0.41-patched",
145+ "peerDependencies": {
146+ "convex": "^1.32.0",
147+ },
148+ },
135149 "packages/domain": {
136150 "name": "@executor-v2/domain",
137151 "dependencies": {
@@ -167,6 +181,7 @@ · 29 unmodified lines
167181 "@executor-v2/persistence-ports": "workspace:*",
168182 "@executor-v2/schema": "workspace:*",
169183 "effect": "catalog:",
184+ "yaml": "^2.8.1",
170185 },
171186 "devDependencies": {
172187 "@effect/vitest": "catalog:",
@@ -782,6 +797,36 @@ · 609 unmodified lines
782797
783798 "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
784799
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+
785830 "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
786831
787832 "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
@@ -1048,6 +1093,8 @@ · 260 unmodified lines
10481093
10491094 "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="],
10501095
1096+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
1097+
10511098 "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
10521099
10531100 "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -1084,7 +1131,7 @@ · 30 unmodified lines
10841131
10851132 "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=="],
10861133
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"],
10881135
10891136 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
10901137
@@ -1174,6 +1221,8 @@ · 83 unmodified lines
11741221
11751222 "end-of-stream": ["end-of-stream@1.1.0", "", { "dependencies": { "once": "~1.3.0" } }, "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ=="],
11761223
1224+ "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
1225+
11771226 "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
11781227
11791228 "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
@@ -1412,6 +1461,30 @@ · 232 unmodified lines
14121461
14131462 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
14141463
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+
14151488 "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
14161489
14171490 "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1680,7 +1753,7 @@ · 262 unmodified lines
16801753
16811754 "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
16821755
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=="],
16841757
16851758 "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=="],
16861759
@@ -1882,7 +1955,11 @@ · 195 unmodified lines
18821955
18831956 "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=="],
18841957
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=="],
18861963
18871964 "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=="],
18881965
@@ -1946,6 +2023,8 @@ · 57 unmodified lines
19462023
19472024 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="],
19482025
2026+ "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
2027+
19492028 "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
19502029
19512030 "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
21322211
21332212 "@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=="],
21342213
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=="],
21362215
21372216 "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
21382217
@@ -2146,6 +2225,8 @@ · 7 unmodified lines
21462225
21472226 "@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=="],
21482227
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+
21492230 "@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=="],
21502231
21512232 "@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
21562237
21572238 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
21582239
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+
21592254 "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
21602255
21612256 "@vercel/backends/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -2274,6 +2369,8 @@ · 112 unmodified lines
22742369
22752370 "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
22762371
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+
22772374 "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
22782375
22792376 "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -2288,18 +2385,12 @@ · 8 unmodified lines
22882385
22892386 "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
22902387
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−
22952388 "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
22962389
22972390 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
22982391
22992392 "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
23002393
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−
23032394 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
23042395
23052396 "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
23182409
23192410 "@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=="],
23202411
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=="],
23222413
23232414 "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
23242415
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+
23252418 "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
23262419
23272420 "@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
23402433
23412434 "@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=="],
23422435
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+
23432438 "@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=="],
23442439
23452440 "@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
24202515
24212516 "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=="],
24222517
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−
24252518 "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
24262519
24272520 "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
24402533
24412534 "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
24422535
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−
24472536 "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
24482537
24492538 "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@@ -2560,6 +2649,14 @@ · 110 unmodified lines
25602649
25612650 "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
25622651
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+
25632660 "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
25642661
25652662 "@vercel/fun/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -2626,8 +2723,10 @@ · 60 unmodified lines
26262723
26272724 "micro/raw-body/http-errors/toidentifier": ["toidentifier@1.0.0", "", {}, "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="],
26282725
2629− "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
2630−
26312726 "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=="],
26322731 }
26332732 }
v2/packages/convex-test/compare.d.ts+3−0
@@ -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
v2/packages/convex-test/compare.js+80−0
@@ -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+}
v2/packages/convex-test/index.d.ts+98−0
@@ -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
v2/packages/convex-test/index.js+1507−0
@@ -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+}
v2/packages/convex-test/package.json+21−0
@@ -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+}
v2/packages/management-api/package.json+14−2
@@ -3,7 +3,18 @@ · 2 unmodified lines
33 "private": true,
44 "type": "module",
55 "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"
718 },
819 "scripts": {
920 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
@@ -14,7 +25,8 @@ · 4 unmodified lines
1425 "@executor-v2/domain": "workspace:*",
1526 "@executor-v2/persistence-ports": "workspace:*",
1627 "@executor-v2/schema": "workspace:*",
17− "effect": "catalog:"
28+ "effect": "catalog:",
29+ "yaml": "^2.8.1"
1830 },
1931 "devDependencies": {
2032 "@effect/vitest": "catalog:",
v2/packages/management-api/src/api.ts+64−0
@@ -1,7 +1,13 @@
11 import { HttpApi, OpenApi } from "@effect/platform";
22
33 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";
48 import { SourcesApi } from "./sources/api";
9+import { ToolsApi } from "./tools/api";
10+import { WorkspacesApi } from "./workspaces/api";
511
612 export {
713 ControlPlaneBadRequestError,
@@ -17,6 +23,58 @@ export { · 9 unmodified lines
1723 type UpsertSourcePayload,
1824 } from "./sources/api";
1925
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+
2078 export {
2179 ResolveApprovalPayloadSchema,
2280 ResolveApprovalStatusSchema,
@@ -25,6 +83,12 @@ export { · 2 unmodified lines
2583
2684 export class ControlPlaneApi extends HttpApi.make("controlPlane")
2785 .add(SourcesApi)
86+ .add(CredentialsApi)
87+ .add(PoliciesApi)
88+ .add(OrganizationsApi)
89+ .add(WorkspacesApi)
90+ .add(ToolsApi)
91+ .add(StorageApi)
2892 .add(ApprovalsApi)
2993 .annotateContext(
3094 OpenApi.annotations({
v2/packages/management-api/src/auth/actor-resolver.ts+7−0
@@ -7,12 +7,19 @@ import * as PlatformHeaders from "@effect/platform/Headers"; · 6 unmodified lines
77 import * as Context from "effect/Context";
88 import * as Effect from "effect/Effect";
99
10+export type ResolveActorInput = {
11+ headers: PlatformHeaders.Headers;
12+};
13+
1014 export type ResolveWorkspaceActorInput = {
1115 workspaceId: WorkspaceId;
1216 headers: PlatformHeaders.Headers;
1317 };
1418
1519 export type ControlPlaneActorResolverShape = {
20+ resolveActor: (
21+ input: ResolveActorInput,
22+ ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
1623 resolveWorkspaceActor: (
1724 input: ResolveWorkspaceActorInput,
1825 ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
v2/packages/management-api/src/credentials/api.ts+74−0
@@ -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") {}
v2/packages/management-api/src/credentials/http.ts+146−0
@@ -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+);
v2/packages/management-api/src/credentials/index.ts+16−0
@@ -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";
v2/packages/management-api/src/credentials/service.ts+38−0
@@ -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;
v2/packages/management-api/src/http.ts+12−0
@@ -8,10 +8,22 @@ import { · 7 unmodified lines
88 } from "./auth/actor-resolver";
99 import { ControlPlaneService } from "./service";
1010 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";
1115 import { ControlPlaneSourcesLive } from "./sources/http";
16+import { ControlPlaneToolsLive } from "./tools/http";
17+import { ControlPlaneWorkspacesLive } from "./workspaces/http";
1218
1319 export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(
1420 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),
1527 Layer.provide(ControlPlaneApprovalsLive),
1628 );
1729
v2/packages/management-api/src/index.ts+84−0
@@ -1,13 +1,47 @@
11 export {
22 ControlPlaneApi,
3+ ListStorageDirectoryPayloadSchema,
4+ ListStorageDirectoryResultSchema,
5+ ListStorageKvPayloadSchema,
6+ ListStorageKvResultSchema,
7+ OpenStorageInstancePayloadSchema,
8+ QueryStorageSqlPayloadSchema,
9+ QueryStorageSqlResultSchema,
10+ ReadStorageFilePayloadSchema,
11+ ReadStorageFileResultSchema,
12+ RemoveCredentialBindingResultSchema,
13+ RemovePolicyResultSchema,
314 RemoveSourceResultSchema,
15+ RemoveStorageInstanceResultSchema,
416 ResolveApprovalPayloadSchema,
517 ResolveApprovalStatusSchema,
18+ SourceToolSummarySchema,
19+ UpsertCredentialBindingPayloadSchema,
20+ UpsertOrganizationPayloadSchema,
21+ UpsertPolicyPayloadSchema,
622 UpsertSourcePayloadSchema,
23+ UpsertWorkspacePayloadSchema,
724 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,
836 type RemoveSourceResult,
37+ type RemoveStorageInstanceResult,
938 type ResolveApprovalPayload,
39+ type SourceToolSummary,
40+ type UpsertCredentialBindingPayload,
41+ type UpsertOrganizationPayload,
42+ type UpsertPolicyPayload,
1043 type UpsertSourcePayload,
44+ type UpsertWorkspacePayload,
1145 } from "./api";
1246
1347 export {
@@ -32,6 +66,7 @@ export { · 18 unmodified lines
3266 export {
3367 ControlPlaneActorResolver,
3468 type ControlPlaneActorResolverShape,
69+ type ResolveActorInput,
3570 type ResolveWorkspaceActorInput,
3671 } from "./auth/actor-resolver";
3772
@@ -51,6 +86,52 @@ export { · 13 unmodified lines
5186 type UpsertSourceInput,
5287 } from "./sources";
5388
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+
54135 export {
55136 ControlPlaneApprovalsLive,
56137 makeControlPlaneApprovalsService,
@@ -90,3 +171,6 @@ export { · 33 unmodified lines
90171 type SourceManagerService,
91172 type ToolManifestDiff,
92173 } from "./source-manager";
174+
175+export { fetchOpenApiDocument, parseOpenApiDocument } from "./openapi-document";
176+
v2/packages/management-api/src/openapi-document.ts+36−0
@@ -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+};
v2/packages/management-api/src/openapi-extraction.ts+13−3
@@ -1,5 +1,3 @@
1−import { createHash } from "node:crypto";
2−
31 import {
42 ToolArtifactStoreError,
53 type ToolArtifactStore,
@@ -473,8 +471,20 @@ const toStableValue = (value: unknown): unknown => { · 467 unmodified lines
473471 return value;
474472 };
475473
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+
476486 const hashUnknown = (value: unknown): string =>
477− createHash("sha256").update(JSON.stringify(toStableValue(value))).digest("hex");
487+ hashString(JSON.stringify(toStableValue(value)));
478488
479489 const normalizePathForToolId = (pathValue: string): string =>
480490 pathValue
v2/packages/management-api/src/organizations/api.ts+47−0
@@ -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") {}
v2/packages/management-api/src/organizations/http.ts+142−0
@@ -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+);
v2/packages/management-api/src/organizations/index.ts+12−0
@@ -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";
v2/packages/management-api/src/organizations/service.ts+20−0
@@ -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;
v2/packages/management-api/src/policies/api.ts+62−0
@@ -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") {}
v2/packages/management-api/src/policies/http.ts+146−0
@@ -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+);
v2/packages/management-api/src/policies/index.ts+16−0
@@ -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";
v2/packages/management-api/src/policies/service.ts+31−0
@@ -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;
v2/packages/management-api/src/service.ts+24−0
@@ -1,9 +1,21 @@
11 import * as Context from "effect/Context";
22
33 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";
48 import { type ControlPlaneSourcesServiceShape } from "./sources/service";
9+import { type ControlPlaneToolsServiceShape } from "./tools/service";
10+import { type ControlPlaneWorkspacesServiceShape } from "./workspaces/service";
511
612 export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape &
13+ ControlPlaneCredentialsServiceShape &
14+ ControlPlanePoliciesServiceShape &
15+ ControlPlaneOrganizationsServiceShape &
16+ ControlPlaneWorkspacesServiceShape &
17+ ControlPlaneToolsServiceShape &
18+ ControlPlaneStorageServiceShape &
719 ControlPlaneApprovalsServiceShape;
820
921 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
1325
1426 export const makeControlPlaneService = (services: {
1527 sources: ControlPlaneSourcesServiceShape;
28+ credentials: ControlPlaneCredentialsServiceShape;
29+ policies: ControlPlanePoliciesServiceShape;
30+ organizations: ControlPlaneOrganizationsServiceShape;
31+ workspaces: ControlPlaneWorkspacesServiceShape;
32+ tools: ControlPlaneToolsServiceShape;
33+ storage: ControlPlaneStorageServiceShape;
1634 approvals: ControlPlaneApprovalsServiceShape;
1735 }): ControlPlaneServiceShape => ({
1836 ...services.sources,
37+ ...services.credentials,
38+ ...services.policies,
39+ ...services.organizations,
40+ ...services.workspaces,
41+ ...services.tools,
42+ ...services.storage,
1943 ...services.approvals,
2044 });
v2/packages/management-api/src/storage/api.ts+235−0
@@ -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") {}
v2/packages/management-api/src/storage/http.ts+265−0
@@ -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+);
v2/packages/management-api/src/storage/index.ts+37−0
@@ -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";
v2/packages/management-api/src/storage/service.ts+90−0
@@ -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;
v2/packages/management-api/src/tools/api.ts+53−0
@@ -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") {}
v2/packages/management-api/src/tools/http.ts+117−0
@@ -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+);
v2/packages/management-api/src/tools/index.ts+12−0
@@ -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";
v2/packages/management-api/src/tools/service.ts+23−0
@@ -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;
v2/packages/management-api/src/workspaces/api.ts+45−0
@@ -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") {}
v2/packages/management-api/src/workspaces/http.ts+188−0
@@ -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+);
v2/packages/management-api/src/workspaces/index.ts+12−0
@@ -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";
v2/packages/management-api/src/workspaces/service.ts+20−0
@@ -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;
v2/packages/schema/src/enums.ts+13−0
@@ -18,6 +18,15 @@ export const CredentialScopeTypeSchema = Schema.Literal( · 17 unmodified lines
1818 "organization",
1919 "account",
2020 );
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");
2130 export const OrganizationStatusSchema = Schema.Literal("active", "suspended", "archived");
2231 export const OrganizationMemberStatusSchema = Schema.Literal(
2332 "invited",
@@ -36,6 +45,10 @@ export type TaskRunStatus = typeof TaskRunStatusSchema.Type; · 12 unmodified lines
3645 export type CredentialMode = typeof CredentialModeSchema.Type;
3746 export type CredentialProvider = typeof CredentialProviderSchema.Type;
3847 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;
3952 export type OrganizationStatus = typeof OrganizationStatusSchema.Type;
4053 export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type;
4154 export type SyncTarget = typeof SyncTargetSchema.Type;
v2/packages/schema/src/ids.ts+4−0
@@ -18,6 +18,9 @@ export const PolicyIdSchema = Schema.String.pipe(Schema.brand("PolicyId")); · 17 unmodified lines
1818 export const ApprovalIdSchema = Schema.String.pipe(Schema.brand("ApprovalId"));
1919 export const TaskRunIdSchema = Schema.String.pipe(Schema.brand("TaskRunId"));
2020 export const SyncStateIdSchema = Schema.String.pipe(Schema.brand("SyncStateId"));
21+export const StorageInstanceIdSchema = Schema.String.pipe(
22+ Schema.brand("StorageInstanceId"),
23+);
2124 export const EventIdSchema = Schema.String.pipe(Schema.brand("EventId"));
2225
2326 export type AccountId = typeof AccountIdSchema.Type;
@@ -34,4 +37,5 @@ export type PolicyId = typeof PolicyIdSchema.Type; · 10 unmodified lines
3437 export type ApprovalId = typeof ApprovalIdSchema.Type;
3538 export type TaskRunId = typeof TaskRunIdSchema.Type;
3639 export type SyncStateId = typeof SyncStateIdSchema.Type;
40+export type StorageInstanceId = typeof StorageInstanceIdSchema.Type;
3741 export type EventId = typeof EventIdSchema.Type;
v2/packages/schema/src/index.ts+1−0
@@ -12,6 +12,7 @@ export * from "./models/policy"; · 11 unmodified lines
1212 export * from "./models/approval";
1313 export * from "./models/task-run";
1414 export * from "./models/sync-state";
15+export * from "./models/storage-instance";
1516 export * from "./models/event-envelope";
1617 export * from "./models/runtime-tool";
1718 export * from "./models/auth";
v2/packages/schema/src/models/auth/permission.ts+1−0
@@ -6,6 +6,7 @@ const permissionDomainActions = { · 5 unmodified lines
66 approvals: ["read", "resolve", "manage"],
77 runs: ["read", "cancel", "manage"],
88 credentials: ["read", "write", "manage"],
9+ storage: ["read", "write", "manage"],
910 organizations: ["read", "manage"],
1011 memberships: ["read", "write", "manage"],
1112 workspace: ["read", "manage"],
v2/packages/schema/src/models/storage-instance.ts+38−0
@@ -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;
v2/packages/state-local-file/src/local-state-snapshot.ts+2−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 SchemaVersionSchema,
1010 SourceCredentialBindingSchema,
1111 SourceSchema,
12+ StorageInstanceSchema,
1213 SyncStateSchema,
1314 TaskRunSchema,
1415 TimestampMsSchema,
@@ -31,6 +32,7 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 16 unmodified lines
3132 policies: Schema.Array(PolicySchema),
3233 approvals: Schema.Array(ApprovalSchema),
3334 taskRuns: Schema.Array(TaskRunSchema),
35+ storageInstances: Schema.Array(StorageInstanceSchema),
3436 syncStates: Schema.Array(SyncStateSchema),
3537 });
3638
v2/packages/state-local-file/src/state-store.test.ts+1−0
@@ -74,6 +74,7 @@ describe("makeLocalStateStore", () => { · 73 unmodified lines
7474 policies: [],
7575 approvals: [],
7676 taskRuns: [],
77+ storageInstances: [],
7778 syncStates: [],
7879 });
7980