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
17# When enabled, local Bun execution is allowed.17# When enabled, local Bun execution is allowed.
18# Leave unset in production to default to Cloudflare runtime.18# Leave unset in production to default to Cloudflare runtime.
19# DANGEROUSLY_ALLOW_LOCAL_VM=119# DANGEROUSLY_ALLOW_LOCAL_VM=1
20# Optional external OpenAPI generator endpoint used by Convex for heavy parse/prepare.
21# Expects GET /api/generate?specUrl=...&sourceName=...&includeDts=0|1.
22# EXECUTOR_GENERATE_URL=http://localhost:4312
23 20
24# ── Agent storage backend (optional) ──21# ── Agent storage backend (optional) ──
25# Default: agentfs-local22# Default: agentfs-local
@@ -34,10 +31,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 8 unmodified lines
34# Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.31# Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.
35 32
36# ── WorkOS (optional — enables auth, omit for anonymous mode) ──33# ── WorkOS (optional — enables auth, omit for anonymous mode) ──
34# WorkOS auth is auto-disabled when CONTROL_PLANE_UPSTREAM_URL points to local PM
35# (localhost/127.0.0.1/0.0.0.0/::1) unless you force it on with WORKOS_FORCE_ENABLED=1.
37# WORKOS_CLIENT_ID=client_...36# WORKOS_CLIENT_ID=client_...
38# WORKOS_API_KEY=sk_test_...37# WORKOS_API_KEY=sk_test_...
39# WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>38# WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>
40# WORKOS_WEBHOOK_SECRET=...39# WORKOS_WEBHOOK_SECRET=...
40# WORKOS_FORCE_ENABLED=1
41 41
42# ── Anonymous JWT Auth (optional — enables anonymous bearer auth via Convex Auth) ──42# ── Anonymous JWT Auth (optional — enables anonymous bearer auth via Convex Auth) ──
43# Generate an ES256 key pair (PKCS8 private + SPKI public), then set both PEM values.43# Generate an ES256 key pair (PKCS8 private + SPKI public), then set both PEM values.
v2/apps/convex/convex/_generated/api.d.ts+22−0
@@ -10,10 +10,19 @@ · 9 unmodified lines
10 10
11import type * as controlPlane from "../controlPlane.js";11import type * as controlPlane from "../controlPlane.js";
12import type * as control_plane_actor from "../control_plane/actor.js";12import type * as control_plane_actor from "../control_plane/actor.js";
13import type * as control_plane_approvals from "../control_plane/approvals.js";
14import type * as control_plane_credentials from "../control_plane/credentials.js";
13import type * as control_plane_errors from "../control_plane/errors.js";15import type * as control_plane_errors from "../control_plane/errors.js";
14import type * as control_plane_http from "../control_plane/http.js";16import type * as control_plane_http from "../control_plane/http.js";
17import type * as control_plane_openapi_ingest from "../control_plane/openapi_ingest.js";
18import type * as control_plane_openapi_ingest_mvp from "../control_plane/openapi_ingest_mvp.js";
19import type * as control_plane_organizations from "../control_plane/organizations.js";
20import type * as control_plane_policies from "../control_plane/policies.js";
15import type * as control_plane_service from "../control_plane/service.js";21import type * as control_plane_service from "../control_plane/service.js";
16import type * as control_plane_sources from "../control_plane/sources.js";22import type * as control_plane_sources from "../control_plane/sources.js";
23import type * as control_plane_storage from "../control_plane/storage.js";
24import type * as control_plane_tools from "../control_plane/tools.js";
25import type * as control_plane_workspaces from "../control_plane/workspaces.js";
17import type * as credential_resolver from "../credential_resolver.js";26import type * as credential_resolver from "../credential_resolver.js";
18import type * as executor from "../executor.js";27import type * as executor from "../executor.js";
19import type * as http from "../http.js";28import type * as http from "../http.js";
@@ -22,6 +31,8 @@ import type * as run_executor from "../run_executor.js"; · 2 unmodified lines
22import type * as runtimeCallbacks from "../runtimeCallbacks.js";31import type * as runtimeCallbacks from "../runtimeCallbacks.js";
23import type * as runtime_adapter from "../runtime_adapter.js";32import type * as runtime_adapter from "../runtime_adapter.js";
24import type * as runtime_execution_port from "../runtime_execution_port.js";33import type * as runtime_execution_port from "../runtime_execution_port.js";
34import type * as source_tool_registry from "../source_tool_registry.js";
35import type * as task_runs from "../task_runs.js";
25 36
26import type {37import type {
27 ApiFromModules,38 ApiFromModules,
@@ -32,10 +43,19 @@ import type { · 4 unmodified lines
32declare const fullApi: ApiFromModules<{43declare const fullApi: ApiFromModules<{
33 controlPlane: typeof controlPlane;44 controlPlane: typeof controlPlane;
34 "control_plane/actor": typeof control_plane_actor;45 "control_plane/actor": typeof control_plane_actor;
46 "control_plane/approvals": typeof control_plane_approvals;
47 "control_plane/credentials": typeof control_plane_credentials;
35 "control_plane/errors": typeof control_plane_errors;48 "control_plane/errors": typeof control_plane_errors;
36 "control_plane/http": typeof control_plane_http;49 "control_plane/http": typeof control_plane_http;
50 "control_plane/openapi_ingest": typeof control_plane_openapi_ingest;
51 "control_plane/openapi_ingest_mvp": typeof control_plane_openapi_ingest_mvp;
52 "control_plane/organizations": typeof control_plane_organizations;
53 "control_plane/policies": typeof control_plane_policies;
37 "control_plane/service": typeof control_plane_service;54 "control_plane/service": typeof control_plane_service;
38 "control_plane/sources": typeof control_plane_sources;55 "control_plane/sources": typeof control_plane_sources;
56 "control_plane/storage": typeof control_plane_storage;
57 "control_plane/tools": typeof control_plane_tools;
58 "control_plane/workspaces": typeof control_plane_workspaces;
39 credential_resolver: typeof credential_resolver;59 credential_resolver: typeof credential_resolver;
40 executor: typeof executor;60 executor: typeof executor;
41 http: typeof http;61 http: typeof http;
@@ -44,6 +64,8 @@ declare const fullApi: ApiFromModules<{ · 2 unmodified lines
44 runtimeCallbacks: typeof runtimeCallbacks;64 runtimeCallbacks: typeof runtimeCallbacks;
45 runtime_adapter: typeof runtime_adapter;65 runtime_adapter: typeof runtime_adapter;
46 runtime_execution_port: typeof runtime_execution_port;66 runtime_execution_port: typeof runtime_execution_port;
67 source_tool_registry: typeof source_tool_registry;
68 task_runs: typeof task_runs;
47}>;69}>;
48 70
49/**71/**
v2/apps/convex/convex/controlPlane.ts+52−0
@@ -2,16 +2,68 @@ import { · 1 unmodified line
2 listApprovals as listApprovalsImpl,2 listApprovals as listApprovalsImpl,
3 resolveApproval as resolveApprovalImpl,3 resolveApproval as resolveApprovalImpl,
4} from "./control_plane/approvals";4} from "./control_plane/approvals";
5import {
6 listCredentialBindings as listCredentialBindingsImpl,
7 removeCredentialBinding as removeCredentialBindingImpl,
8 upsertCredentialBinding as upsertCredentialBindingImpl,
9} from "./control_plane/credentials";
5import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http";10import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http";
11import {
12 listOrganizations as listOrganizationsImpl,
13 upsertOrganization as upsertOrganizationImpl,
14} from "./control_plane/organizations";
15import {
16 listPolicies as listPoliciesImpl,
17 removePolicy as removePolicyImpl,
18 upsertPolicy as upsertPolicyImpl,
19} from "./control_plane/policies";
6import {20import {
7 listSources as listSourcesImpl,21 listSources as listSourcesImpl,
8 removeSource as removeSourceImpl,22 removeSource as removeSourceImpl,
9 upsertSource as upsertSourceImpl,23 upsertSource as upsertSourceImpl,
10} from "./control_plane/sources";24} from "./control_plane/sources";
25import {
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";
35import {
36 listSourceTools as listSourceToolsImpl,
37 listWorkspaceTools as listWorkspaceToolsImpl,
38} from "./control_plane/tools";
39import {
40 listWorkspaces as listWorkspacesImpl,
41 upsertWorkspace as upsertWorkspaceImpl,
42} from "./control_plane/workspaces";
11 43
12export const listSources = listSourcesImpl;44export const listSources = listSourcesImpl;
13export const upsertSource = upsertSourceImpl;45export const upsertSource = upsertSourceImpl;
14export const removeSource = removeSourceImpl;46export const removeSource = removeSourceImpl;
47export const listCredentialBindings = listCredentialBindingsImpl;
48export const upsertCredentialBinding = upsertCredentialBindingImpl;
49export const removeCredentialBinding = removeCredentialBindingImpl;
50export const listPolicies = listPoliciesImpl;
51export const upsertPolicy = upsertPolicyImpl;
52export const removePolicy = removePolicyImpl;
53export const listOrganizations = listOrganizationsImpl;
54export const upsertOrganization = upsertOrganizationImpl;
55export const listWorkspaces = listWorkspacesImpl;
56export const upsertWorkspace = upsertWorkspaceImpl;
57export const listWorkspaceTools = listWorkspaceToolsImpl;
58export const listSourceTools = listSourceToolsImpl;
59export const listStorageInstances = listStorageInstancesImpl;
60export const openStorageInstance = openStorageInstanceImpl;
61export const closeStorageInstance = closeStorageInstanceImpl;
62export const removeStorageInstance = removeStorageInstanceImpl;
63export const listStorageDirectory = listStorageDirectoryImpl;
64export const readStorageFile = readStorageFileImpl;
65export const listStorageKv = listStorageKvImpl;
66export const queryStorageSql = queryStorageSqlImpl;
15export const listApprovals = listApprovalsImpl;67export const listApprovals = listApprovalsImpl;
16export const resolveApproval = resolveApprovalImpl;68export const resolveApproval = resolveApprovalImpl;
17 69
v2/apps/convex/convex/control_plane/actor.ts+66−0
@@ -71,6 +71,34 @@ export const listOrganizationMembershipsForActor = internalQuery({ · 70 unmodified lines
71 },71 },
72});72});
73 73
74export 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
74export const ensureWorkspaceForActor = internalMutation({102export const ensureWorkspaceForActor = internalMutation({
75 args: {103 args: {
76 workspaceId: v.string(),104 workspaceId: v.string(),
@@ -139,6 +167,44 @@ const runQueryEffect = <A>( · 62 unmodified lines
139 167
140export const ConvexControlPlaneActorLive = (ctx: ActionCtx) =>168export const ConvexControlPlaneActorLive = (ctx: ActionCtx) =>
141 ControlPlaneActorResolverLive({169 ControlPlaneActorResolverLive({
170 resolveActor: (input) =>
171 Effect.gen(function* () {
172 const principal = yield* requirePrincipalFromHeaders(input.headers);
173
174 const organizationMemberships = yield* runQueryEffect(
175 "organizationMembership.list",
176 () =>
177 ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
178 accountId: principal.accountId,
179 }),
180 );
181
182 const organizationIds = organizationMemberships.map(
183 (membership) => membership.organizationId,
184 );
185
186 const workspaces = yield* runQueryEffect("workspace.list", () =>
187 ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {
188 accountId: principal.accountId,
189 organizationIds,
190 }),
191 );
192
193 const workspaceMemberships = workspaces.flatMap((workspace) =>
194 deriveWorkspaceMembershipsForPrincipal({
195 principalAccountId: principal.accountId,
196 workspaceId: workspace.id,
197 workspace,
198 organizationMemberships,
199 }),
200 );
201
202 return yield* makeActor({
203 principal,
204 workspaceMemberships,
205 organizationMemberships,
206 });
207 }),
142 resolveWorkspaceActor: (input) =>208 resolveWorkspaceActor: (input) =>
143 Effect.gen(function* () {209 Effect.gen(function* () {
144 const principal = yield* requirePrincipalFromHeaders(input.headers);210 const principal = yield* requirePrincipalFromHeaders(input.headers);
v2/apps/convex/convex/control_plane/credentials.ts+231−0
@@ -0,0 +1,231 @@
1import { type UpsertCredentialBindingPayload } from "@executor-v2/management-api";
2import {
3 SourceCredentialBindingSchema,
4 type SourceCredentialBinding,
5} from "@executor-v2/schema";
6import { v } from "convex/values";
7import * as Schema from "effect/Schema";
8
9import {
10 mutation,
11 query,
12 type MutationCtx,
13 type QueryCtx,
14} from "../_generated/server";
15
16const decodeSourceCredentialBinding = Schema.decodeUnknownSync(
17 SourceCredentialBindingSchema,
18);
19
20const stripConvexSystemFields = (
21 value: Record<string, unknown>,
22): Record<string, unknown> => {
23 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
24 return rest;
25};
26
27const toSourceCredentialBinding = (
28 document: Record<string, unknown>,
29): SourceCredentialBinding =>
30 decodeSourceCredentialBinding(stripConvexSystemFields(document));
31
32const credentialProviderValidator = v.union(
33 v.literal("api_key"),
34 v.literal("bearer"),
35 v.literal("oauth2"),
36 v.literal("custom"),
37);
38
39const credentialScopeTypeValidator = v.union(
40 v.literal("workspace"),
41 v.literal("organization"),
42 v.literal("account"),
43);
44
45const 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
59const 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
75const 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
85export 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
121export 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
192export 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
3import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api";
4import { v } from "convex/values";
5import * as Effect from "effect/Effect";
6
7import { internal } from "../_generated/api";
8import { internalAction } from "../_generated/server";
9
10const runtimeInternal = internal as any;
11
12const extractorVersion = "openapi_v2";
13const writeBatchSize = 500;
14
15const formatError = (cause: unknown): string =>
16 cause instanceof Error ? cause.message : String(cause);
17
18export 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 @@
1import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api";
2import { v } from "convex/values";
3import * as Effect from "effect/Effect";
4
5import { api, internal } from "../_generated/api";
6import { action, internalMutation, internalQuery } from "../_generated/server";
7
8const runtimeApi = api as any;
9const runtimeInternal = internal as any;
10
11const defaultExtractorVersion = "openapi_mvp_v1";
12const defaultToolBatchSize = 100;
13const maxToolBatchSize = 1000;
14
15
16export 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
61export 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
136export 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
173export 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
204export 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 @@
1import { type UpsertOrganizationPayload } from "@executor-v2/management-api";
2import { OrganizationSchema, type Organization } from "@executor-v2/schema";
3import { v } from "convex/values";
4import * as Schema from "effect/Schema";
5
6import { mutation, query } from "../_generated/server";
7
8const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema);
9
10const stripConvexSystemFields = (
11 value: Record<string, unknown>,
12): Record<string, unknown> => {
13 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14 return rest;
15};
16
17const toOrganization = (document: Record<string, unknown>): Organization =>
18 decodeOrganization(stripConvexSystemFields(document));
19
20const organizationStatusValidator = v.union(
21 v.literal("active"),
22 v.literal("suspended"),
23 v.literal("archived"),
24);
25
26const 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
40export 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
51export 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 @@
1import { type UpsertPolicyPayload } from "@executor-v2/management-api";
2import { PolicySchema, type Policy } from "@executor-v2/schema";
3import { v } from "convex/values";
4import * as Schema from "effect/Schema";
5
6import { mutation, query } from "../_generated/server";
7
8const decodePolicy = Schema.decodeUnknownSync(PolicySchema);
9
10const stripConvexSystemFields = (
11 value: Record<string, unknown>,
12): Record<string, unknown> => {
13 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14 return rest;
15};
16
17const toPolicy = (document: Record<string, unknown>): Policy =>
18 decodePolicy(stripConvexSystemFields(document));
19
20const policyDecisionValidator = v.union(
21 v.literal("allow"),
22 v.literal("require_approval"),
23 v.literal("deny"),
24);
25
26const 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
38export 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
54export 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
96export 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
38 }),38 }),
39 catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause),39 catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause),
40 }),40 }),
41 listCredentialBindings: (workspaceId) =>
42 Effect.tryPromise({
43 try: () =>
44 ctx.runQuery(api.controlPlane.listCredentialBindings, {
45 workspaceId,
46 }),
47 catch: (cause) =>
48 toSourceStoreError("controlPlane.listCredentialBindings", cause),
49 }),
50 upsertCredentialBinding: (input) =>
51 Effect.tryPromise({
52 try: () =>
53 ctx.runMutation(api.controlPlane.upsertCredentialBinding, {
54 workspaceId: input.workspaceId,
55 payload: input.payload,
56 }),
57 catch: (cause) =>
58 toSourceStoreError("controlPlane.upsertCredentialBinding", cause),
59 }),
60 removeCredentialBinding: (input) =>
61 Effect.tryPromise({
62 try: () =>
63 ctx.runMutation(api.controlPlane.removeCredentialBinding, {
64 workspaceId: input.workspaceId,
65 credentialBindingId: input.credentialBindingId,
66 }),
67 catch: (cause) =>
68 toSourceStoreError("controlPlane.removeCredentialBinding", cause),
69 }),
70 listPolicies: (workspaceId) =>
71 Effect.tryPromise({
72 try: () =>
73 ctx.runQuery(api.controlPlane.listPolicies, {
74 workspaceId,
75 }),
76 catch: (cause) => toSourceStoreError("controlPlane.listPolicies", cause),
77 }),
78 upsertPolicy: (input) =>
79 Effect.tryPromise({
80 try: () =>
81 ctx.runMutation(api.controlPlane.upsertPolicy, {
82 workspaceId: input.workspaceId,
83 payload: input.payload,
84 }),
85 catch: (cause) => toSourceStoreError("controlPlane.upsertPolicy", cause),
86 }),
87 removePolicy: (input) =>
88 Effect.tryPromise({
89 try: () =>
90 ctx.runMutation(api.controlPlane.removePolicy, {
91 workspaceId: input.workspaceId,
92 policyId: input.policyId,
93 }),
94 catch: (cause) => toSourceStoreError("controlPlane.removePolicy", cause),
95 }),
96 listOrganizations: () =>
97 Effect.tryPromise({
98 try: () => ctx.runQuery(api.controlPlane.listOrganizations, {}),
99 catch: (cause) => toSourceStoreError("controlPlane.listOrganizations", cause),
100 }),
101 upsertOrganization: (input) =>
102 Effect.tryPromise({
103 try: () =>
104 ctx.runMutation(api.controlPlane.upsertOrganization, {
105 payload: input.payload,
106 }),
107 catch: (cause) => toSourceStoreError("controlPlane.upsertOrganization", cause),
108 }),
109 listWorkspaces: () =>
110 Effect.tryPromise({
111 try: () => ctx.runQuery(api.controlPlane.listWorkspaces, {}),
112 catch: (cause) => toSourceStoreError("controlPlane.listWorkspaces", cause),
113 }),
114 upsertWorkspace: (input) =>
115 Effect.tryPromise({
116 try: () =>
117 ctx.runMutation(api.controlPlane.upsertWorkspace, {
118 payload: input.payload,
119 }),
120 catch: (cause) => toSourceStoreError("controlPlane.upsertWorkspace", cause),
121 }),
122 listWorkspaceTools: (workspaceId) =>
123 Effect.tryPromise({
124 try: () =>
125 ctx.runQuery(api.controlPlane.listWorkspaceTools, {
126 workspaceId,
127 }),
128 catch: (cause) => toSourceStoreError("controlPlane.listWorkspaceTools", cause),
129 }),
130 listSourceTools: (input) =>
131 Effect.tryPromise({
132 try: () =>
133 ctx.runQuery(api.controlPlane.listSourceTools, {
134 workspaceId: input.workspaceId,
135 sourceId: input.sourceId,
136 }),
137 catch: (cause) => toSourceStoreError("controlPlane.listSourceTools", cause),
138 }),
139 listStorageInstances: (workspaceId) =>
140 Effect.tryPromise({
141 try: () =>
142 ctx.runQuery(api.controlPlane.listStorageInstances, {
143 workspaceId,
144 }),
145 catch: (cause) =>
146 toSourceStoreError("controlPlane.listStorageInstances", cause),
147 }),
148 openStorageInstance: (input) =>
149 Effect.tryPromise({
150 try: () =>
151 ctx.runMutation(api.controlPlane.openStorageInstance, {
152 workspaceId: input.workspaceId,
153 payload: input.payload,
154 }),
155 catch: (cause) =>
156 toSourceStoreError("controlPlane.openStorageInstance", cause),
157 }),
158 closeStorageInstance: (input) =>
159 Effect.tryPromise({
160 try: () =>
161 ctx.runMutation(api.controlPlane.closeStorageInstance, {
162 workspaceId: input.workspaceId,
163 storageInstanceId: input.storageInstanceId,
164 }),
165 catch: (cause) =>
166 toSourceStoreError("controlPlane.closeStorageInstance", cause),
167 }),
168 removeStorageInstance: (input) =>
169 Effect.tryPromise({
170 try: () =>
171 ctx.runMutation(api.controlPlane.removeStorageInstance, {
172 workspaceId: input.workspaceId,
173 storageInstanceId: input.storageInstanceId,
174 }),
175 catch: (cause) =>
176 toSourceStoreError("controlPlane.removeStorageInstance", cause),
177 }),
178 listStorageDirectory: (input) =>
179 Effect.tryPromise({
180 try: () =>
181 ctx.runQuery(api.controlPlane.listStorageDirectory, {
182 workspaceId: input.workspaceId,
183 storageInstanceId: input.storageInstanceId,
184 payload: input.payload,
185 }),
186 catch: (cause) =>
187 toSourceStoreError("controlPlane.listStorageDirectory", cause),
188 }),
189 readStorageFile: (input) =>
190 Effect.tryPromise({
191 try: () =>
192 ctx.runQuery(api.controlPlane.readStorageFile, {
193 workspaceId: input.workspaceId,
194 storageInstanceId: input.storageInstanceId,
195 payload: input.payload,
196 }),
197 catch: (cause) => toSourceStoreError("controlPlane.readStorageFile", cause),
198 }),
199 listStorageKv: (input) =>
200 Effect.tryPromise({
201 try: () =>
202 ctx.runQuery(api.controlPlane.listStorageKv, {
203 workspaceId: input.workspaceId,
204 storageInstanceId: input.storageInstanceId,
205 payload: input.payload,
206 }),
207 catch: (cause) => toSourceStoreError("controlPlane.listStorageKv", cause),
208 }),
209 queryStorageSql: (input) =>
210 Effect.tryPromise({
211 try: () =>
212 ctx.runMutation(api.controlPlane.queryStorageSql, {
213 workspaceId: input.workspaceId,
214 storageInstanceId: input.storageInstanceId,
215 payload: input.payload,
216 }),
217 catch: (cause) => toSourceStoreError("controlPlane.queryStorageSql", cause),
218 }),
41 listApprovals: (workspaceId) =>219 listApprovals: (workspaceId) =>
42 Effect.tryPromise({220 Effect.tryPromise({
43 try: () =>221 try: () =>
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
3import { v } from "convex/values";3import { v } from "convex/values";
4import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5 5
6import { mutation, query } from "../_generated/server";6import { api } from "../_generated/api";
7import { internalQuery, internalMutation, mutation, query } from "../_generated/server";
7 8
8const decodeSource = Schema.decodeUnknownSync(SourceSchema);9const decodeSource = Schema.decodeUnknownSync(SourceSchema);
9 10
@@ -109,10 +110,63 @@ export const upsertSource = mutation({ · 99 unmodified lines
109 await ctx.db.insert("sources", source);110 await ctx.db.insert("sources", source);
110 }111 }
111 112
113 await ctx.scheduler.runAfter(
114 0,
115 (api as any).control_plane.openapi_ingest.ingestSourceArtifact,
116 {
117 workspaceId: args.workspaceId,
118 sourceId,
119 },
120 );
121
112 return source;122 return source;
113 },123 },
114});124});
115 125
126export 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
144export 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
116export const removeSource = mutation({170export const removeSource = mutation({
117 args: {171 args: {
118 workspaceId: v.string(),172 workspaceId: v.string(),
@@ -133,6 +187,17 @@ export const removeSource = mutation({ · 14 unmodified lines
133 return { removed: false };187 return { removed: false };
134 }188 }
135 189
190 const existingBinding = await ctx.db
191 .query("openApiSourceArtifactBindings")
192 .withIndex("by_workspaceId_sourceId", (q) =>
193 q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId)
194 )
195 .unique();
196
197 if (existingBinding) {
198 await ctx.db.delete(existingBinding._id);
199 }
200
136 await ctx.db.delete(existing._id);201 await ctx.db.delete(existing._id);
137 202
138 return { removed: true };203 return { removed: true };
v2/apps/convex/convex/control_plane/storage.ts+1069−0
@@ -0,0 +1,1069 @@
1import {
2 type ListStorageDirectoryResult,
3 type ListStorageKvResult,
4 type OpenStorageInstancePayload,
5 type QueryStorageSqlResult,
6 type ReadStorageFileResult,
7} from "@executor-v2/management-api";
8import { StorageInstanceSchema, type StorageInstance } from "@executor-v2/schema";
9import { v } from "convex/values";
10import * as Schema from "effect/Schema";
11
12import {
13 internalMutation,
14 mutation,
15 query,
16 type MutationCtx,
17 type QueryCtx,
18} from "../_generated/server";
19
20const decodeStorageInstance = Schema.decodeUnknownSync(StorageInstanceSchema);
21
22const 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
31type StorageFileEntry = typeof StorageFileEntrySchema.Type;
32const decodeStorageFileEntry = Schema.decodeUnknownSync(StorageFileEntrySchema);
33
34const 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
42type StorageKvEntry = typeof StorageKvEntrySchema.Type;
43const decodeStorageKvEntry = Schema.decodeUnknownSync(StorageKvEntrySchema);
44
45const 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
53type StorageSqlKvEntry = typeof StorageSqlKvEntrySchema.Type;
54const decodeStorageSqlKvEntry = Schema.decodeUnknownSync(StorageSqlKvEntrySchema);
55
56const DEFAULT_KV_LIMIT = 100;
57const MAX_KV_LIMIT = 1000;
58const DEFAULT_SQL_MAX_ROWS = 200;
59const MAX_SQL_MAX_ROWS = 5000;
60
61const stripConvexSystemFields = (
62 value: Record<string, unknown>,
63): Record<string, unknown> => {
64 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
65 return rest;
66};
67
68const toStorageInstance = (document: Record<string, unknown>): StorageInstance =>
69 decodeStorageInstance(stripConvexSystemFields(document));
70
71const toStorageFileEntry = (document: Record<string, unknown>): StorageFileEntry =>
72 decodeStorageFileEntry(stripConvexSystemFields(document));
73
74const toStorageKvEntry = (document: Record<string, unknown>): StorageKvEntry =>
75 decodeStorageKvEntry(stripConvexSystemFields(document));
76
77const toStorageSqlKvEntry = (document: Record<string, unknown>): StorageSqlKvEntry =>
78 decodeStorageSqlKvEntry(stripConvexSystemFields(document));
79
80const storageScopeTypeValidator = v.union(
81 v.literal("scratch"),
82 v.literal("account"),
83 v.literal("workspace"),
84 v.literal("organization"),
85);
86
87const storageDurabilityValidator = v.union(
88 v.literal("ephemeral"),
89 v.literal("durable"),
90);
91
92const storageProviderValidator = v.union(
93 v.literal("agentfs-local"),
94 v.literal("agentfs-cloudflare"),
95);
96
97const readStorageEncodingValidator = v.union(
98 v.literal("utf8"),
99 v.literal("base64"),
100);
101
102const 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
113const 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
129const 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
142const 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
174const 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
187const 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
197const parseJsonOrString = (value: string): unknown => {
198 try {
199 return JSON.parse(value) as unknown;
200 } catch {
201 return value;
202 }
203};
204
205const 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
238export 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
291export 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
340const decodeSqlStringLiteral = (value: string): string => value.replaceAll("''", "'");
341
342const 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
369const 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
399type SqlWrite =
400 | {
401 type: "upsert";
402 key: string;
403 value: string;
404 insertOnly: boolean;
405 }
406 | {
407 type: "delete";
408 key: string;
409 };
410
411const 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
550export 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
587export 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
651export 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
690export 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
752export 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
872export 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
917export 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
964export 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 @@
1import { type SourceToolSummary } from "@executor-v2/management-api";
2import {
3 SourceSchema,
4 type OpenApiHttpMethod,
5 type Source,
6} from "@executor-v2/schema";
7import { v } from "convex/values";
8import * as Schema from "effect/Schema";
9
10import { query } from "../_generated/server";
11
12const decodeSource = Schema.decodeUnknownSync(SourceSchema);
13
14const 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
28type OpenApiArtifactToolRow = typeof OpenApiArtifactToolRowSchema.Type;
29
30const decodeOpenApiArtifactToolRow = Schema.decodeUnknownSync(OpenApiArtifactToolRowSchema);
31
32const stripConvexSystemFields = (
33 value: Record<string, unknown>,
34): Record<string, unknown> => {
35 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
36 return rest;
37};
38
39const toSource = (document: Record<string, unknown>): Source =>
40 decodeSource(stripConvexSystemFields(document));
41
42const toOpenApiArtifactToolRow = (
43 document: Record<string, unknown>,
44): OpenApiArtifactToolRow =>
45 decodeOpenApiArtifactToolRow(stripConvexSystemFields(document));
46
47const 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
62const 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
81export 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
126export 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 @@
1import { type UpsertWorkspacePayload } from "@executor-v2/management-api";
2import { WorkspaceSchema, type Workspace } from "@executor-v2/schema";
3import { v } from "convex/values";
4import * as Schema from "effect/Schema";
5
6import { mutation, query } from "../_generated/server";
7
8const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
9
10const stripConvexSystemFields = (
11 value: Record<string, unknown>,
12): Record<string, unknown> => {
13 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14 return rest;
15};
16
17const toWorkspace = (document: Record<string, unknown>): Workspace =>
18 decodeWorkspace(stripConvexSystemFields(document));
19
20const 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
32export 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
43export 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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { extractOpenApiManifest } from "@executor-v2/management-api";
2import { convexTest } from "convex-test";3import { convexTest } from "convex-test";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4 5
@@ -16,6 +17,10 @@ const setup = () => · 11 unmodified lines
16 "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),17 "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),
17 "./source_tool_registry.ts": () => import("./source_tool_registry"),18 "./source_tool_registry.ts": () => import("./source_tool_registry"),
18 "./task_runs.ts": () => import("./task_runs"),19 "./task_runs.ts": () => import("./task_runs"),
20 "./control_plane/storage.ts": () => import("./control_plane/storage"),
21 "./control_plane/sources.ts": () => import("./control_plane/sources"),
22 "./control_plane/openapi_ingest_mvp.ts": () => import("./control_plane/openapi_ingest_mvp"),
23 "./control_plane/openapi_ingest.ts": () => import("./control_plane/openapi_ingest"),
19 "./controlPlane.ts": () => import("./controlPlane"),24 "./controlPlane.ts": () => import("./controlPlane"),
20 "./_generated/api.js": () => import("./_generated/api.js"),25 "./_generated/api.js": () => import("./_generated/api.js"),
21 });26 });
@@ -57,6 +62,8 @@ describe("Convex executor and control-plane", () => { · 35 unmodified lines
57 name: string;62 name: string;
58 };63 };
59 64
65 yield* Effect.tryPromise(() => t.finishInProgressScheduledFunctions());
66
60 expect(added.id).toBe("src_1");67 expect(added.id).toBe("src_1");
61 expect(added.workspaceId).toBe("ws_1");68 expect(added.workspaceId).toBe("ws_1");
62 expect(added.name).toBe("Weather");69 expect(added.name).toBe("Weather");
@@ -93,6 +100,455 @@ describe("Convex executor and control-plane", () => { · 30 unmodified lines
93 }),100 }),
94 );101 );
95 102
103 it.effect("upserts, lists, and removes credentials, policies, and storage", () =>
104 Effect.gen(function* () {
105 const t = setup();
106
107 const addedCredential = (yield* Effect.tryPromise(() =>
108 t.mutation(api.controlPlane.upsertCredentialBinding, {
109 workspaceId: "ws_1",
110 payload: {
111 id: "credential_binding_1",
112 credentialId: "cred_1",
113 scopeType: "workspace",
114 sourceKey: "github",
115 provider: "bearer",
116 secretRef: "secret://github/token",
117 accountId: null,
118 additionalHeadersJson: null,
119 boundAuthFingerprint: null,
120 },
121 }),
122 )) as {
123 id: string;
124 workspaceId: string | null;
125 };
126
127 expect(addedCredential.id).toBe("credential_binding_1");
128 expect(addedCredential.workspaceId).toBe("ws_1");
129
130 const listedCredentials = (yield* Effect.tryPromise(() =>
131 t.query(api.controlPlane.listCredentialBindings, {
132 workspaceId: "ws_1",
133 }),
134 )) as Array<{
135 id: string;
136 }>;
137
138 expect(listedCredentials).toHaveLength(1);
139 expect(listedCredentials[0]?.id).toBe("credential_binding_1");
140
141 const removedCredential = (yield* Effect.tryPromise(() =>
142 t.mutation(api.controlPlane.removeCredentialBinding, {
143 workspaceId: "ws_1",
144 credentialBindingId: "credential_binding_1",
145 }),
146 )) as {
147 removed: boolean;
148 };
149
150 expect(removedCredential.removed).toBe(true);
151
152 const listedCredentialsAfterRemove = (yield* Effect.tryPromise(() =>
153 t.query(api.controlPlane.listCredentialBindings, {
154 workspaceId: "ws_1",
155 }),
156 )) as Array<unknown>;
157
158 expect(listedCredentialsAfterRemove).toHaveLength(0);
159
160 const addedPolicy = (yield* Effect.tryPromise(() =>
161 t.mutation(api.controlPlane.upsertPolicy, {
162 workspaceId: "ws_1",
163 payload: {
164 id: "pol_1",
165 toolPathPattern: "github.repos.*",
166 decision: "require_approval",
167 },
168 }),
169 )) as {
170 id: string;
171 workspaceId: string;
172 };
173
174 expect(addedPolicy.id).toBe("pol_1");
175 expect(addedPolicy.workspaceId).toBe("ws_1");
176
177 const listedPolicies = (yield* Effect.tryPromise(() =>
178 t.query(api.controlPlane.listPolicies, {
179 workspaceId: "ws_1",
180 }),
181 )) as Array<{
182 id: string;
183 }>;
184
185 expect(listedPolicies).toHaveLength(1);
186 expect(listedPolicies[0]?.id).toBe("pol_1");
187
188 const removedPolicy = (yield* Effect.tryPromise(() =>
189 t.mutation(api.controlPlane.removePolicy, {
190 workspaceId: "ws_1",
191 policyId: "pol_1",
192 }),
193 )) as {
194 removed: boolean;
195 };
196
197 expect(removedPolicy.removed).toBe(true);
198
199 const listedPoliciesAfterRemove = (yield* Effect.tryPromise(() =>
200 t.query(api.controlPlane.listPolicies, {
201 workspaceId: "ws_1",
202 }),
203 )) as Array<unknown>;
204
205 expect(listedPoliciesAfterRemove).toHaveLength(0);
206
207 const openedStorage = (yield* Effect.tryPromise(() =>
208 t.mutation(api.controlPlane.openStorageInstance, {
209 workspaceId: "ws_1",
210 payload: {
211 scopeType: "workspace",
212 durability: "ephemeral",
213 provider: "agentfs-local",
214 purpose: "test storage",
215 ttlHours: 1,
216 sessionId: "session_1",
217 },
218 }),
219 )) as {
220 id: string;
221 status: string;
222 };
223
224 expect(openedStorage.status).toBe("active");
225
226 const listedStorage = (yield* Effect.tryPromise(() =>
227 t.query(api.controlPlane.listStorageInstances, {
228 workspaceId: "ws_1",
229 }),
230 )) as Array<{
231 id: string;
232 }>;
233
234 expect(listedStorage).toHaveLength(1);
235 expect(listedStorage[0]?.id).toBe(openedStorage.id);
236
237 yield* Effect.tryPromise(() =>
238 t.mutation(runtimeInternal.control_plane.storage.upsertStorageFileEntry, {
239 workspaceId: "ws_1",
240 storageInstanceId: openedStorage.id,
241 payload: {
242 path: "/notes/today.txt",
243 content: "hello convex storage",
244 },
245 }),
246 );
247
248 const listedDirectory = (yield* Effect.tryPromise(() =>
249 t.query(api.controlPlane.listStorageDirectory, {
250 workspaceId: "ws_1",
251 storageInstanceId: openedStorage.id,
252 payload: {
253 path: "/",
254 },
255 }),
256 )) as {
257 path: string;
258 entries: ReadonlyArray<{
259 name: string;
260 path: string;
261 kind: "file" | "directory";
262 }>;
263 };
264
265 expect(listedDirectory.path).toBe("/");
266 expect(listedDirectory.entries).toHaveLength(1);
267 expect(listedDirectory.entries[0]?.name).toBe("notes");
268 expect(listedDirectory.entries[0]?.kind).toBe("directory");
269
270 const listedNestedDirectory = (yield* Effect.tryPromise(() =>
271 t.query(api.controlPlane.listStorageDirectory, {
272 workspaceId: "ws_1",
273 storageInstanceId: openedStorage.id,
274 payload: {
275 path: "/notes",
276 },
277 }),
278 )) as {
279 path: string;
280 entries: ReadonlyArray<{
281 name: string;
282 path: string;
283 kind: "file" | "directory";
284 }>;
285 };
286
287 expect(listedNestedDirectory.path).toBe("/notes");
288 expect(listedNestedDirectory.entries).toHaveLength(1);
289 expect(listedNestedDirectory.entries[0]?.name).toBe("today.txt");
290 expect(listedNestedDirectory.entries[0]?.kind).toBe("file");
291
292 const readStorageFile = (yield* Effect.tryPromise(() =>
293 t.query(api.controlPlane.readStorageFile, {
294 workspaceId: "ws_1",
295 storageInstanceId: openedStorage.id,
296 payload: {
297 path: "/notes/today.txt",
298 encoding: "utf8",
299 },
300 }),
301 )) as {
302 content: string;
303 };
304
305 expect(readStorageFile.content).toBe("hello convex storage");
306
307 yield* Effect.tryPromise(() =>
308 t.mutation(runtimeInternal.control_plane.storage.upsertStorageKvEntry, {
309 workspaceId: "ws_1",
310 storageInstanceId: openedStorage.id,
311 payload: {
312 key: "feature.enabled",
313 valueJson: "true",
314 },
315 }),
316 );
317
318 const listedKv = (yield* Effect.tryPromise(() =>
319 t.query(api.controlPlane.listStorageKv, {
320 workspaceId: "ws_1",
321 storageInstanceId: openedStorage.id,
322 payload: {
323 prefix: "feature.",
324 },
325 }),
326 )) as {
327 items: Array<{
328 key: string;
329 value: unknown;
330 }>;
331 };
332
333 expect(listedKv.items).toHaveLength(1);
334 expect(listedKv.items[0]?.key).toBe("feature.enabled");
335 expect(listedKv.items[0]?.value).toBe(true);
336
337 yield* Effect.tryPromise(() =>
338 t.mutation(api.controlPlane.queryStorageSql, {
339 workspaceId: "ws_1",
340 storageInstanceId: openedStorage.id,
341 payload: {
342 sql: "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
343 },
344 }),
345 );
346
347 yield* Effect.tryPromise(() =>
348 t.mutation(api.controlPlane.queryStorageSql, {
349 workspaceId: "ws_1",
350 storageInstanceId: openedStorage.id,
351 payload: {
352 sql: "INSERT INTO kv_store (key, value) VALUES ('hello', 'world');",
353 },
354 }),
355 );
356
357 const queriedSql = (yield* Effect.tryPromise(() =>
358 t.mutation(api.controlPlane.queryStorageSql, {
359 workspaceId: "ws_1",
360 storageInstanceId: openedStorage.id,
361 payload: {
362 sql: "SELECT key, value FROM kv_store LIMIT 10;",
363 },
364 }),
365 )) as {
366 rowCount: number;
367 columns: Array<string>;
368 rows: Array<Record<string, unknown>>;
369 };
370
371 expect(queriedSql.rowCount).toBe(1);
372 expect(queriedSql.columns).toEqual(["key", "value"]);
373 expect(queriedSql.rows[0]).toEqual({ key: "hello", value: "world" });
374
375 const closedStorage = (yield* Effect.tryPromise(() =>
376 t.mutation(api.controlPlane.closeStorageInstance, {
377 workspaceId: "ws_1",
378 storageInstanceId: openedStorage.id,
379 }),
380 )) as {
381 id: string;
382 status: string;
383 };
384
385 expect(closedStorage.id).toBe(openedStorage.id);
386 expect(closedStorage.status).toBe("closed");
387
388 const removedStorage = (yield* Effect.tryPromise(() =>
389 t.mutation(api.controlPlane.removeStorageInstance, {
390 workspaceId: "ws_1",
391 storageInstanceId: openedStorage.id,
392 }),
393 )) as {
394 removed: boolean;
395 };
396
397 expect(removedStorage.removed).toBe(true);
398
399 const listedStorageAfterRemove = (yield* Effect.tryPromise(() =>
400 t.query(api.controlPlane.listStorageInstances, {
401 workspaceId: "ws_1",
402 }),
403 )) as Array<unknown>;
404
405 expect(listedStorageAfterRemove).toHaveLength(0);
406 }),
407 );
408
409 it.effect("upserts and lists organizations/workspaces and tool views", () =>
410 Effect.gen(function* () {
411 const t = setup();
412
413 const organization = (yield* Effect.tryPromise(() =>
414 t.mutation(api.controlPlane.upsertOrganization, {
415 payload: {
416 id: "org_1",
417 slug: "acme",
418 name: "Acme Inc",
419 status: "active",
420 },
421 }),
422 )) as {
423 id: string;
424 name: string;
425 };
426
427 expect(organization.id).toBe("org_1");
428 expect(organization.name).toBe("Acme Inc");
429
430 const organizations = (yield* Effect.tryPromise(() =>
431 t.query(api.controlPlane.listOrganizations, {}),
432 )) as Array<{
433 id: string;
434 }>;
435
436 expect(organizations).toHaveLength(1);
437 expect(organizations[0]?.id).toBe("org_1");
438
439 const workspace = (yield* Effect.tryPromise(() =>
440 t.mutation(api.controlPlane.upsertWorkspace, {
441 payload: {
442 id: "ws_1",
443 organizationId: "org_1",
444 name: "Primary Workspace",
445 },
446 }),
447 )) as {
448 id: string;
449 organizationId: string | null;
450 };
451
452 expect(workspace.id).toBe("ws_1");
453 expect(workspace.organizationId).toBe("org_1");
454
455 const workspaces = (yield* Effect.tryPromise(() =>
456 t.query(api.controlPlane.listWorkspaces, {}),
457 )) as Array<{
458 id: string;
459 }>;
460
461 expect(workspaces).toHaveLength(1);
462 expect(workspaces[0]?.id).toBe("ws_1");
463
464 yield* Effect.tryPromise(() =>
465 t.mutation(api.controlPlane.upsertSource, {
466 workspaceId: "ws_1",
467 payload: {
468 id: "src_1",
469 name: "Weather API",
470 kind: "openapi",
471 endpoint: "https://example.com/openapi.json",
472 enabled: true,
473 configJson: "{}",
474 status: "draft",
475 sourceHash: null,
476 lastError: null,
477 },
478 }),
479 );
480 yield* Effect.tryPromise(() => t.finishInProgressScheduledFunctions());
481
482 const manifest = yield* extractOpenApiManifest("Weather API", {
483 openapi: "3.0.0",
484 info: {
485 title: "Weather API",
486 version: "1.0.0",
487 },
488 paths: {
489 "/weather": {
490 get: {
491 operationId: "getWeather",
492 responses: {
493 "200": {
494 description: "ok",
495 },
496 },
497 },
498 },
499 },
500 });
501
502 const now = Date.now();
503 yield* Effect.tryPromise(() =>
504 t.mutation(runtimeInternal.source_tool_registry.upsertToolArtifactForSource, {
505 artifact: {
506 id: "tool_artifact_src_1",
507 workspaceId: "ws_1",
508 sourceId: "src_1",
509 sourceHash: manifest.sourceHash,
510 toolCount: manifest.tools.length,
511 manifestJson: JSON.stringify(manifest),
512 createdAt: now,
513 updatedAt: now,
514 },
515 }),
516 );
517
518 const workspaceTools = (yield* Effect.tryPromise(() =>
519 t.query(api.controlPlane.listWorkspaceTools, {
520 workspaceId: "ws_1",
521 }),
522 )) as Array<{
523 sourceId: string;
524 }>;
525
526 expect(workspaceTools).toHaveLength(1);
527 expect(workspaceTools[0]?.sourceId).toBe("src_1");
528
529 const sourceTools = (yield* Effect.tryPromise(() =>
530 t.query(api.controlPlane.listSourceTools, {
531 workspaceId: "ws_1",
532 sourceId: "src_1",
533 }),
534 )) as Array<{
535 sourceId: string;
536 }>;
537
538 expect(sourceTools).toHaveLength(1);
539 expect(sourceTools[0]?.sourceId).toBe("src_1");
540
541 const sourceToolsFromWrongWorkspace = (yield* Effect.tryPromise(() =>
542 t.query(api.controlPlane.listSourceTools, {
543 workspaceId: "ws_2",
544 sourceId: "src_1",
545 }),
546 )) as Array<unknown>;
547
548 expect(sourceToolsFromWrongWorkspace).toHaveLength(0);
549 }),
550 );
551
96 it.effect("persists approval state for runtime tool calls", () =>552 it.effect("persists approval state for runtime tool calls", () =>
97 Effect.gen(function* () {553 Effect.gen(function* () {
98 const t = setup();554 const t = setup();
v2/apps/convex/convex/schema.ts+88−4
@@ -9,11 +9,70 @@ import { · 8 unmodified lines
9 ProfileSchema,9 ProfileSchema,
10 SourceCredentialBindingSchema,10 SourceCredentialBindingSchema,
11 SourceSchema,11 SourceSchema,
12 StorageInstanceSchema,
12 SyncStateSchema,13 SyncStateSchema,
13 TaskRunSchema,14 TaskRunSchema,
14 ToolArtifactSchema,
15 WorkspaceSchema,15 WorkspaceSchema,
16} from "@executor-v2/schema";16} from "@executor-v2/schema";
17import * as Schema from "effect/Schema";
18
19const 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
28const 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
36const 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
44const 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
53const 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
67const OpenApiSourceArtifactBindingSchema = Schema.Struct({
68 id: Schema.String,
69 workspaceId: Schema.String,
70 sourceId: Schema.String,
71 artifactId: Schema.String,
72 sourceHash: Schema.String,
73 extractorVersion: Schema.String,
74 updatedAt: Schema.Number,
75});
17 76
18export const executorConfectSchema = defineSchema({77export const executorConfectSchema = defineSchema({
19 profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]),78 profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]),
@@ -27,14 +86,23 @@ export const executorConfectSchema = defineSchema({ · 7 unmodified lines
27 .index("by_organizationId_accountId", ["organizationId", "accountId"]),86 .index("by_organizationId_accountId", ["organizationId", "accountId"]),
28 workspaces: defineTable(WorkspaceSchema)87 workspaces: defineTable(WorkspaceSchema)
29 .index("by_domainId", ["id"])88 .index("by_domainId", ["id"])
30 .index("by_organizationId", ["organizationId"]),89 .index("by_organizationId", ["organizationId"])
90 .index("by_createdByAccountId", ["createdByAccountId"]),
31 sources: defineTable(SourceSchema)91 sources: defineTable(SourceSchema)
32 .index("by_domainId", ["id"])92 .index("by_domainId", ["id"])
33 .index("by_workspaceId", ["workspaceId"]),93 .index("by_workspaceId", ["workspaceId"]),
34 toolArtifacts: defineTable(ToolArtifactSchema)94 openApiArtifacts: defineTable(OpenApiArtifactSchema)
95 .index("by_domainId", ["id"])
96 .index("by_sourceHash_extractorVersion", ["sourceHash", "extractorVersion"]),
97 openApiArtifactTools: defineTable(OpenApiArtifactToolSchema)
98 .index("by_domainId", ["id"])
99 .index("by_artifactId", ["artifactId"])
100 .index("by_artifactId_toolId", ["artifactId", "toolId"]),
101 openApiSourceArtifactBindings: defineTable(OpenApiSourceArtifactBindingSchema)
35 .index("by_domainId", ["id"])102 .index("by_domainId", ["id"])
36 .index("by_workspaceId", ["workspaceId"])103 .index("by_workspaceId", ["workspaceId"])
37 .index("by_sourceId", ["sourceId"]),104 .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"])
105 .index("by_artifactId", ["artifactId"]),
38 sourceCredentialBindings: defineTable(SourceCredentialBindingSchema)106 sourceCredentialBindings: defineTable(SourceCredentialBindingSchema)
39 .index("by_domainId", ["id"])107 .index("by_domainId", ["id"])
40 .index("by_credentialId", ["credentialId"])108 .index("by_credentialId", ["credentialId"])
@@ -51,6 +119,22 @@ export const executorConfectSchema = defineSchema({ · 10 unmodified lines
51 policies: defineTable(PolicySchema)119 policies: defineTable(PolicySchema)
52 .index("by_domainId", ["id"])120 .index("by_domainId", ["id"])
53 .index("by_workspaceId", ["workspaceId"]),121 .index("by_workspaceId", ["workspaceId"]),
122 storageInstances: defineTable(StorageInstanceSchema)
123 .index("by_domainId", ["id"])
124 .index("by_workspaceId", ["workspaceId"])
125 .index("by_organizationId", ["organizationId"]),
126 storageFileEntries: defineTable(StorageFileEntrySchema)
127 .index("by_domainId", ["id"])
128 .index("by_storageInstanceId", ["storageInstanceId"])
129 .index("by_storageInstanceId_path", ["storageInstanceId", "path"]),
130 storageKvEntries: defineTable(StorageKvEntrySchema)
131 .index("by_domainId", ["id"])
132 .index("by_storageInstanceId", ["storageInstanceId"])
133 .index("by_storageInstanceId_key", ["storageInstanceId", "key"]),
134 storageSqlKvEntries: defineTable(StorageSqlKvEntrySchema)
135 .index("by_domainId", ["id"])
136 .index("by_storageInstanceId", ["storageInstanceId"])
137 .index("by_storageInstanceId_key", ["storageInstanceId", "key"]),
54 approvals: defineTable(ApprovalSchema)138 approvals: defineTable(ApprovalSchema)
55 .index("by_domainId", ["id"])139 .index("by_domainId", ["id"])
56 .index("by_workspaceId", ["workspaceId"])140 .index("by_workspaceId", ["workspaceId"])
v2/apps/convex/convex/source_tool_registry.ts+147−20
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16} from "@executor-v2/persistence-ports";16} from "@executor-v2/persistence-ports";
17import {17import {
18 ApprovalSchema,18 ApprovalSchema,
19 OpenApiToolManifestSchema,
19 SourceSchema,20 SourceSchema,
20 ToolArtifactSchema,21 ToolArtifactSchema,
21 type Approval,22 type Approval,
@@ -37,9 +38,14 @@ const runtimeInternal = internal as any; · 15 unmodified lines
37 38
38const decodeSource = Schema.decodeUnknownSync(SourceSchema);39const decodeSource = Schema.decodeUnknownSync(SourceSchema);
39const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema);40const decodeToolArtifact = Schema.decodeUnknownSync(ToolArtifactSchema);
41const decodeOpenApiToolManifest = Schema.decodeUnknownSync(OpenApiToolManifestSchema);
42const encodeOpenApiToolManifestJson = Schema.encodeSync(
43 Schema.parseJson(OpenApiToolManifestSchema),
44);
40const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);45const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);
41 46
42const defaultPendingRetryAfterMs = 1_000;47const defaultPendingRetryAfterMs = 1_000;
48const openApiExtractorVersion = "openapi_v2";
43 49
44const readBooleanFlag = (value: string | undefined): boolean => {50const readBooleanFlag = (value: string | undefined): boolean => {
45 const normalized = value?.trim().toLowerCase();51 const normalized = value?.trim().toLowerCase();
@@ -100,16 +106,6 @@ const unsupportedSourceStoreMutation = ( · 54 unmodified lines
100 details: null,106 details: null,
101 });107 });
102 108
103const 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
113const sourceStoreQueryError = (109const sourceStoreQueryError = (
114 operation: string,110 operation: string,
115 cause: unknown,111 cause: unknown,
@@ -136,6 +132,19 @@ const toolArtifactStoreQueryError = ( · 20 unmodified lines
136 details: String(cause),132 details: String(cause),
137 });133 });
138 134
135const 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
139export const listSourcesForWorkspace = query({148export const listSourcesForWorkspace = query({
140 args: {149 args: {
141 workspaceId: v.string(),150 workspaceId: v.string(),
@@ -158,19 +167,130 @@ export const getToolArtifactBySource = query({ · 16 unmodified lines
158 sourceId: v.string(),167 sourceId: v.string(),
159 },168 },
160 handler: async (ctx, args): Promise<ToolArtifact | null> => {169 handler: async (ctx, args): Promise<ToolArtifact | null> => {
161 const rows = await ctx.db170 const binding = await ctx.db
162 .query("toolArtifacts")171 .query("openApiSourceArtifactBindings")
163 .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))172 .withIndex("by_workspaceId_sourceId", (q) =>
164 .collect();173 q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId)
174 )
175 .unique();
165 176
166 const record = rows.find((row) => row.workspaceId === args.workspaceId) ?? null;177 if (!binding) {
167 if (!record) {
168 return null;178 return null;
169 }179 }
170 180
171 return decodeToolArtifact(181 const toolRows = await ctx.db
172 stripConvexSystemFields(record as unknown as Record<string, unknown>),182 .query("openApiArtifactTools")
173 );183 .withIndex("by_artifactId", (q) => q.eq("artifactId", binding.artifactId))
184 .collect();
185
186 const manifest = {
187 version: 1 as const,
188 sourceHash: binding.sourceHash,
189 tools: toolRows
190 .map((toolRow) => ({
191 toolId: toolRow.toolId,
192 name: toolRow.name,
193 description: toolRow.description,
194 method: toolRow.method,
195 path: toolRow.path,
196 invocation: JSON.parse(toolRow.invocationJson) as unknown,
197 operationHash: toolRow.operationHash,
198 }))
199 .sort((left, right) => left.toolId.localeCompare(right.toolId)),
200 };
201
202 const normalizedManifest = decodeOpenApiToolManifest(manifest);
203
204 return decodeToolArtifact({
205 id: `tool_artifact_${args.sourceId}`,
206 workspaceId: args.workspaceId,
207 sourceId: args.sourceId,
208 sourceHash: binding.sourceHash,
209 toolCount: normalizedManifest.tools.length,
210 manifestJson: encodeOpenApiToolManifestJson(normalizedManifest),
211 createdAt: binding.updatedAt,
212 updatedAt: binding.updatedAt,
213 });
214 },
215});
216
217export const upsertToolArtifactForSource = internalMutation({
218 args: {
219 artifact: v.object({
220 id: v.string(),
221 workspaceId: v.string(),
222 sourceId: v.string(),
223 sourceHash: v.string(),
224 toolCount: v.number(),
225 manifestJson: v.string(),
226 createdAt: v.number(),
227 updatedAt: v.number(),
228 }),
229 },
230 handler: async (ctx, args): Promise<ToolArtifact> => {
231 const artifact = decodeToolArtifact(args.artifact as unknown as Record<string, unknown>);
232 const manifest = decodeOpenApiToolManifest(JSON.parse(artifact.manifestJson) as unknown);
233
234 const artifactId = `oa_${artifact.sourceHash}_${openApiExtractorVersion}`;
235 const existingArtifact = await ctx.db
236 .query("openApiArtifacts")
237 .withIndex("by_sourceHash_extractorVersion", (q) =>
238 q.eq("sourceHash", artifact.sourceHash).eq("extractorVersion", openApiExtractorVersion)
239 )
240 .unique();
241
242 if (!existingArtifact) {
243 const now = Date.now();
244 await ctx.db.insert("openApiArtifacts", {
245 id: artifactId,
246 sourceHash: artifact.sourceHash,
247 extractorVersion: openApiExtractorVersion,
248 toolCount: manifest.tools.length,
249 createdAt: now,
250 updatedAt: now,
251 });
252
253 for (const tool of manifest.tools) {
254 await ctx.db.insert("openApiArtifactTools", {
255 id: `${artifactId}:${tool.toolId}`,
256 artifactId,
257 toolId: tool.toolId,
258 name: tool.name,
259 description: tool.description,
260 method: tool.method,
261 path: tool.path,
262 operationHash: tool.operationHash,
263 invocationJson: JSON.stringify(tool.invocation),
264 createdAt: now,
265 updatedAt: now,
266 });
267 }
268 }
269
270 const existingBinding = await ctx.db
271 .query("openApiSourceArtifactBindings")
272 .withIndex("by_workspaceId_sourceId", (q) =>
273 q.eq("workspaceId", artifact.workspaceId).eq("sourceId", artifact.sourceId)
274 )
275 .unique();
276
277 const bindingRow = {
278 id: existingBinding?.id ?? `oab_${artifact.workspaceId}_${artifact.sourceId}`,
279 workspaceId: artifact.workspaceId,
280 sourceId: artifact.sourceId,
281 artifactId: existingArtifact?.id ?? artifactId,
282 sourceHash: artifact.sourceHash,
283 extractorVersion: openApiExtractorVersion,
284 updatedAt: Date.now(),
285 };
286
287 if (existingBinding) {
288 await ctx.db.patch(existingBinding._id, bindingRow);
289 } else {
290 await ctx.db.insert("openApiSourceArtifactBindings", bindingRow);
291 }
292
293 return artifact;
174 },294 },
175});295});
176 296
@@ -342,7 +462,14 @@ const createConvexToolArtifactStore = (ctx: ActionCtx): ToolArtifactStore => ({ · 165 unmodified lines
342 catch: (cause) => toolArtifactStoreQueryError("getBySource", cause),462 catch: (cause) => toolArtifactStoreQueryError("getBySource", cause),
343 }),463 }),
344 464
345 upsert: () => Effect.fail(unsupportedToolArtifactMutation()),465 upsert: (artifact: ToolArtifact) =>
466 Effect.tryPromise({
467 try: () =>
468 ctx.runMutation(runtimeInternal.source_tool_registry.upsertToolArtifactForSource, {
469 artifact,
470 }),
471 catch: (cause) => toolArtifactStoreMutationError("upsert", cause),
472 }).pipe(Effect.asVoid),
346});473});
347 474
348const createConvexPersistentToolApprovalPolicy = (475const createConvexPersistentToolApprovalPolicy = (
v2/apps/convex/package.json+1−1
@@ -23,7 +23,7 @@ · 22 unmodified lines
23 },23 },
24 "devDependencies": {24 "devDependencies": {
25 "@effect/vitest": "catalog:",25 "@effect/vitest": "catalog:",
26 "convex-test": "^0.0.41",26 "convex-test": "workspace:*",
27 "vitest": "catalog:"27 "vitest": "catalog:"
28 }28 }
29}29}
v2/apps/pm/src/actor.ts+82−2
@@ -3,15 +3,38 @@ import { · 2 unmodified lines
3 deriveWorkspaceMembershipsForPrincipal,3 deriveWorkspaceMembershipsForPrincipal,
4 requirePrincipalFromHeaders,4 requirePrincipalFromHeaders,
5} from "@executor-v2/management-api";5} from "@executor-v2/management-api";
6import { ActorUnauthenticatedError, makeActor } from "@executor-v2/domain";6import {
7 ActorUnauthenticatedError,
8 makeActor,
9 makeAllowAllActor,
10} from "@executor-v2/domain";
7import {11import {
8 type LocalStateSnapshot,12 type LocalStateSnapshot,
9 type LocalStateStore,13 type LocalStateStore,
10} from "@executor-v2/persistence-local";14} from "@executor-v2/persistence-local";
11import { type OrganizationMembership } from "@executor-v2/schema";15import {
16 type OrganizationMembership,
17 type WorkspaceMembership,
18} from "@executor-v2/schema";
19import * as PlatformHeaders from "@effect/platform/Headers";
12import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";21import * as Option from "effect/Option";
14 22
23const isTruthy = (value: string | undefined): boolean => {
24 const normalized = value?.trim().toLowerCase();
25 return normalized === "1" || normalized === "true" || normalized === "yes";
26};
27
28const 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
15const organizationMembershipsForAccount = (38const organizationMembershipsForAccount = (
16 snapshot: LocalStateSnapshot,39 snapshot: LocalStateSnapshot,
17 accountId: OrganizationMembership["accountId"],40 accountId: OrganizationMembership["accountId"],
@@ -20,8 +43,61 @@ const organizationMembershipsForAccount = ( · 2 unmodified lines
20 (membership) => membership.accountId === accountId,43 (membership) => membership.accountId === accountId,
21 );44 );
22 45
46const 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
60const 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
23export const PmActorLive = (localStateStore: LocalStateStore) =>98export const PmActorLive = (localStateStore: LocalStateStore) =>
24 ControlPlaneActorResolverLive({99 ControlPlaneActorResolverLive({
100 resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers),
25 resolveWorkspaceActor: (input) =>101 resolveWorkspaceActor: (input) =>
26 Effect.gen(function* () {102 Effect.gen(function* () {
27 const principal = yield* requirePrincipalFromHeaders(input.headers);103 const principal = yield* requirePrincipalFromHeaders(input.headers);
@@ -37,6 +113,10 @@ export const PmActorLive = (localStateStore: LocalStateStore) => · 9 unmodified lines
37 113
38 const snapshot = Option.getOrNull(snapshotOption);114 const snapshot = Option.getOrNull(snapshotOption);
39 115
116 if (snapshot === null && localAdminFallbackEnabled) {
117 return makeAllowAllActor(principal);
118 }
119
40 const organizationMemberships =120 const organizationMemberships =
41 snapshot === null121 snapshot === null
42 ? []122 ? []
v2/apps/pm/src/credentials-service.ts+260−0
@@ -0,0 +1,260 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 type LocalStateSnapshot,
4 type LocalStateStore,
5 type LocalStateStoreError,
6} from "@executor-v2/persistence-local";
7import {
8 makeControlPlaneCredentialsService,
9 type ControlPlaneCredentialsServiceShape,
10} from "@executor-v2/management-api";
11import {
12 type OrganizationId,
13 type SourceCredentialBinding,
14 type WorkspaceId,
15} from "@executor-v2/schema";
16import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";
18
19const 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
33const toSourceStoreErrorFromLocalState = (
34 operation: string,
35 error: LocalStateStoreError,
36): SourceStoreError =>
37 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
38
39const 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
53const 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
62const 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
77const appendCredentialBinding = (
78 snapshot: LocalStateSnapshot,
79 binding: SourceCredentialBinding,
80): LocalStateSnapshot => ({
81 ...snapshot,
82 generatedAt: Date.now(),
83 credentialBindings: [...snapshot.credentialBindings, binding],
84});
85
86const 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
121export 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
3 ControlPlaneService,3 ControlPlaneService,
4 makeControlPlaneService,4 makeControlPlaneService,
5 makeControlPlaneSourcesService,5 makeControlPlaneSourcesService,
6 fetchOpenApiDocument,
6 makeControlPlaneWebHandler,7 makeControlPlaneWebHandler,
7 makeSourceCatalogService,8 makeSourceCatalogService,
8 makeSourceManagerService,9 makeSourceManagerService,
@@ -34,6 +35,12 @@ import { · 25 unmodified lines
34 createPmPersistentToolApprovalPolicy,35 createPmPersistentToolApprovalPolicy,
35} from "./approvals-service";36} from "./approvals-service";
36import { startPmHttpServer } from "./http-server";37import { startPmHttpServer } from "./http-server";
38import { createPmPoliciesService } from "./policies-service";
39import { createPmCredentialsService } from "./credentials-service";
40import { createPmOrganizationsService } from "./organizations-service";
41import { createPmStorageService } from "./storage-service";
42import { createPmToolsService } from "./tools-service";
43import { createPmWorkspacesService } from "./workspaces-service";
37import { createPmMcpHandler } from "./mcp-handler";44import { createPmMcpHandler } from "./mcp-handler";
38import { createPmExecuteRuntimeRun } from "./runtime-execution-port";45import { createPmExecuteRuntimeRun } from "./runtime-execution-port";
39import { createPmToolCallHttpHandler } from "./tool-call-handler";46import { createPmToolCallHttpHandler } from "./tool-call-handler";
@@ -113,14 +120,7 @@ const sourcesService = { · 73 unmodified lines
113 }120 }
114 121
115 const openApiSpecResult = yield* Effect.tryPromise({122 const openApiSpecResult = yield* Effect.tryPromise({
116 try: async () => {123 try: () => fetchOpenApiDocument(source.endpoint),
117 const response = await fetch(source.endpoint);
118 if (!response.ok) {
119 throw new Error(`Failed fetching OpenAPI spec (${response.status})`);
120 }
121
122 return await response.json();
123 },
124 catch: (cause) => String(cause),124 catch: (cause) => String(cause),
125 }).pipe(Effect.either);125 }).pipe(Effect.either);
126 126
@@ -139,9 +139,23 @@ const sourcesService = { · 12 unmodified lines
139 }),139 }),
140};140};
141 141
142const credentialsService = createPmCredentialsService(localStateStore);
143const policiesService = createPmPoliciesService(localStateStore);
144const organizationsService = createPmOrganizationsService(localStateStore);
145const workspacesService = createPmWorkspacesService(localStateStore);
146const toolsService = createPmToolsService(sourceStore, toolArtifactStore);
147const storageService = createPmStorageService(localStateStore, {
148 stateRootDir: pmStateRootDir,
149});
142const approvalsService = createPmApprovalsService(localStateStore);150const approvalsService = createPmApprovalsService(localStateStore);
143const controlPlaneService = makeControlPlaneService({151const controlPlaneService = makeControlPlaneService({
144 sources: sourcesService,152 sources: sourcesService,
153 credentials: credentialsService,
154 policies: policiesService,
155 organizations: organizationsService,
156 workspaces: workspacesService,
157 tools: toolsService,
158 storage: storageService,
145 approvals: approvalsService,159 approvals: approvalsService,
146});160});
147 161
v2/apps/pm/src/organizations-service.ts+142−0
@@ -0,0 +1,142 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 makeControlPlaneOrganizationsService,
4 type ControlPlaneOrganizationsServiceShape,
5} from "@executor-v2/management-api";
6import {
7 type LocalStateSnapshot,
8 type LocalStateStore,
9 type LocalStateStoreError,
10} from "@executor-v2/persistence-local";
11import { type Organization } from "@executor-v2/schema";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14
15const 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
29const toSourceStoreErrorFromLocalState = (
30 operation: string,
31 error: LocalStateStoreError,
32): SourceStoreError =>
33 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
34
35const 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
49const 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
64const appendOrganization = (
65 snapshot: LocalStateSnapshot,
66 organization: Organization,
67): LocalStateSnapshot => ({
68 ...snapshot,
69 generatedAt: Date.now(),
70 organizations: [...snapshot.organizations, organization],
71});
72
73export 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
33 policies: [],33 policies: [],
34 approvals: [],34 approvals: [],
35 taskRuns: [],35 taskRuns: [],
36 storageInstances: [],
36 syncStates: [],37 syncStates: [],
37 }) as unknown as LocalStateSnapshot;38 }) as unknown as LocalStateSnapshot;
38 39
v2/apps/pm/src/policies-service.ts+208−0
@@ -0,0 +1,208 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 type LocalStateSnapshot,
4 type LocalStateStore,
5 type LocalStateStoreError,
6} from "@executor-v2/persistence-local";
7import {
8 makeControlPlanePoliciesService,
9 type ControlPlanePoliciesServiceShape,
10} from "@executor-v2/management-api";
11import { type Policy } from "@executor-v2/schema";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14
15const 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
29const toSourceStoreErrorFromLocalState = (
30 operation: string,
31 error: LocalStateStoreError,
32): SourceStoreError =>
33 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
34
35const 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
46const 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
61const appendPolicy = (
62 snapshot: LocalStateSnapshot,
63 policy: Policy,
64): LocalStateSnapshot => ({
65 ...snapshot,
66 generatedAt: Date.now(),
67 policies: [...snapshot.policies, policy],
68});
69
70const 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
99export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import type {
3 LocalStateSnapshot,
4 LocalStateStore,
5} from "@executor-v2/persistence-local";
6import type { StorageInstance, WorkspaceId } from "@executor-v2/schema";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import { mkdtemp, rm, writeFile } from "node:fs/promises";
10import { tmpdir } from "node:os";
11import * as path from "node:path";
12
13import { createPmStorageService } from "./storage-service";
14
15const 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
50describe("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 @@
1import {
2 makeControlPlaneStorageService,
3 type ControlPlaneStorageServiceShape,
4} from "@executor-v2/management-api";
5import { SourceStoreError } from "@executor-v2/persistence-ports";
6import {
7 type LocalStateSnapshot,
8 type LocalStateStore,
9 type LocalStateStoreError,
10} from "@executor-v2/persistence-local";
11import {
12 type OrganizationId,
13 type StorageInstance,
14 type WorkspaceId,
15} from "@executor-v2/schema";
16import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";
18import {
19 mkdir,
20 readdir,
21 readFile,
22 rm,
23 stat,
24 writeFile,
25} from "node:fs/promises";
26import * as path from "node:path";
27
28const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
29const DEFAULT_KV_LIMIT = 100;
30const MAX_KV_LIMIT = 1000;
31const DEFAULT_SQL_MAX_ROWS = 200;
32const MAX_SQL_MAX_ROWS = 5000;
33const MILLIS_PER_HOUR = 3_600_000;
34const STORAGE_ROOT_DIRECTORY = "storage";
35const STORAGE_FS_DIRECTORY = "fs";
36const STORAGE_KV_FILE = "kv-store.json";
37const STORAGE_SQLITE_FILE = "storage.sqlite";
38
39type 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
48type SqliteDatabaseConstructor = new (
49 filename: string,
50 options?: {
51 create?: boolean;
52 },
53) => SqliteDatabaseInstance;
54
55const 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
64const 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
78const toSourceStoreErrorFromLocalState = (
79 operation: string,
80 error: LocalStateStoreError,
81): SourceStoreError =>
82 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
83
84const 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
95const 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
109const canAccessStorageInstance = (
110 instance: StorageInstance,
111 workspaceId: WorkspaceId,
112 organizationId: OrganizationId,
113): boolean =>
114 instance.workspaceId === workspaceId
115 || (instance.workspaceId === null && instance.organizationId === organizationId);
116
117const 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
128const 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
143const appendStorageInstance = (
144 snapshot: LocalStateSnapshot,
145 storageInstance: StorageInstance,
146): LocalStateSnapshot => ({
147 ...snapshot,
148 generatedAt: Date.now(),
149 storageInstances: [...snapshot.storageInstances, storageInstance],
150});
151
152const 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
192const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string =>
193 path.resolve(stateRootDir, STORAGE_ROOT_DIRECTORY, storageInstanceId);
194
195const storageInstanceFsRootPath = (
196 stateRootDir: string,
197 storageInstanceId: string,
198): string =>
199 path.resolve(
200 storageInstanceRootPath(stateRootDir, storageInstanceId),
201 STORAGE_FS_DIRECTORY,
202 );
203
204const storageInstanceKvPath = (stateRootDir: string, storageInstanceId: string): string =>
205 path.resolve(
206 storageInstanceRootPath(stateRootDir, storageInstanceId),
207 STORAGE_KV_FILE,
208 );
209
210const storageInstanceSqlitePath = (
211 stateRootDir: string,
212 storageInstanceId: string,
213): string =>
214 path.resolve(
215 storageInstanceRootPath(stateRootDir, storageInstanceId),
216 STORAGE_SQLITE_FILE,
217 );
218
219const 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
250const 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
277const toStorageEntryPath = (absolutePath: string, fsRootPath: string): string => {
278 const relativePath = path.relative(fsRootPath, absolutePath);
279 return `/${relativePath.split(path.sep).join("/")}`;
280};
281
282const 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
295const 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
326const 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
348export 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 @@
1import {
2 SourceStoreError,
3 ToolArtifactStoreError,
4 type SourceStore,
5 type ToolArtifactStore,
6} from "@executor-v2/persistence-ports";
7import {
8 makeControlPlaneToolsService,
9 type ControlPlaneToolsServiceShape,
10 type SourceToolSummary,
11} from "@executor-v2/management-api";
12import {
13 OpenApiToolManifestSchema,
14 type Source,
15 type SourceId,
16 type ToolArtifact,
17 type WorkspaceId,
18} from "@executor-v2/schema";
19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";
21import * as ParseResult from "effect/ParseResult";
22import * as Schema from "effect/Schema";
23
24const decodeOpenApiToolManifest = Schema.decodeUnknown(OpenApiToolManifestSchema);
25
26const 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
40const toSourceStoreErrorFromSourceStore = (
41 operation: string,
42 error: SourceStoreError,
43): SourceStoreError =>
44 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
45
46const toSourceStoreErrorFromToolArtifactStore = (
47 operation: string,
48 error: ToolArtifactStoreError,
49): SourceStoreError =>
50 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
51
52const 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
91const 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
110const 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
146export 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 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 makeControlPlaneWorkspacesService,
4 type ControlPlaneWorkspacesServiceShape,
5} from "@executor-v2/management-api";
6import {
7 type LocalStateSnapshot,
8 type LocalStateStore,
9 type LocalStateStoreError,
10} from "@executor-v2/persistence-local";
11import { type Workspace } from "@executor-v2/schema";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14
15const 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
29const toSourceStoreErrorFromLocalState = (
30 operation: string,
31 error: LocalStateStoreError,
32): SourceStoreError =>
33 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
34
35const 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
47const 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
62const appendWorkspace = (
63 snapshot: LocalStateSnapshot,
64 workspace: Workspace,
65): LocalStateSnapshot => ({
66 ...snapshot,
67 generatedAt: Date.now(),
68 workspaces: [...snapshot.workspaces, workspace],
69});
70
71export 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
2import { withAuth } from "@workos-inc/authkit-nextjs";2import { withAuth } from "@workos-inc/authkit-nextjs";
3import { type NextRequest } from "next/server";3import { type NextRequest } from "next/server";
4 4
5import { isWorkosEnabled } from "../../../../lib/workos";5import { isLocalControlPlaneUpstream, isWorkosEnabled } from "../../../../lib/workos";
6 6
7const controlPlaneUpstream =7const controlPlaneUpstream =
8 process.env.CONTROL_PLANE_UPSTREAM_URL ?? "http://127.0.0.1:8787";8 process.env.CONTROL_PLANE_UPSTREAM_URL ?? "http://127.0.0.1:8787";
@@ -217,12 +217,6 @@ const normalizeSuccessResponse = async ( · 208 unmodified lines
217 upstreamResponse: Response,217 upstreamResponse: Response,
218): Promise<Response> => {218): Promise<Response> => {
219 const contentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";219 const contentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";
220
221 if (contentType.includes("application/json")) {
222 const payload = await upstreamResponse.json();
223 return Response.json(payload, { status: upstreamResponse.status });
224 }
225
226 const payload = await upstreamResponse.arrayBuffer();220 const payload = await upstreamResponse.arrayBuffer();
227 221
228 const headers = new Headers();222 const headers = new Headers();
@@ -241,14 +235,22 @@ const proxyControlPlaneRequest = async ( · 12 unmodified lines
241 path: ReadonlyArray<string>,235 path: ReadonlyArray<string>,
242): Promise<Response> => {236): Promise<Response> => {
243 try {237 try {
244 const { user } = await withAuth();238 const workosEnabled = isWorkosEnabled();
239 const user = workosEnabled ? (await withAuth()).user : null;
245 240
246 const principalAccountId = resolvePrincipalAccountId(request, user?.id ?? null);241 let principalAccountId = resolvePrincipalAccountId(request, user?.id ?? null);
247 242
248 if (isWorkosEnabled() && user === null) {243 if (workosEnabled && user === null) {
249 return unauthorizedResponse();244 return unauthorizedResponse();
250 }245 }
251 246
247 if (principalAccountId === null && !workosEnabled && isLocalControlPlaneUpstream()) {
248 const localAccountId = process.env.CONTROL_PLANE_LOCAL_ACCOUNT_ID?.trim();
249 principalAccountId = localAccountId && localAccountId.length > 0
250 ? localAccountId
251 : "local-dev";
252 }
253
252 if (principalAccountId === null) {254 if (principalAccountId === null) {
253 return unauthorizedResponse();255 return unauthorizedResponse();
254 }256 }
v2/apps/web/app/globals.css+53−208
@@ -1,216 +1,61 @@
1:root {1@import "tailwindcss";
2 --bg: #f2f0ea;2@import "tw-animate-css";
3 --panel: #fffdf8;3
4 --ink: #1b1a16;4@theme inline {
5 --muted: #6b675d;5 --font-sans: var(--font-body), "IBM Plex Sans", sans-serif;
6 --accent: #0a7f64;6 --font-display: var(--font-display), "Fraunces", serif;
7 --accent-ink: #f4fffc;7 --color-background: var(--background);
8 --border: rgba(27, 26, 22, 0.14);8 --color-foreground: var(--foreground);
9 --danger: #a22a2a;9 --color-card: var(--card);
10}10 --color-card-foreground: var(--card-foreground);
11 11 --color-primary: var(--primary);
12* {12 --color-primary-foreground: var(--primary-foreground);
13 box-sizing: border-box;13 --color-secondary: var(--secondary);
14}14 --color-secondary-foreground: var(--secondary-foreground);
15 15 --color-muted: var(--muted);
16html,16 --color-muted-foreground: var(--muted-foreground);
17body {17 --color-accent: var(--accent);
18 margin: 0;18 --color-accent-foreground: var(--accent-foreground);
19 padding: 0;19 --color-destructive: var(--destructive);
20 min-height: 100%;20 --color-border: var(--border);
21}21 --color-input: var(--input);
22 22 --color-ring: var(--ring);
23body {
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
32a {
33 color: inherit;
34}
35
36main {
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
100h2 {
101 margin: 0 0 1rem;
102 font-family: var(--font-display), "Fraunces", serif;
103 font-size: 1.3rem;
104}23}
105 24
106.field {25:root {
107 margin-bottom: 0.95rem;26 --background: oklch(0.986 0.01 92);
108}27 --foreground: oklch(0.22 0.01 96);
109 28 --card: oklch(0.995 0.004 95);
110label {29 --card-foreground: oklch(0.22 0.01 96);
111 display: block;30 --primary: oklch(0.51 0.13 170);
112 margin-bottom: 0.4rem;31 --primary-foreground: oklch(0.98 0.004 120);
113 color: var(--muted);32 --secondary: oklch(0.95 0.008 85);
114 font-size: 0.9rem;33 --secondary-foreground: oklch(0.32 0.01 92);
115}34 --muted: oklch(0.95 0.008 90);
116 35 --muted-foreground: oklch(0.48 0.015 92);
117input,36 --accent: oklch(0.93 0.01 160);
118select,37 --accent-foreground: oklch(0.26 0.02 165);
119button {38 --destructive: oklch(0.56 0.2 25);
120 width: 100%;39 --border: oklch(0.88 0.01 92);
121 border-radius: 10px;40 --input: oklch(0.88 0.01 92);
122 border: 1px solid var(--border);41 --ring: oklch(0.51 0.13 170);
123 font: inherit;42}
124}43
125 44@layer base {
126input,45 * {
127select {46 @apply border-border;
128 padding: 0.62rem 0.7rem;
129 background: #fff;
130}
131
132button {
133 padding: 0.7rem 0.8rem;
134 font-weight: 600;
135 cursor: pointer;
136}
137
138button.primary {
139 background: var(--accent);
140 color: var(--accent-ink);
141 border-color: transparent;
142}
143
144button.secondary {
145 background: transparent;
146}
147
148button.danger {
149 background: transparent;
150 color: var(--danger);
151 border-color: rgba(162, 42, 42, 0.35);
152 width: auto;
153 padding: 0.45rem 0.7rem;
154}
155
156.status {
157 margin-top: 0.7rem;
158 padding: 0.65rem 0.75rem;
159 border: 1px solid var(--border);
160 border-radius: 10px;
161 color: var(--muted);
162 font-size: 0.9rem;
163}
164
165.error {
166 color: var(--danger);
167}
168
169.list {
170 display: grid;
171 gap: 0.75rem;
172}
173
174.list-item {
175 border: 1px solid var(--border);
176 border-radius: 12px;
177 padding: 0.8rem;
178 background: rgba(255, 255, 255, 0.65);
179}
180
181.list-item header {
182 display: flex;
183 align-items: flex-start;
184 justify-content: space-between;
185 gap: 0.75rem;
186}
187
188.list-item strong {
189 display: block;
190 font-size: 1rem;
191}
192
193.meta {
194 margin-top: 0.2rem;
195 color: var(--muted);
196 font-size: 0.82rem;
197 word-break: break-all;
198}
199
200@media (max-width: 860px) {
201 .grid {
202 grid-template-columns: 1fr;
203 }47 }
204 48
205 .card + .card {49 html,
206 border-left: 0;50 body {
207 border-top: 1px solid var(--border);51 min-height: 100%;
208 }52 }
209}
210 53
211@media (max-width: 640px) {54 body {
212 .hero-top-row {55 @apply font-sans text-foreground antialiased;
213 flex-direction: column;56 background:
214 align-items: flex-start;57 radial-gradient(circle at 12% 18%, color-mix(in oklch, var(--primary) 22%, transparent), transparent 42%),
58 radial-gradient(circle at 84% 6%, oklch(0.8 0.07 84 / 0.35), transparent 38%),
59 linear-gradient(170deg, #f3f0e6 0%, #f7f4ec 58%, #ece6d9 100%);
215 }60 }
216}61}
v2/apps/web/app/page-content.tsx+2914−133
@@ -1,39 +1,307 @@
1"use client";1"use client";
2 2
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react";3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect-atom/atom-react";
4import type { UpsertSourcePayload } from "@executor-v2/management-api";4import type {
5import type { SourceId, SourceKind, WorkspaceId } from "@executor-v2/schema";5 ListStorageKvResult,
6import { useState, type FormEvent } from "react";6 QueryStorageSqlResult,
7 StorageDirectoryEntry,
8} from "@executor-v2/management-api/storage/api";
9import 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";
26import { useEffect, useMemo, useState, type FormEvent } from "react";
7 27
8import {28import {
29 approvalsByWorkspace,
30 approvalsResultByWorkspace,
31 closeStorageInstance,
32 credentialBindingsByWorkspace,
33 credentialBindingsResultByWorkspace,
34 listStorageDirectory,
35 listStorageKv,
36 openStorageInstance,
37 optimisticRemovePolicy,
9 optimisticRemoveSources,38 optimisticRemoveSources,
39 optimisticResolveApproval,
10 optimisticSourcesByWorkspace,40 optimisticSourcesByWorkspace,
41 optimisticUpsertPolicy,
11 optimisticUpsertSources,42 optimisticUpsertSources,
43 organizationsResult,
44 organizationsState,
45 policiesByWorkspace,
46 policiesResultByWorkspace,
47 queryStorageSql,
48 readStorageFile,
49 removeCredentialBinding,
50 removePolicy,
12 removeSource,51 removeSource,
52 removeStorageInstance,
53 resolveApproval,
13 sourcesByWorkspace,54 sourcesByWorkspace,
14 sourcesPendingByWorkspace,55 sourcesPendingByWorkspace,
15 sourcesResultByWorkspace,56 sourcesResultByWorkspace,
57 storageByWorkspace,
58 storageResultByWorkspace,
59 toCredentialBindingRemoveResult,
60 toCredentialBindingUpsertPayload,
61 toListStorageDirectoryPayload,
62 toListStorageKvPayload,
63 toOpenStoragePayload,
64 toOrganizationUpsertPayload,
65 toPolicyRemoveResult,
66 toPolicyUpsertPayload,
67 toQueryStorageSqlPayload,
68 toReadStorageFilePayload,
69 toStorageDirectoryResult,
70 toStorageKvResult,
71 toStorageReadFileResult,
72 toStorageRemoveResult,
73 toStorageSqlResult,
74 toWorkspaceUpsertPayload,
75 upsertCredentialBinding,
76 upsertOrganization,
77 upsertPolicy,
16 upsertSource,78 upsertSource,
79 upsertWorkspace,
80 workspaceToolsByWorkspace,
81 workspaceToolsResultByWorkspace,
82 workspacesResult,
83 workspacesState,
17} from "../lib/control-plane/atoms";84} from "../lib/control-plane/atoms";
85import {
86 formStateFromSource,
87 sourceToLegacyRecord,
88 upsertPayloadFromForm,
89 type LegacySourceFormState,
90 type LegacySourceType,
91} from "../lib/control-plane/legacy-source";
92import { Badge } from "../components/ui/badge";
93import { Button } from "../components/ui/button";
94import {
95 Card,
96 CardContent,
97 CardDescription,
98 CardHeader,
99 CardTitle,
100} from "../components/ui/card";
101import { Input } from "../components/ui/input";
102import { Select } from "../components/ui/select";
103import { cn } from "../lib/utils";
104
105const kindOptions: ReadonlyArray<LegacySourceType> = ["openapi", "mcp", "graphql"];
106
107const catalogTemplates = [
108 {
109 name: "GitHub",
110 type: "openapi" as const,
111 endpoint:
112 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
113 },
114 {
115 name: "OpenAI",
116 type: "openapi" as const,
117 endpoint:
118 "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
119 },
120 {
121 name: "Linear",
122 type: "graphql" as const,
123 endpoint: "https://api.linear.app/graphql",
124 },
125 {
126 name: "Generic MCP",
127 type: "mcp" as const,
128 endpoint: "https://example.com/mcp",
129 },
130] as const;
18 131
19const kindOptions: ReadonlyArray<SourceKind> = [132const defaultFormState = (): LegacySourceFormState => ({
20 "openapi",133 name: "",
21 "mcp",134 type: "openapi",
22 "graphql",135 endpoint: "",
23 "internal",136 baseUrl: "",
24];137 mcpTransport: "auto",
138 authType: "none",
139 authMode: "workspace",
140 apiKeyHeader: "Authorization",
141 enabled: true,
142});
143
144const createLocalId = (prefix: string): string => {
145 const randomPart =
146 typeof globalThis.crypto !== "undefined"
147 && typeof globalThis.crypto.randomUUID === "function"
148 ? globalThis.crypto.randomUUID()
149 : `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
150
151 return `${prefix}${randomPart}`;
152};
25 153
26type PageProps = {154type PageProps = {
27 authEnabled: boolean;155 authEnabled: boolean;
28 initialWorkspaceId: string;156 initialWorkspaceId: string;
29};157};
30 158
159type ConsoleTab =
160 | "sources"
161 | "tools"
162 | "credentials"
163 | "policies"
164 | "organizations"
165 | "workspaces"
166 | "storage"
167 | "approvals";
168type ApprovalFilter = "pending" | "resolved" | "all";
169
170const 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
183const 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
198const 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
31const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => {207const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => {
208 const [activeTab, setActiveTab] = useState<ConsoleTab>("sources");
32 const [workspaceIdInput, setWorkspaceIdInput] = useState(initialWorkspaceId);209 const [workspaceIdInput, setWorkspaceIdInput] = useState(initialWorkspaceId);
33 const [name, setName] = useState("Weather API");210 const [formState, setFormState] = useState<LegacySourceFormState>(() =>
34 const [kind, setKind] = useState<SourceKind>("openapi");211 defaultFormState(),
35 const [endpoint, setEndpoint] = useState("https://example.com/openapi.json");212 );
213 const [searchQuery, setSearchQuery] = useState("");
36 const [statusText, setStatusText] = useState<string | null>(null);214 const [statusText, setStatusText] = useState<string | null>(null);
215 const [toolsRefreshPending, setToolsRefreshPending] = useState(false);
216 const [toolSearchQuery, setToolSearchQuery] = useState("");
217 const [selectedToolSourceId, setSelectedToolSourceId] = useState<SourceId | "all">("all");
218
219 const [organizationIdInput, setOrganizationIdInput] = useState("");
220 const [organizationSlugInput, setOrganizationSlugInput] = useState("");
221 const [organizationNameInput, setOrganizationNameInput] = useState("");
222 const [organizationStatusInput, setOrganizationStatusInput] =
223 useState<Organization["status"]>("active");
224 const [organizationStatusText, setOrganizationStatusText] = useState<string | null>(null);
225
226 const [workspaceEditIdInput, setWorkspaceEditIdInput] = useState("");
227 const [workspaceNameInput, setWorkspaceNameInput] = useState("");
228 const [workspaceOrganizationIdInput, setWorkspaceOrganizationIdInput] = useState("");
229 const [workspaceStatusText, setWorkspaceStatusText] = useState<string | null>(null);
230
231 const [credentialSourceKey, setCredentialSourceKey] = useState("");
232 const [credentialProvider, setCredentialProvider] =
233 useState<CredentialProvider>("api_key");
234 const [credentialScopeType, setCredentialScopeType] =
235 useState<CredentialScopeType>("workspace");
236 const [credentialIdInput, setCredentialIdInput] = useState("");
237 const [credentialSecretRef, setCredentialSecretRef] = useState("");
238 const [credentialAccountId, setCredentialAccountId] = useState("");
239 const [credentialAdditionalHeadersJson, setCredentialAdditionalHeadersJson] =
240 useState("");
241 const [credentialBoundAuthFingerprint, setCredentialBoundAuthFingerprint] =
242 useState("");
243 const [credentialEditingId, setCredentialEditingId] =
244 useState<SourceCredentialBinding["id"] | null>(null);
245 const [credentialSearchQuery, setCredentialSearchQuery] = useState("");
246 const [credentialStatusText, setCredentialStatusText] = useState<string | null>(
247 null,
248 );
249 const [credentialBusyId, setCredentialBusyId] =
250 useState<SourceCredentialBinding["id"] | null>(null);
251
252 const [policyPattern, setPolicyPattern] = useState("");
253 const [policyDecision, setPolicyDecision] = useState<PolicyDecision>("require_approval");
254 const [policyEditingId, setPolicyEditingId] = useState<PolicyId | null>(null);
255 const [policySearchQuery, setPolicySearchQuery] = useState("");
256 const [policyStatusText, setPolicyStatusText] = useState<string | null>(null);
257 const [policyBusyId, setPolicyBusyId] = useState<PolicyId | null>(null);
258 const [optimisticPolicies, setOptimisticPolicies] = useState<
259 ReadonlyArray<Policy> | null
260 >(null);
261
262 const [storageScopeType, setStorageScopeType] =
263 useState<StorageScopeType>("scratch");
264 const [storageDurability, setStorageDurability] =
265 useState<StorageDurability>("ephemeral");
266 const [storageProvider, setStorageProvider] =
267 useState<StorageInstance["provider"]>("agentfs-local");
268 const [storagePurposeInput, setStoragePurposeInput] = useState("");
269 const [storageTtlHoursInput, setStorageTtlHoursInput] = useState("24");
270 const [storageAccountIdInput, setStorageAccountIdInput] = useState("");
271 const [storageSearchQuery, setStorageSearchQuery] = useState("");
272 const [storageStatusText, setStorageStatusText] = useState<string | null>(null);
273 const [storageBusyId, setStorageBusyId] = useState<string | null>(null);
274 const [storageSelectedId, setStorageSelectedId] = useState<StorageInstance["id"] | null>(
275 null,
276 );
277 const [storageDirectoryPath, setStorageDirectoryPath] = useState("/");
278 const [storageDirectoryEntries, setStorageDirectoryEntries] = useState<
279 ReadonlyArray<StorageDirectoryEntry>
280 >([]);
281 const [storageDirectoryBusy, setStorageDirectoryBusy] = useState(false);
282 const [storageFilePreviewPath, setStorageFilePreviewPath] = useState<string | null>(null);
283 const [storageFilePreviewContent, setStorageFilePreviewContent] = useState("");
284 const [storageFilePreviewBusy, setStorageFilePreviewBusy] = useState(false);
285 const [storageKvPrefix, setStorageKvPrefix] = useState("");
286 const [storageKvLimit, setStorageKvLimit] = useState("100");
287 const [storageKvItems, setStorageKvItems] = useState<
288 ReadonlyArray<ListStorageKvResult["items"][number]>
289 >([]);
290 const [storageKvBusy, setStorageKvBusy] = useState(false);
291 const [storageSqlText, setStorageSqlText] = useState("SELECT name FROM sqlite_master LIMIT 50");
292 const [storageSqlMaxRows, setStorageSqlMaxRows] = useState("200");
293 const [storageSqlResult, setStorageSqlResult] = useState<QueryStorageSqlResult | null>(
294 null,
295 );
296 const [storageSqlBusy, setStorageSqlBusy] = useState(false);
297
298 const [approvalFilter, setApprovalFilter] = useState<ApprovalFilter>("pending");
299 const [approvalSearchQuery, setApprovalSearchQuery] = useState("");
300 const [approvalStatusText, setApprovalStatusText] = useState<string | null>(null);
301 const [approvalBusyId, setApprovalBusyId] = useState<ApprovalId | null>(null);
302 const [optimisticApprovals, setOptimisticApprovals] = useState<
303 ReadonlyArray<Approval> | null
304 >(null);
37 305
38 const workspaceId = workspaceIdInput as WorkspaceId;306 const workspaceId = workspaceIdInput as WorkspaceId;
39 307
@@ -41,35 +309,478 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 1 unmodified line
41 const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId));309 const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId));
42 const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId));310 const refreshSources = useAtomRefresh(sourcesResultByWorkspace(workspaceId));
43 311
312 const credentialBindingsState = useAtomValue(
313 credentialBindingsByWorkspace(workspaceId),
314 );
315 const refreshCredentialBindings = useAtomRefresh(
316 credentialBindingsResultByWorkspace(workspaceId),
317 );
318
319 const policiesState = useAtomValue(policiesByWorkspace(workspaceId));
320 const refreshPolicies = useAtomRefresh(policiesResultByWorkspace(workspaceId));
321
322 const storageState = useAtomValue(storageByWorkspace(workspaceId));
323 const refreshStorage = useAtomRefresh(storageResultByWorkspace(workspaceId));
324
325 const approvalsState = useAtomValue(approvalsByWorkspace(workspaceId));
326 const refreshApprovals = useAtomRefresh(approvalsResultByWorkspace(workspaceId));
327
328 const organizations = useAtomValue(organizationsState);
329 const refreshOrganizations = useAtomRefresh(organizationsResult);
330
331 const workspaces = useAtomValue(workspacesState);
332 const refreshWorkspaces = useAtomRefresh(workspacesResult);
333
334 const workspaceTools = useAtomValue(workspaceToolsByWorkspace(workspaceId));
335 const refreshWorkspaceTools = useAtomRefresh(
336 workspaceToolsResultByWorkspace(workspaceId),
337 );
44 const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" });338 const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" });
45 const runRemoveSource = useAtomSet(removeSource, { mode: "promise" });339 const runRemoveSource = useAtomSet(removeSource, { mode: "promise" });
46 const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId));340 const runUpsertCredentialBinding = useAtomSet(upsertCredentialBinding, {
341 mode: "promise",
342 });
343 const runRemoveCredentialBinding = useAtomSet(removeCredentialBinding, {
344 mode: "promise",
345 });
346 const runUpsertPolicy = useAtomSet(upsertPolicy, { mode: "promise" });
347 const runRemovePolicy = useAtomSet(removePolicy, { mode: "promise" });
348 const runUpsertOrganization = useAtomSet(upsertOrganization, { mode: "promise" });
349 const runUpsertWorkspace = useAtomSet(upsertWorkspace, { mode: "promise" });
350 const runOpenStorageInstance = useAtomSet(openStorageInstance, { mode: "promise" });
351 const runCloseStorageInstance = useAtomSet(closeStorageInstance, { mode: "promise" });
352 const runRemoveStorageInstance = useAtomSet(removeStorageInstance, {
353 mode: "promise",
354 });
355 const runListStorageDirectory = useAtomSet(listStorageDirectory, {
356 mode: "promise",
357 });
358 const runReadStorageFile = useAtomSet(readStorageFile, { mode: "promise" });
359 const runListStorageKv = useAtomSet(listStorageKv, { mode: "promise" });
360 const runQueryStorageSql = useAtomSet(queryStorageSql, { mode: "promise" });
361 const runResolveApproval = useAtomSet(resolveApproval, { mode: "promise" });
362 const setOptimisticSources = useAtomSet(
363 optimisticSourcesByWorkspace(workspaceId),
364 );
365
366 const sourceItems = useMemo(
367 () => sources.items.map(sourceToLegacyRecord),
368 [sources.items],
369 );
370
371 const credentialItems = useMemo(
372 () => credentialBindingsState.items,
373 [credentialBindingsState.items],
374 );
375
376 const policyItems = useMemo(
377 () => optimisticPolicies ?? policiesState.items,
378 [optimisticPolicies, policiesState.items],
379 );
380
381 const storageItems = useMemo(
382 () => storageState.items,
383 [storageState.items],
384 );
385
386 const selectedStorageInstance = useMemo(() => {
387 if (storageItems.length === 0) {
388 return null;
389 }
390
391 if (storageSelectedId === null) {
392 return storageItems[0] ?? null;
393 }
394
395 return storageItems.find((instance) => instance.id === storageSelectedId)
396 ?? storageItems[0]
397 ?? null;
398 }, [storageItems, storageSelectedId]);
399
400 useEffect(() => {
401 if (selectedStorageInstance === null) {
402 if (storageSelectedId !== null) {
403 setStorageSelectedId(null);
404 }
405 return;
406 }
407
408 if (storageSelectedId !== selectedStorageInstance.id) {
409 setStorageSelectedId(selectedStorageInstance.id);
410 }
411 }, [selectedStorageInstance, storageSelectedId]);
412
413 useEffect(() => {
414 if (workspaceNameInput.trim().length > 0) {
415 return;
416 }
417
418 const current = workspaces.items.find((workspace) => workspace.id === workspaceId);
419 if (!current) {
420 return;
421 }
422
423 setWorkspaceEditIdInput(current.id);
424 setWorkspaceNameInput(current.name);
425 setWorkspaceOrganizationIdInput(current.organizationId ?? "");
426 }, [workspaceId, workspaceNameInput, workspaces.items]);
427
428 useEffect(() => {
429 if (organizationIdInput.trim().length > 0) {
430 return;
431 }
432
433 const currentWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId);
434 if (!currentWorkspace?.organizationId) {
435 return;
436 }
437
438 const currentOrganization = organizations.items.find(
439 (organization) => organization.id === currentWorkspace.organizationId,
440 );
441
442 if (!currentOrganization) {
443 return;
444 }
445
446 setOrganizationIdInput(currentOrganization.id);
447 setOrganizationSlugInput(currentOrganization.slug);
448 setOrganizationNameInput(currentOrganization.name);
449 setOrganizationStatusInput(currentOrganization.status);
450 }, [organizationIdInput, organizations.items, workspaceId, workspaces.items]);
451
452 const approvalItems = useMemo(
453 () => optimisticApprovals ?? approvalsState.items,
454 [approvalsState.items, optimisticApprovals],
455 );
456
457 const toolItems = useMemo(() => workspaceTools.items, [workspaceTools.items]);
458
459 const filteredToolItems = useMemo(() => {
460 const query = toolSearchQuery.trim().toLowerCase();
461
462 return toolItems.filter((tool) => {
463 if (selectedToolSourceId !== "all" && tool.sourceId !== selectedToolSourceId) {
464 return false;
465 }
466
467 if (query.length === 0) {
468 return true;
469 }
470
471 return (
472 tool.sourceName.toLowerCase().includes(query)
473 || tool.name.toLowerCase().includes(query)
474 || tool.toolId.toLowerCase().includes(query)
475 || `${tool.method} ${tool.path}`.toLowerCase().includes(query)
476 );
477 });
478 }, [selectedToolSourceId, toolItems, toolSearchQuery]);
479
480 const filteredSourceItems = useMemo(() => {
481 const query = searchQuery.trim().toLowerCase();
482 if (query.length === 0) {
483 return sourceItems;
484 }
485
486 return sourceItems.filter((source) => {
487 return (
488 source.name.toLowerCase().includes(query)
489 || source.type.toLowerCase().includes(query)
490 || source.endpoint.toLowerCase().includes(query)
491 );
492 });
493 }, [searchQuery, sourceItems]);
494
495 const filteredCredentials = useMemo(() => {
496 const query = credentialSearchQuery.trim().toLowerCase();
497 if (query.length === 0) {
498 return credentialItems;
499 }
500
501 return credentialItems.filter((binding) =>
502 binding.sourceKey.toLowerCase().includes(query)
503 || binding.provider.toLowerCase().includes(query)
504 || binding.scopeType.toLowerCase().includes(query)
505 || binding.secretRef.toLowerCase().includes(query),
506 );
507 }, [credentialItems, credentialSearchQuery]);
508
509 const filteredPolicies = useMemo(() => {
510 const query = policySearchQuery.trim().toLowerCase();
511 if (query.length === 0) {
512 return policyItems;
513 }
514
515 return policyItems.filter((policy) =>
516 policy.toolPathPattern.toLowerCase().includes(query)
517 || policy.decision.toLowerCase().includes(query),
518 );
519 }, [policyItems, policySearchQuery]);
520
521 const filteredStorageItems = useMemo(() => {
522 const query = storageSearchQuery.trim().toLowerCase();
523 if (query.length === 0) {
524 return storageItems;
525 }
526
527 return storageItems.filter((storageInstance) => {
528 return (
529 storageInstance.scopeType.toLowerCase().includes(query)
530 || storageInstance.durability.toLowerCase().includes(query)
531 || storageInstance.status.toLowerCase().includes(query)
532 || storageInstance.provider.toLowerCase().includes(query)
533 || storageInstance.backendKey.toLowerCase().includes(query)
534 || (storageInstance.purpose?.toLowerCase().includes(query) ?? false)
535 );
536 });
537 }, [storageItems, storageSearchQuery]);
538
539 const filteredApprovals = useMemo(() => {
540 const query = approvalSearchQuery.trim().toLowerCase();
541
542 return approvalItems.filter((approval) => {
543 if (approvalFilter === "pending" && approval.status !== "pending") {
544 return false;
545 }
546
547 if (
548 approvalFilter === "resolved"
549 && (approval.status === "pending" || approval.status === "expired")
550 ) {
551 return false;
552 }
553
554 if (query.length === 0) {
555 return true;
556 }
557
558 return (
559 approval.toolPath.toLowerCase().includes(query)
560 || approval.status.toLowerCase().includes(query)
561 || approval.callId.toLowerCase().includes(query)
562 || approval.taskRunId.toLowerCase().includes(query)
563 );
564 });
565 }, [approvalFilter, approvalItems, approvalSearchQuery]);
566
567 const pendingApprovalCount = useMemo(
568 () => approvalItems.filter((approval) => approval.status === "pending").length,
569 [approvalItems],
570 );
571
572 const isEditing = Boolean(formState.id);
47 573
48 const handleWorkspaceChange = (value: string) => {574 const handleWorkspaceChange = (value: string) => {
49 setWorkspaceIdInput(value);575 setWorkspaceIdInput(value);
50 setStatusText(null);576 setStatusText(null);
577 setCredentialStatusText(null);
578 setPolicyStatusText(null);
579 setStorageStatusText(null);
580 setApprovalStatusText(null);
581 setOrganizationStatusText(null);
582 setWorkspaceStatusText(null);
583 setSearchQuery("");
584 setToolSearchQuery("");
585 setCredentialSearchQuery("");
586 setPolicySearchQuery("");
587 setStorageSearchQuery("");
588 setApprovalSearchQuery("");
589
590 setCredentialSourceKey("");
591 setCredentialProvider("api_key");
592 setCredentialScopeType("workspace");
593 setCredentialIdInput("");
594 setCredentialSecretRef("");
595 setCredentialAccountId("");
596 setCredentialAdditionalHeadersJson("");
597 setCredentialBoundAuthFingerprint("");
598 setCredentialEditingId(null);
599 setCredentialBusyId(null);
600
601 setPolicyPattern("");
602 setPolicyDecision("require_approval");
603 setPolicyEditingId(null);
604
605 setStorageScopeType("scratch");
606 setStorageDurability("ephemeral");
607 setStorageProvider("agentfs-local");
608 setStoragePurposeInput("");
609 setStorageTtlHoursInput("24");
610 setStorageAccountIdInput("");
611 setStorageBusyId(null);
612 setStorageSelectedId(null);
613 setStorageDirectoryPath("/");
614 setStorageDirectoryEntries([]);
615 setStorageDirectoryBusy(false);
616 setStorageFilePreviewPath(null);
617 setStorageFilePreviewContent("");
618 setStorageFilePreviewBusy(false);
619 setStorageKvPrefix("");
620 setStorageKvLimit("100");
621 setStorageKvItems([]);
622 setStorageKvBusy(false);
623 setStorageSqlText("SELECT name FROM sqlite_master LIMIT 50");
624 setStorageSqlMaxRows("200");
625 setStorageSqlResult(null);
626 setStorageSqlBusy(false);
627
628 setOrganizationIdInput("");
629 setOrganizationSlugInput("");
630 setOrganizationNameInput("");
631 setOrganizationStatusInput("active");
632 setWorkspaceEditIdInput("");
633 setWorkspaceNameInput("");
634 setWorkspaceOrganizationIdInput("");
635 setSelectedToolSourceId("all");
636
637 setFormState(defaultFormState());
638 setOptimisticPolicies(null);
639 setOptimisticApprovals(null);
640 };
641
642 const setFormField = <K extends keyof LegacySourceFormState>(
643 key: K,
644 value: LegacySourceFormState[K],
645 ) => {
646 setFormState((current) => ({
647 ...current,
648 [key]: value,
649 }));
650 };
651
652 const resetForm = () => {
653 setFormState(defaultFormState());
654 };
655
656 const handleTemplateUse = (template: (typeof catalogTemplates)[number]) => {
657 setFormState((current) => ({
658 ...current,
659 id: undefined,
660 name: template.name,
661 type: template.type,
662 endpoint: template.endpoint,
663 baseUrl: "",
664 mcpTransport: "auto",
665 }));
666 setStatusText(`Loaded template for ${template.name}.`);
667 };
668
669 const handleEdit = (sourceId: SourceId) => {
670 const source = sourceItems.find((item) => item.id === sourceId);
671 if (!source) {
672 return;
673 }
674
675 setFormState(formStateFromSource(source));
676 setStatusText(`Editing ${source.name}.`);
677 };
678
679 const handleCancelEdit = () => {
680 resetForm();
681 setStatusText("Edit cancelled.");
682 };
683
684 const handleUpsertOrganization = (event: FormEvent<HTMLFormElement>) => {
685 event.preventDefault();
686
687 const slug = organizationSlugInput.trim();
688 const name = organizationNameInput.trim();
689 const id = organizationIdInput.trim();
690
691 if (slug.length === 0 || name.length === 0) {
692 setOrganizationStatusText("Organization slug and name are required.");
693 return;
694 }
695
696 void runUpsertOrganization({
697 payload: toOrganizationUpsertPayload({
698 id: id.length > 0 ? (id as Organization["id"]) : undefined,
699 slug,
700 name,
701 status: organizationStatusInput,
702 }),
703 })
704 .then((organization) => {
705 setOrganizationStatusText(`Saved organization ${organization.slug}.`);
706 setOrganizationIdInput(organization.id);
707 refreshOrganizations();
708 })
709 .catch(() => {
710 setOrganizationStatusText("Organization save failed.");
711 refreshOrganizations();
712 });
713 };
714
715 const handleUpsertWorkspace = (event: FormEvent<HTMLFormElement>) => {
716 event.preventDefault();
717
718 const name = workspaceNameInput.trim();
719 const id = workspaceEditIdInput.trim();
720 const organizationId = workspaceOrganizationIdInput.trim();
721
722 if (name.length === 0) {
723 setWorkspaceStatusText("Workspace name is required.");
724 return;
725 }
726
727 void runUpsertWorkspace({
728 payload: toWorkspaceUpsertPayload({
729 id: id.length > 0 ? (id as Workspace["id"]) : undefined,
730 name,
731 organizationId:
732 organizationId.length > 0
733 ? (organizationId as Workspace["organizationId"])
734 : null,
735 }),
736 })
737 .then((workspace) => {
738 setWorkspaceStatusText(`Saved workspace ${workspace.name}.`);
739 setWorkspaceEditIdInput(workspace.id);
740 setWorkspaceOrganizationIdInput(workspace.organizationId ?? "");
741 setWorkspaceIdInput(workspace.id);
742 refreshWorkspaces();
743 })
744 .catch(() => {
745 setWorkspaceStatusText("Workspace save failed.");
746 refreshWorkspaces();
747 });
748 };
749
750 const handleLoadWorkspace = (workspace: Workspace) => {
751 setWorkspaceEditIdInput(workspace.id);
752 setWorkspaceNameInput(workspace.name);
753 setWorkspaceOrganizationIdInput(workspace.organizationId ?? "");
754 setWorkspaceIdInput(workspace.id);
755 setWorkspaceStatusText(`Loaded workspace ${workspace.id}.`);
756 };
757
758 const handleLoadOrganization = (organization: Organization) => {
759 setOrganizationIdInput(organization.id);
760 setOrganizationSlugInput(organization.slug);
761 setOrganizationNameInput(organization.name);
762 setOrganizationStatusInput(organization.status);
763 setOrganizationStatusText(`Loaded organization ${organization.slug}.`);
51 };764 };
52 765
53 const handleAddSource = (event: FormEvent<HTMLFormElement>) => {766 const handleUpsertSource = (event: FormEvent<HTMLFormElement>) => {
54 event.preventDefault();767 event.preventDefault();
55 768
56 if (sourcesPending) {769 if (sourcesPending) {
57 return;770 return;
58 }771 }
59 772
60 const sourceId = `src_${crypto.randomUUID()}` as SourceId;773 if (formState.name.trim().length === 0 || formState.endpoint.trim().length === 0) {
774 setStatusText("Name and endpoint are required.");
775 return;
776 }
61 777
62 const payload: UpsertSourcePayload = {778 const sourceId = formState.id ?? (createLocalId("src_") as SourceId);
63 id: sourceId,779 const payload = upsertPayloadFromForm({
64 name,780 workspaceId,
65 kind,781 form: formState,
66 endpoint,782 sourceId,
67 enabled: true,783 });
68 configJson: "{}",
69 status: "draft",
70 sourceHash: null,
71 lastError: null,
72 };
73 784
74 const previousSources = sources.items;785 const previousSources = sources.items;
75 const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload);786 const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload);
@@ -87,7 +798,12 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 11 unmodified lines
87 payload,798 payload,
88 })799 })
89 .then(() => {800 .then(() => {
90 setStatusText("Source saved.");801 setStatusText(
802 isEditing
803 ? `Updated ${formState.name.trim()}.`
804 : `Saved ${formState.name.trim()}.`,
805 );
806 resetForm();
91 refreshSources();807 refreshSources();
92 })808 })
93 .catch(() => {809 .catch(() => {
@@ -118,6 +834,9 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 24 unmodified lines
118 })834 })
119 .then(() => {835 .then(() => {
120 setStatusText("Source removed.");836 setStatusText("Source removed.");
837 if (formState.id === sourceId) {
838 resetForm();
839 }
121 refreshSources();840 refreshSources();
122 })841 })
123 .catch(() => {842 .catch(() => {
@@ -127,122 +846,2184 @@ const Page = ({ authEnabled, initialWorkspaceId }: PageProps) => { · 3 unmodified lines
127 });846 });
128 };847 };
129 848
130 return (849 const handleRefreshTools = async () => {
131 <main>850 if (sourcesPending || toolsRefreshPending) {
132 <section className="shell">851 return;
133 <header className="hero">852 }
134 <div className="hero-top-row">
135 <h1>Executor v2 Control Plane</h1>
136 {authEnabled ? (
137 <a className="sign-out-link" href="/sign-out">
138 Sign out
139 </a>
140 ) : null}
141 </div>
142 <p>
143 Basic Next.js frontend wired to the shared Effect HttpApi client via
144 Effect Atom.
145 </p>
146 </header>
147
148 <div className="grid">
149 <section className="card">
150 <h2>Workspace + Source</h2>
151 <form onSubmit={handleAddSource}>
152 <div className="field">
153 <label htmlFor="workspace-id">Workspace ID</label>
154 <input
155 id="workspace-id"
156 value={workspaceIdInput}
157 onChange={(event) => handleWorkspaceChange(event.target.value)}
158 required
159 />
160 </div>
161 853
162 <div className="field">854 const openApiSources = sourceItems.filter((source) => source.type === "openapi");
163 <label htmlFor="source-name">Name</label>855 if (openApiSources.length === 0) {
164 <input856 setStatusText("No OpenAPI sources available to refresh.");
165 id="source-name"857 refreshWorkspaceTools();
166 value={name}858 return;
167 onChange={(event) => setName(event.target.value)}859 }
168 required
169 />
170 </div>
171 860
172 <div className="field">861 setToolsRefreshPending(true);
173 <label htmlFor="source-kind">Kind</label>862 setStatusText(
174 <select863 `Refreshing tools for ${openApiSources.length} OpenAPI source${
175 id="source-kind"864 openApiSources.length === 1 ? "" : "s"
176 value={kind}865 }...`,
177 onChange={(event) => setKind(event.target.value as SourceKind)}866 );
178 >
179 {kindOptions.map((option) => (
180 <option key={option} value={option}>
181 {option}
182 </option>
183 ))}
184 </select>
185 </div>
186 867
187 <div className="field">868 try {
188 <label htmlFor="source-endpoint">Endpoint</label>869 await Promise.all(
189 <input870 openApiSources.map((source) =>
190 id="source-endpoint"871 runUpsertSource({
191 value={endpoint}872 path: { workspaceId },
192 onChange={(event) => setEndpoint(event.target.value)}873 payload: upsertPayloadFromForm({
193 required874 workspaceId,
194 />875 sourceId: source.id,
195 </div>876 form: formStateFromSource(source),
877 }),
878 }),
879 ),
880 );
196 881
197 <button className="primary" type="submit" disabled={sourcesPending}>882 setStatusText(
198 {sourcesPending ? "Saving..." : "Save Source"}883 `Refreshed tools for ${openApiSources.length} OpenAPI source${
199 </button>884 openApiSources.length === 1 ? "" : "s"
200 </form>885 }.`,
886 );
887 } catch {
888 setStatusText("Tool refresh failed.");
889 } finally {
890 refreshSources();
891 refreshWorkspaceTools();
892 setToolsRefreshPending(false);
893 }
894 };
201 895
202 {statusText ? <p className="status">{statusText}</p> : null}
203 </section>
204 896
205 <section className="card">897 const handleEditCredential = (credentialBindingId: SourceCredentialBinding["id"]) => {
206 <h2>Sources</h2>898 const binding = credentialItems.find((item) => item.id === credentialBindingId);
899 if (!binding) {
900 return;
901 }
207 902
208 {sources.state === "loading" ? (903 setCredentialEditingId(binding.id);
209 <p className="status">Loading sources...</p>904 setCredentialSourceKey(binding.sourceKey);
210 ) : null}905 setCredentialProvider(binding.provider);
906 setCredentialScopeType(binding.scopeType);
907 setCredentialIdInput(binding.credentialId);
908 setCredentialSecretRef(binding.secretRef);
909 setCredentialAccountId(binding.accountId ?? "");
910 setCredentialAdditionalHeadersJson(binding.additionalHeadersJson ?? "");
911 setCredentialBoundAuthFingerprint(binding.boundAuthFingerprint ?? "");
912 setCredentialStatusText(`Editing credential binding ${binding.sourceKey}.`);
913 };
211 914
212 {sources.state === "error" ? (915 const handleCancelCredentialEdit = () => {
213 <p className="status error">{sources.message}</p>916 setCredentialEditingId(null);
214 ) : null}917 setCredentialSourceKey("");
918 setCredentialProvider("api_key");
919 setCredentialScopeType("workspace");
920 setCredentialIdInput("");
921 setCredentialSecretRef("");
922 setCredentialAccountId("");
923 setCredentialAdditionalHeadersJson("");
924 setCredentialBoundAuthFingerprint("");
925 setCredentialStatusText("Credential edit cancelled.");
926 };
215 927
216 {sources.state !== "loading" && sources.items.length === 0 ? (928 const handleUpsertCredential = (event: FormEvent<HTMLFormElement>) => {
217 <p className="status">No sources yet in this workspace.</p>929 event.preventDefault();
218 ) : null}
219 930
220 <div className="list">931 if (credentialBusyId !== null) {
221 {sources.items.map((source) => (932 return;
222 <article className="list-item" key={source.id}>933 }
223 <header>934
224 <div>935 const sourceKey = credentialSourceKey.trim();
225 <strong>{source.name}</strong>936 const credentialId = credentialIdInput.trim();
226 <div className="meta">937 const secretRef = credentialSecretRef.trim();
227 {source.kind} · {source.status}938 const accountId = credentialAccountId.trim();
228 </div>939
229 <div className="meta">{source.endpoint}</div>940 if (sourceKey.length === 0 || credentialId.length === 0 || secretRef.length === 0) {
230 </div>941 setCredentialStatusText("Source key, credential id, and secret ref are required.");
231 <button942 return;
232 className="danger"943 }
233 type="button"944
234 onClick={() => handleRemoveSource(source.id)}945 if (credentialScopeType === "account" && accountId.length === 0) {
235 disabled={sourcesPending}946 setCredentialStatusText("Account scope credentials require account id.");
236 >947 return;
237 Remove948 }
238 </button>949
239 </header>950 const requestId =
240 </article>951 credentialEditingId
241 ))}952 ?? (createLocalId("credential_binding_") as SourceCredentialBinding["id"]);
242 </div>953
243 </section>954 setCredentialBusyId(requestId);
244 </div>955
245 </section>956 void runUpsertCredentialBinding({
957 path: { workspaceId },
958 payload: toCredentialBindingUpsertPayload({
959 id: credentialEditingId ?? undefined,
960 credentialId: credentialId as SourceCredentialBinding["credentialId"],
961 scopeType: credentialScopeType,
962 sourceKey,
963 provider: credentialProvider,
964 secretRef,
965 accountId: credentialScopeType === "account"
966 ? (accountId as SourceCredentialBinding["accountId"])
967 : null,
968 additionalHeadersJson:
969 credentialAdditionalHeadersJson.trim().length > 0
970 ? credentialAdditionalHeadersJson.trim()
971 : null,
972 boundAuthFingerprint:
973 credentialBoundAuthFingerprint.trim().length > 0
974 ? credentialBoundAuthFingerprint.trim()
975 : null,
976 }),
977 })
978 .then(() => {
979 setCredentialStatusText(
980 credentialEditingId
981 ? `Updated credential binding ${sourceKey}.`
982 : `Added credential binding ${sourceKey}.`,
983 );
984 setCredentialEditingId(null);
985 setCredentialSourceKey("");
986 setCredentialProvider("api_key");
987 setCredentialScopeType("workspace");
988 setCredentialIdInput("");
989 setCredentialSecretRef("");
990 setCredentialAccountId("");
991 setCredentialAdditionalHeadersJson("");
992 setCredentialBoundAuthFingerprint("");
993 refreshCredentialBindings();
994 })
995 .catch(() => {
996 setCredentialStatusText("Credential save failed.");
997 refreshCredentialBindings();
998 })
999 .finally(() => {
1000 setCredentialBusyId(null);
1001 });
1002 };
1003
1004 const handleRemoveCredential = (credentialBindingId: SourceCredentialBinding["id"]) => {
1005 if (credentialBusyId !== null) {
1006 return;
1007 }
1008
1009 setCredentialBusyId(credentialBindingId);
1010
1011 void runRemoveCredentialBinding({
1012 path: {
1013 workspaceId,
1014 credentialBindingId,
1015 },
1016 })
1017 .then((result) => {
1018 const removed = toCredentialBindingRemoveResult(result);
1019 setCredentialStatusText(
1020 removed ? "Credential binding removed." : "Credential binding not found.",
1021 );
1022
1023 if (credentialEditingId === credentialBindingId) {
1024 handleCancelCredentialEdit();
1025 }
1026
1027 refreshCredentialBindings();
1028 })
1029 .catch(() => {
1030 setCredentialStatusText("Credential removal failed.");
1031 refreshCredentialBindings();
1032 })
1033 .finally(() => {
1034 setCredentialBusyId(null);
1035 });
1036 };
1037
1038 const handleEditPolicy = (policyId: PolicyId) => {
1039 const policy = policyItems.find((item) => item.id === policyId);
1040 if (!policy) {
1041 return;
1042 }
1043
1044 setPolicyEditingId(policy.id);
1045 setPolicyPattern(policy.toolPathPattern);
1046 setPolicyDecision(policy.decision);
1047 setPolicyStatusText(`Editing policy ${policy.toolPathPattern}.`);
1048 };
1049
1050 const handleCancelPolicyEdit = () => {
1051 setPolicyEditingId(null);
1052 setPolicyPattern("");
1053 setPolicyDecision("require_approval");
1054 setPolicyStatusText("Policy edit cancelled.");
1055 };
1056
1057 const handleUpsertPolicy = (event: FormEvent<HTMLFormElement>) => {
1058 event.preventDefault();
1059
1060 if (policyBusyId !== null) {
1061 return;
1062 }
1063
1064 const toolPathPattern = policyPattern.trim();
1065 if (toolPathPattern.length === 0) {
1066 setPolicyStatusText("Tool path pattern is required.");
1067 return;
1068 }
1069
1070 const policyId = policyEditingId ?? (createLocalId("pol_") as PolicyId);
1071 const nextPolicies = optimisticUpsertPolicy(policyItems, {
1072 workspaceId,
1073 policyId,
1074 toolPathPattern,
1075 decision: policyDecision,
1076 });
1077
1078 setPolicyBusyId(policyId);
1079 setOptimisticPolicies(nextPolicies);
1080
1081 void runUpsertPolicy({
1082 path: { workspaceId },
1083 payload: toPolicyUpsertPayload({
1084 id: policyEditingId ?? undefined,
1085 toolPathPattern,
1086 decision: policyDecision,
1087 }),
1088 })
1089 .then(() => {
1090 setPolicyStatusText(
1091 policyEditingId
1092 ? `Updated policy ${toolPathPattern}.`
1093 : `Added policy ${toolPathPattern}.`,
1094 );
1095 setPolicyEditingId(null);
1096 setPolicyPattern("");
1097 setPolicyDecision("require_approval");
1098 setOptimisticPolicies(null);
1099 refreshPolicies();
1100 })
1101 .catch(() => {
1102 setPolicyStatusText("Policy save failed.");
1103 setOptimisticPolicies(null);
1104 refreshPolicies();
1105 })
1106 .finally(() => {
1107 setPolicyBusyId(null);
1108 });
1109 };
1110
1111 const handleRemovePolicy = (policyId: PolicyId) => {
1112 if (policyBusyId !== null) {
1113 return;
1114 }
1115
1116 const nextPolicies = optimisticRemovePolicy(policyItems, policyId);
1117 setPolicyBusyId(policyId);
1118 setOptimisticPolicies(nextPolicies);
1119
1120 void runRemovePolicy({
1121 path: { workspaceId, policyId },
1122 })
1123 .then((result) => {
1124 const removed = toPolicyRemoveResult(result);
1125 setPolicyStatusText(removed ? "Policy removed." : "Policy not found.");
1126 if (policyEditingId === policyId) {
1127 setPolicyEditingId(null);
1128 setPolicyPattern("");
1129 setPolicyDecision("require_approval");
1130 }
1131 setOptimisticPolicies(null);
1132 refreshPolicies();
1133 })
1134 .catch(() => {
1135 setPolicyStatusText("Policy removal failed.");
1136 setOptimisticPolicies(null);
1137 refreshPolicies();
1138 })
1139 .finally(() => {
1140 setPolicyBusyId(null);
1141 });
1142 };
1143
1144 const handleOpenStorage = (event: FormEvent<HTMLFormElement>) => {
1145 event.preventDefault();
1146
1147 if (storageBusyId !== null) {
1148 return;
1149 }
1150
1151 const purpose = storagePurposeInput.trim();
1152 const accountId = storageAccountIdInput.trim();
1153 const ttlHours = Number.parseInt(storageTtlHoursInput, 10);
1154
1155 if (storageScopeType === "account" && accountId.length === 0) {
1156 setStorageStatusText("Account scope storage requires account id.");
1157 return;
1158 }
1159
1160 if (
1161 storageDurability === "ephemeral"
1162 && (!Number.isFinite(ttlHours) || ttlHours <= 0)
1163 ) {
1164 setStorageStatusText("Ephemeral storage requires a positive TTL in hours.");
1165 return;
1166 }
1167
1168 setStorageBusyId("create");
1169
1170 void runOpenStorageInstance({
1171 path: { workspaceId },
1172 payload: toOpenStoragePayload({
1173 scopeType: storageScopeType,
1174 durability: storageDurability,
1175 provider: storageProvider,
1176 purpose: purpose.length > 0 ? purpose : undefined,
1177 ttlHours: storageDurability === "ephemeral" ? ttlHours : undefined,
1178 accountId:
1179 storageScopeType === "account"
1180 ? (accountId as Exclude<StorageInstance["accountId"], null>)
1181 : undefined,
1182 }),
1183 })
1184 .then((storageInstance) => {
1185 setStorageStatusText(`Opened storage instance ${storageInstance.id}.`);
1186 setStoragePurposeInput("");
1187 setStorageAccountIdInput("");
1188 setStorageSelectedId(storageInstance.id);
1189 refreshStorage();
1190 })
1191 .catch(() => {
1192 setStorageStatusText("Storage open failed.");
1193 refreshStorage();
1194 })
1195 .finally(() => {
1196 setStorageBusyId(null);
1197 });
1198 };
1199
1200 const handleCloseStorage = (storageInstanceId: StorageInstance["id"]) => {
1201 if (storageBusyId !== null) {
1202 return;
1203 }
1204
1205 setStorageBusyId(storageInstanceId);
1206
1207 void runCloseStorageInstance({
1208 path: {
1209 workspaceId,
1210 storageInstanceId,
1211 },
1212 })
1213 .then(() => {
1214 setStorageStatusText("Storage instance closed.");
1215 refreshStorage();
1216 })
1217 .catch(() => {
1218 setStorageStatusText("Storage close failed.");
1219 refreshStorage();
1220 })
1221 .finally(() => {
1222 setStorageBusyId(null);
1223 });
1224 };
1225
1226 const handleRemoveStorage = (storageInstanceId: StorageInstance["id"]) => {
1227 if (storageBusyId !== null) {
1228 return;
1229 }
1230
1231 setStorageBusyId(storageInstanceId);
1232
1233 void runRemoveStorageInstance({
1234 path: {
1235 workspaceId,
1236 storageInstanceId,
1237 },
1238 })
1239 .then((result) => {
1240 const removed = toStorageRemoveResult(result);
1241 setStorageStatusText(
1242 removed ? "Storage instance removed." : "Storage instance not found.",
1243 );
1244 if (storageSelectedId === storageInstanceId) {
1245 setStorageSelectedId(null);
1246 setStorageDirectoryEntries([]);
1247 setStorageFilePreviewPath(null);
1248 setStorageFilePreviewContent("");
1249 setStorageKvItems([]);
1250 setStorageSqlResult(null);
1251 }
1252 refreshStorage();
1253 })
1254 .catch(() => {
1255 setStorageStatusText("Storage removal failed.");
1256 refreshStorage();
1257 })
1258 .finally(() => {
1259 setStorageBusyId(null);
1260 });
1261 };
1262
1263 const handleListStorageDirectory = (nextPath?: string) => {
1264 if (selectedStorageInstance === null || storageDirectoryBusy) {
1265 return;
1266 }
1267
1268 const pathValue = (nextPath ?? storageDirectoryPath).trim();
1269 const normalizedPath = pathValue.length > 0 ? pathValue : "/";
1270
1271 setStorageDirectoryBusy(true);
1272
1273 void runListStorageDirectory({
1274 path: {
1275 workspaceId,
1276 storageInstanceId: selectedStorageInstance.id,
1277 },
1278 payload: toListStorageDirectoryPayload({
1279 path: normalizedPath,
1280 }),
1281 })
1282 .then((result) => {
1283 const directory = toStorageDirectoryResult(result);
1284 setStorageDirectoryPath(directory.path);
1285 setStorageDirectoryEntries(directory.entries);
1286 setStorageStatusText(`Loaded directory ${directory.path}.`);
1287 })
1288 .catch(() => {
1289 setStorageStatusText("Directory listing failed.");
1290 })
1291 .finally(() => {
1292 setStorageDirectoryBusy(false);
1293 });
1294 };
1295
1296 const handleReadStorageFile = (filePath: string) => {
1297 if (selectedStorageInstance === null || storageFilePreviewBusy) {
1298 return;
1299 }
1300
1301 setStorageFilePreviewBusy(true);
1302
1303 void runReadStorageFile({
1304 path: {
1305 workspaceId,
1306 storageInstanceId: selectedStorageInstance.id,
1307 },
1308 payload: toReadStorageFilePayload({
1309 path: filePath,
1310 encoding: "utf8",
1311 }),
1312 })
1313 .then((result) => {
1314 const fileResult = toStorageReadFileResult(result);
1315 setStorageFilePreviewPath(fileResult.path);
1316 setStorageFilePreviewContent(fileResult.content);
1317 })
1318 .catch(() => {
1319 setStorageStatusText("File read failed.");
1320 })
1321 .finally(() => {
1322 setStorageFilePreviewBusy(false);
1323 });
1324 };
1325
1326 const handleListStorageKv = () => {
1327 if (selectedStorageInstance === null || storageKvBusy) {
1328 return;
1329 }
1330
1331 const parsedLimit = Number.parseInt(storageKvLimit, 10);
1332
1333 setStorageKvBusy(true);
1334
1335 void runListStorageKv({
1336 path: {
1337 workspaceId,
1338 storageInstanceId: selectedStorageInstance.id,
1339 },
1340 payload: toListStorageKvPayload({
1341 prefix: storageKvPrefix.trim(),
1342 limit: Number.isFinite(parsedLimit) ? parsedLimit : undefined,
1343 }),
1344 })
1345 .then((result) => {
1346 const kvResult = toStorageKvResult(result);
1347 setStorageKvItems(kvResult.items);
1348 })
1349 .catch(() => {
1350 setStorageStatusText("KV listing failed.");
1351 })
1352 .finally(() => {
1353 setStorageKvBusy(false);
1354 });
1355 };
1356
1357 const handleQueryStorageSql = () => {
1358 if (selectedStorageInstance === null || storageSqlBusy) {
1359 return;
1360 }
1361
1362 const sql = storageSqlText.trim();
1363 const maxRows = Number.parseInt(storageSqlMaxRows, 10);
1364
1365 if (sql.length === 0) {
1366 setStorageStatusText("SQL query is required.");
1367 return;
1368 }
1369
1370 setStorageSqlBusy(true);
1371
1372 void runQueryStorageSql({
1373 path: {
1374 workspaceId,
1375 storageInstanceId: selectedStorageInstance.id,
1376 },
1377 payload: toQueryStorageSqlPayload({
1378 sql,
1379 maxRows: Number.isFinite(maxRows) ? maxRows : undefined,
1380 }),
1381 })
1382 .then((result) => {
1383 const sqlResult = toStorageSqlResult(result);
1384 setStorageSqlResult(sqlResult);
1385 })
1386 .catch(() => {
1387 setStorageStatusText("SQL query failed.");
1388 })
1389 .finally(() => {
1390 setStorageSqlBusy(false);
1391 });
1392 };
1393
1394 const handleSelectStorageInstance = (storageInstanceId: StorageInstance["id"]) => {
1395 setStorageSelectedId(storageInstanceId);
1396 setStorageDirectoryPath("/");
1397 setStorageDirectoryEntries([]);
1398 setStorageFilePreviewPath(null);
1399 setStorageFilePreviewContent("");
1400 setStorageKvItems([]);
1401 setStorageSqlResult(null);
1402 };
1403
1404 const handleResolveApproval = (approvalId: ApprovalId, status: "approved" | "denied") => {
1405 if (approvalBusyId !== null) {
1406 return;
1407 }
1408
1409 setApprovalBusyId(approvalId);
1410
1411 const nextOptimistic = optimisticResolveApproval(approvalItems, {
1412 approvalId,
1413 payload: {
1414 status,
1415 reason: null,
1416 },
1417 });
1418
1419 setOptimisticApprovals(nextOptimistic);
1420
1421 void runResolveApproval({
1422 path: {
1423 workspaceId,
1424 approvalId,
1425 },
1426 payload: {
1427 status,
1428 reason: null,
1429 },
1430 })
1431 .then(() => {
1432 setApprovalStatusText(
1433 status === "approved" ? "Approval granted." : "Approval denied.",
1434 );
1435 setOptimisticApprovals(null);
1436 refreshApprovals();
1437 })
1438 .catch(() => {
1439 setApprovalStatusText("Approval update failed.");
1440 setOptimisticApprovals(null);
1441 refreshApprovals();
1442 })
1443 .finally(() => {
1444 setApprovalBusyId(null);
1445 });
1446 };
1447
1448 return (
1449 <main className="min-h-screen py-8 sm:py-10">
1450 <div className="mx-auto w-full max-w-7xl px-4 sm:px-6">
1451 <Card className="overflow-hidden border-border/70 bg-card/95 backdrop-blur-sm">
1452 <CardHeader className="space-y-4 border-b border-dashed border-border/80 pb-5">
1453 <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
1454 <div className="space-y-2">
1455 <CardTitle className="text-2xl sm:text-3xl">Executor v2 Console</CardTitle>
1456 <CardDescription>
1457 Migrated legacy control workflows onto the v2 control-plane APIs.
1458 </CardDescription>
1459 </div>
1460 {authEnabled ? (
1461 <a
1462 href="/sign-out"
1463 className="inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
1464 >
1465 Sign out
1466 </a>
1467 ) : null}
1468 </div>
1469
1470 <div className="grid gap-2 sm:max-w-sm">
1471 <label className="text-xs font-medium uppercase tracking-wide text-muted-foreground" htmlFor="workspace-id">
1472 Workspace
1473 </label>
1474 <Input
1475 id="workspace-id"
1476 value={workspaceIdInput}
1477 onChange={(event) => handleWorkspaceChange(event.target.value)}
1478 required
1479 />
1480 </div>
1481
1482 <div className="flex flex-wrap gap-2" role="tablist" aria-label="Console tabs">
1483 <Button
1484 type="button"
1485 size="sm"
1486 variant={activeTab === "sources" ? "default" : "secondary"}
1487 onClick={() => setActiveTab("sources")}
1488 role="tab"
1489 aria-selected={activeTab === "sources"}
1490 >
1491 Sources
1492 </Button>
1493 <Button
1494 type="button"
1495 size="sm"
1496 variant={activeTab === "tools" ? "default" : "secondary"}
1497 onClick={() => setActiveTab("tools")}
1498 role="tab"
1499 aria-selected={activeTab === "tools"}
1500 >
1501 Tools
1502 </Button>
1503 <Button
1504 type="button"
1505 size="sm"
1506 variant={activeTab === "credentials" ? "default" : "secondary"}
1507 onClick={() => setActiveTab("credentials")}
1508 role="tab"
1509 aria-selected={activeTab === "credentials"}
1510 >
1511 Credentials
1512 </Button>
1513 <Button
1514 type="button"
1515 size="sm"
1516 variant={activeTab === "policies" ? "default" : "secondary"}
1517 onClick={() => setActiveTab("policies")}
1518 role="tab"
1519 aria-selected={activeTab === "policies"}
1520 >
1521 Policies
1522 </Button>
1523 <Button
1524 type="button"
1525 size="sm"
1526 variant={activeTab === "organizations" ? "default" : "secondary"}
1527 onClick={() => setActiveTab("organizations")}
1528 role="tab"
1529 aria-selected={activeTab === "organizations"}
1530 >
1531 Organizations
1532 </Button>
1533 <Button
1534 type="button"
1535 size="sm"
1536 variant={activeTab === "workspaces" ? "default" : "secondary"}
1537 onClick={() => setActiveTab("workspaces")}
1538 role="tab"
1539 aria-selected={activeTab === "workspaces"}
1540 >
1541 Workspaces
1542 </Button>
1543 <Button
1544 type="button"
1545 size="sm"
1546 variant={activeTab === "storage" ? "default" : "secondary"}
1547 onClick={() => setActiveTab("storage")}
1548 role="tab"
1549 aria-selected={activeTab === "storage"}
1550 >
1551 Storage
1552 </Button>
1553 <Button
1554 type="button"
1555 size="sm"
1556 variant={activeTab === "approvals" ? "default" : "secondary"}
1557 onClick={() => setActiveTab("approvals")}
1558 role="tab"
1559 aria-selected={activeTab === "approvals"}
1560 className="gap-2"
1561 >
1562 Approvals
1563 {pendingApprovalCount > 0 ? (
1564 <span className="inline-flex min-w-[1.2rem] items-center justify-center rounded-full bg-background/20 px-1 text-[10px]">
1565 {pendingApprovalCount}
1566 </span>
1567 ) : null}
1568 </Button>
1569 </div>
1570 </CardHeader>
1571
1572 <CardContent className="pt-6">
1573 {activeTab === "sources" ? (
1574 <div className="grid gap-5 lg:grid-cols-2">
1575 <Card className="border-border/70">
1576 <CardHeader className="pb-3">
1577 <CardTitle>{isEditing ? "Edit Source" : "Add Source"}</CardTitle>
1578 <CardDescription>
1579 Manage endpoints, transport, and auth wiring in one place.
1580 </CardDescription>
1581 </CardHeader>
1582 <CardContent className="space-y-4">
1583 <div className="grid gap-2 sm:grid-cols-2">
1584 {catalogTemplates.map((template) => (
1585 <Button
1586 key={template.name}
1587 type="button"
1588 variant="outline"
1589 size="sm"
1590 className="justify-start"
1591 onClick={() => handleTemplateUse(template)}
1592 >
1593 {template.name}
1594 </Button>
1595 ))}
1596 </div>
1597
1598 <form className="space-y-3" onSubmit={handleUpsertSource}>
1599 <div className="grid gap-1.5">
1600 <label className="text-xs text-muted-foreground" htmlFor="source-name">Name</label>
1601 <Input
1602 id="source-name"
1603 value={formState.name}
1604 onChange={(event) => setFormField("name", event.target.value)}
1605 required
1606 />
1607 </div>
1608
1609 <div className="grid gap-1.5">
1610 <label className="text-xs text-muted-foreground" htmlFor="source-kind">Kind</label>
1611 <Select
1612 id="source-kind"
1613 value={formState.type}
1614 onChange={(event) =>
1615 setFormField("type", event.target.value as LegacySourceType)
1616 }
1617 >
1618 {kindOptions.map((option) => (
1619 <option key={option} value={option}>
1620 {option}
1621 </option>
1622 ))}
1623 </Select>
1624 </div>
1625
1626 <div className="grid gap-1.5">
1627 <label className="text-xs text-muted-foreground" htmlFor="source-endpoint">Endpoint</label>
1628 <Input
1629 id="source-endpoint"
1630 value={formState.endpoint}
1631 onChange={(event) => setFormField("endpoint", event.target.value)}
1632 placeholder="https://api.example.com/openapi.json"
1633 required
1634 />
1635 </div>
1636
1637 {formState.type === "openapi" ? (
1638 <div className="grid gap-1.5">
1639 <label className="text-xs text-muted-foreground" htmlFor="source-base-url">Base URL</label>
1640 <Input
1641 id="source-base-url"
1642 value={formState.baseUrl}
1643 onChange={(event) => setFormField("baseUrl", event.target.value)}
1644 placeholder="https://api.example.com"
1645 />
1646 </div>
1647 ) : null}
1648
1649 {formState.type === "mcp" ? (
1650 <div className="grid gap-1.5">
1651 <label className="text-xs text-muted-foreground" htmlFor="source-transport">MCP Transport</label>
1652 <Select
1653 id="source-transport"
1654 value={formState.mcpTransport}
1655 onChange={(event) =>
1656 setFormField(
1657 "mcpTransport",
1658 event.target.value as "auto" | "streamable-http" | "sse",
1659 )
1660 }
1661 >
1662 <option value="auto">auto</option>
1663 <option value="streamable-http">streamable-http</option>
1664 <option value="sse">sse</option>
1665 </Select>
1666 </div>
1667 ) : null}
1668
1669 <div className="grid gap-3 sm:grid-cols-2">
1670 <div className="grid gap-1.5">
1671 <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label>
1672 <Select
1673 id="source-auth-type"
1674 value={formState.authType}
1675 onChange={(event) =>
1676 setFormField(
1677 "authType",
1678 event.target.value as "none" | "bearer" | "apiKey" | "basic",
1679 )
1680 }
1681 >
1682 <option value="none">none</option>
1683 <option value="bearer">bearer</option>
1684 <option value="apiKey">apiKey</option>
1685 <option value="basic">basic</option>
1686 </Select>
1687 </div>
1688
1689 <div className="grid gap-1.5">
1690 <label className="text-xs text-muted-foreground" htmlFor="source-auth-mode">Auth Scope</label>
1691 <Select
1692 id="source-auth-mode"
1693 value={formState.authMode}
1694 onChange={(event) =>
1695 setFormField(
1696 "authMode",
1697 event.target.value as "workspace" | "organization" | "account",
1698 )
1699 }
1700 >
1701 <option value="workspace">workspace</option>
1702 <option value="organization">organization</option>
1703 <option value="account">account</option>
1704 </Select>
1705 </div>
1706 </div>
1707
1708 {formState.authType === "apiKey" ? (
1709 <div className="grid gap-1.5">
1710 <label className="text-xs text-muted-foreground" htmlFor="source-auth-header">API Key Header</label>
1711 <Input
1712 id="source-auth-header"
1713 value={formState.apiKeyHeader}
1714 onChange={(event) => setFormField("apiKeyHeader", event.target.value)}
1715 placeholder="Authorization"
1716 />
1717 </div>
1718 ) : null}
1719
1720 <div className="flex items-center justify-between rounded-md border border-border bg-muted/35 px-3 py-2">
1721 <label htmlFor="source-enabled" className="text-xs font-medium">Enabled</label>
1722 <input
1723 id="source-enabled"
1724 checked={formState.enabled}
1725 onChange={(event) => setFormField("enabled", event.target.checked)}
1726 type="checkbox"
1727 className="h-4 w-4 rounded border-input bg-background text-primary"
1728 />
1729 </div>
1730
1731 <div className="grid gap-2 sm:grid-cols-2">
1732 <Button type="submit" disabled={sourcesPending}>
1733 {sourcesPending
1734 ? "Saving..."
1735 : isEditing
1736 ? "Save Source"
1737 : "Add Source"}
1738 </Button>
1739 {isEditing ? (
1740 <Button
1741 type="button"
1742 variant="outline"
1743 onClick={handleCancelEdit}
1744 disabled={sourcesPending}
1745 >
1746 Cancel
1747 </Button>
1748 ) : null}
1749 </div>
1750 </form>
1751
1752 {statusText ? (
1753 <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
1754 {statusText}
1755 </div>
1756 ) : null}
1757 </CardContent>
1758 </Card>
1759
1760 <Card className="border-border/70">
1761 <CardHeader className="pb-3">
1762 <CardTitle>Sources</CardTitle>
1763 <CardDescription>Review existing sources and update quickly.</CardDescription>
1764 </CardHeader>
1765 <CardContent className="space-y-4">
1766 <div className="grid gap-1.5">
1767 <label className="text-xs text-muted-foreground" htmlFor="source-search">Search</label>
1768 <Input
1769 id="source-search"
1770 value={searchQuery}
1771 onChange={(event) => setSearchQuery(event.target.value)}
1772 placeholder="Search by name, kind, endpoint"
1773 />
1774 </div>
1775
1776 {sources.state === "loading" ? (
1777 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
1778 Loading sources...
1779 </div>
1780 ) : null}
1781
1782 {sources.state === "error" ? (
1783 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
1784 {sources.message}
1785 </div>
1786 ) : null}
1787
1788 {sources.state !== "loading" && filteredSourceItems.length === 0 ? (
1789 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
1790 {sourceItems.length === 0
1791 ? "No sources yet in this workspace."
1792 : "No sources match your search."}
1793 </div>
1794 ) : null}
1795
1796 <div className="space-y-2">
1797 {filteredSourceItems.map((source) => (
1798 <div
1799 key={source.id}
1800 className="rounded-lg border border-border bg-background/70 p-3"
1801 >
1802 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
1803 <div className="min-w-0 space-y-1">
1804 <p className="truncate text-sm font-medium">{source.name}</p>
1805 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
1806 <Badge variant="outline">{source.type}</Badge>
1807 <span>{source.status}</span>
1808 <span>{source.enabled ? "enabled" : "disabled"}</span>
1809 </div>
1810 <p className="break-all text-xs text-muted-foreground">
1811 {source.endpoint}
1812 </p>
1813 </div>
1814 <div className="flex items-center gap-2">
1815 <Button
1816 type="button"
1817 size="sm"
1818 variant="outline"
1819 onClick={() => handleEdit(source.id)}
1820 disabled={sourcesPending}
1821 >
1822 Edit
1823 </Button>
1824 <Button
1825 type="button"
1826 size="sm"
1827 variant="destructive"
1828 onClick={() => handleRemoveSource(source.id)}
1829 disabled={sourcesPending}
1830 >
1831 Remove
1832 </Button>
1833 </div>
1834 </div>
1835 </div>
1836 ))}
1837 </div>
1838 </CardContent>
1839 </Card>
1840 </div>
1841 ) : null}
1842
1843 {activeTab === "tools" ? (
1844 <div className="space-y-5">
1845 <Card className="border-border/70">
1846 <CardHeader className="pb-3">
1847 <CardTitle>Source Tools</CardTitle>
1848 <CardDescription>
1849 Inspect extracted tools for each source in the current workspace.
1850 </CardDescription>
1851 </CardHeader>
1852 <CardContent className="space-y-4">
1853 <div className="grid gap-3 md:grid-cols-2">
1854 <div className="grid gap-1.5">
1855 <label className="text-xs text-muted-foreground" htmlFor="tools-source-filter">
1856 Source filter
1857 </label>
1858 <Select
1859 id="tools-source-filter"
1860 value={selectedToolSourceId}
1861 onChange={(event) =>
1862 setSelectedToolSourceId(event.target.value as SourceId | "all")
1863 }
1864 >
1865 <option value="all">All sources</option>
1866 {sourceItems.map((source) => (
1867 <option key={source.id} value={source.id}>
1868 {source.name}
1869 </option>
1870 ))}
1871 </Select>
1872 </div>
1873 <div className="grid gap-1.5">
1874 <label className="text-xs text-muted-foreground" htmlFor="tools-search">
1875 Search
1876 </label>
1877 <Input
1878 id="tools-search"
1879 value={toolSearchQuery}
1880 onChange={(event) => setToolSearchQuery(event.target.value)}
1881 placeholder="Search tool id, name, path"
1882 />
1883 </div>
1884 </div>
1885
1886 <div className="flex justify-end">
1887 <Button
1888 type="button"
1889 variant="outline"
1890 size="sm"
1891 onClick={() => {
1892 void handleRefreshTools();
1893 }}
1894 disabled={sourcesPending || toolsRefreshPending}
1895 >
1896 {toolsRefreshPending ? "Refreshing..." : "Refresh tools"}
1897 </Button>
1898 </div>
1899
1900 {workspaceTools.state === "loading" ? (
1901 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
1902 Loading tools...
1903 </div>
1904 ) : null}
1905
1906 {workspaceTools.state === "error" ? (
1907 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
1908 {workspaceTools.message}
1909 </div>
1910 ) : null}
1911
1912 {workspaceTools.state !== "loading" && filteredToolItems.length === 0 ? (
1913 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
1914 No tools found for this workspace/source filter.
1915 </div>
1916 ) : null}
1917
1918 <div className="space-y-2">
1919 {filteredToolItems.map((tool) => (
1920 <div key={`${tool.sourceId}:${tool.toolId}`} className="rounded-lg border border-border bg-background/70 p-3">
1921 <div className="space-y-1">
1922 <p className="text-sm font-medium">{tool.name}</p>
1923 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
1924 <Badge variant="outline">{tool.sourceName}</Badge>
1925 <Badge variant="outline">{tool.method.toUpperCase()}</Badge>
1926 <span className="break-all">{tool.path}</span>
1927 </div>
1928 <p className="break-all text-xs text-muted-foreground">{tool.toolId}</p>
1929 </div>
1930 </div>
1931 ))}
1932 </div>
1933 </CardContent>
1934 </Card>
1935 </div>
1936 ) : null}
1937
1938 {activeTab === "credentials" ? (
1939 <div className="grid gap-5 lg:grid-cols-2">
1940 <Card className="border-border/70">
1941 <CardHeader className="pb-3">
1942 <CardTitle>{credentialEditingId ? "Edit Credential Binding" : "Add Credential Binding"}</CardTitle>
1943 <CardDescription>
1944 Bind workspace credentials to sources and providers.
1945 </CardDescription>
1946 </CardHeader>
1947 <CardContent className="space-y-4">
1948 <form className="space-y-3" onSubmit={handleUpsertCredential}>
1949 <div className="grid gap-1.5">
1950 <label className="text-xs text-muted-foreground" htmlFor="credential-source-key">Source key</label>
1951 <Input
1952 id="credential-source-key"
1953 value={credentialSourceKey}
1954 onChange={(event) => setCredentialSourceKey(event.target.value)}
1955 placeholder="source_github"
1956 required
1957 />
1958 </div>
1959
1960 <div className="grid gap-3 sm:grid-cols-2">
1961 <div className="grid gap-1.5">
1962 <label className="text-xs text-muted-foreground" htmlFor="credential-provider">Provider</label>
1963 <Select
1964 id="credential-provider"
1965 value={credentialProvider}
1966 onChange={(event) =>
1967 setCredentialProvider(event.target.value as CredentialProvider)
1968 }
1969 >
1970 <option value="api_key">api_key</option>
1971 <option value="bearer">bearer</option>
1972 <option value="oauth2">oauth2</option>
1973 <option value="custom">custom</option>
1974 </Select>
1975 </div>
1976
1977 <div className="grid gap-1.5">
1978 <label className="text-xs text-muted-foreground" htmlFor="credential-scope-type">Scope</label>
1979 <Select
1980 id="credential-scope-type"
1981 value={credentialScopeType}
1982 onChange={(event) =>
1983 setCredentialScopeType(event.target.value as CredentialScopeType)
1984 }
1985 >
1986 <option value="workspace">workspace</option>
1987 <option value="organization">organization</option>
1988 <option value="account">account</option>
1989 </Select>
1990 </div>
1991 </div>
1992
1993 <div className="grid gap-3 sm:grid-cols-2">
1994 <div className="grid gap-1.5">
1995 <label className="text-xs text-muted-foreground" htmlFor="credential-id">Credential id</label>
1996 <Input
1997 id="credential-id"
1998 value={credentialIdInput}
1999 onChange={(event) => setCredentialIdInput(event.target.value)}
2000 placeholder="cred_123"
2001 required
2002 />
2003 </div>
2004
2005 <div className="grid gap-1.5">
2006 <label className="text-xs text-muted-foreground" htmlFor="credential-secret-ref">Secret ref</label>
2007 <Input
2008 id="credential-secret-ref"
2009 value={credentialSecretRef}
2010 onChange={(event) => setCredentialSecretRef(event.target.value)}
2011 placeholder="secrets/github-token"
2012 required
2013 />
2014 </div>
2015 </div>
2016
2017 {credentialScopeType === "account" ? (
2018 <div className="grid gap-1.5">
2019 <label className="text-xs text-muted-foreground" htmlFor="credential-account-id">Account id</label>
2020 <Input
2021 id="credential-account-id"
2022 value={credentialAccountId}
2023 onChange={(event) => setCredentialAccountId(event.target.value)}
2024 placeholder="acct_123"
2025 required
2026 />
2027 </div>
2028 ) : null}
2029
2030 <div className="grid gap-1.5">
2031 <label className="text-xs text-muted-foreground" htmlFor="credential-headers-json">Additional headers JSON (optional)</label>
2032 <Input
2033 id="credential-headers-json"
2034 value={credentialAdditionalHeadersJson}
2035 onChange={(event) => setCredentialAdditionalHeadersJson(event.target.value)}
2036 placeholder='{"X-Team": "tools"}'
2037 />
2038 </div>
2039
2040 <div className="grid gap-1.5">
2041 <label className="text-xs text-muted-foreground" htmlFor="credential-auth-fingerprint">Bound auth fingerprint (optional)</label>
2042 <Input
2043 id="credential-auth-fingerprint"
2044 value={credentialBoundAuthFingerprint}
2045 onChange={(event) => setCredentialBoundAuthFingerprint(event.target.value)}
2046 placeholder="fingerprint hash"
2047 />
2048 </div>
2049
2050 <div className="grid gap-2 sm:grid-cols-2">
2051 <Button type="submit" disabled={credentialBusyId !== null}>
2052 {credentialBusyId !== null
2053 ? "Saving..."
2054 : credentialEditingId
2055 ? "Save Binding"
2056 : "Add Binding"}
2057 </Button>
2058 {credentialEditingId ? (
2059 <Button
2060 type="button"
2061 variant="outline"
2062 onClick={handleCancelCredentialEdit}
2063 disabled={credentialBusyId !== null}
2064 >
2065 Cancel
2066 </Button>
2067 ) : null}
2068 </div>
2069 </form>
2070
2071 {credentialStatusText ? (
2072 <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
2073 {credentialStatusText}
2074 </div>
2075 ) : null}
2076 </CardContent>
2077 </Card>
2078
2079 <Card className="border-border/70">
2080 <CardHeader className="pb-3">
2081 <CardTitle>Credential Bindings</CardTitle>
2082 <CardDescription>Credentials currently linked to sources.</CardDescription>
2083 </CardHeader>
2084 <CardContent className="space-y-4">
2085 <div className="grid gap-1.5">
2086 <label className="text-xs text-muted-foreground" htmlFor="credential-search">Search</label>
2087 <Input
2088 id="credential-search"
2089 value={credentialSearchQuery}
2090 onChange={(event) => setCredentialSearchQuery(event.target.value)}
2091 placeholder="source key, provider, scope, secret"
2092 />
2093 </div>
2094
2095 {credentialBindingsState.state === "loading" ? (
2096 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2097 Loading credential bindings...
2098 </div>
2099 ) : null}
2100
2101 {credentialBindingsState.state === "error" ? (
2102 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
2103 {credentialBindingsState.message}
2104 </div>
2105 ) : null}
2106
2107 {credentialBindingsState.state !== "loading" && filteredCredentials.length === 0 ? (
2108 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2109 No credential bindings found.
2110 </div>
2111 ) : null}
2112
2113 <div className="space-y-2">
2114 {filteredCredentials.map((binding) => {
2115 const isBusy = credentialBusyId === binding.id;
2116
2117 return (
2118 <div
2119 key={binding.id}
2120 className={cn(
2121 "rounded-lg border border-border bg-background/70 p-3",
2122 isBusy && "opacity-80",
2123 )}
2124 >
2125 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
2126 <div className="min-w-0 space-y-1">
2127 <p className="truncate text-sm font-medium">{binding.sourceKey}</p>
2128 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2129 <Badge variant="outline">{binding.provider}</Badge>
2130 <Badge variant="outline">{binding.scopeType}</Badge>
2131 <span>{binding.credentialId}</span>
2132 </div>
2133 <p className="break-all text-xs text-muted-foreground">
2134 secret {binding.secretRef}
2135 </p>
2136 {binding.accountId ? (
2137 <p className="break-all text-xs text-muted-foreground">
2138 account {binding.accountId}
2139 </p>
2140 ) : null}
2141 </div>
2142
2143 <div className="flex items-center gap-2">
2144 <Button
2145 type="button"
2146 size="sm"
2147 variant="outline"
2148 onClick={() => handleEditCredential(binding.id)}
2149 disabled={credentialBusyId !== null}
2150 >
2151 Edit
2152 </Button>
2153 <Button
2154 type="button"
2155 size="sm"
2156 variant="destructive"
2157 onClick={() => handleRemoveCredential(binding.id)}
2158 disabled={credentialBusyId !== null}
2159 >
2160 {isBusy ? "Working..." : "Remove"}
2161 </Button>
2162 </div>
2163 </div>
2164 </div>
2165 );
2166 })}
2167 </div>
2168 </CardContent>
2169 </Card>
2170 </div>
2171 ) : null}
2172
2173 {activeTab === "policies" ? (
2174 <Card className="border-border/70">
2175 <CardHeader className="pb-3">
2176 <CardTitle>Policies</CardTitle>
2177 <CardDescription>
2178 Define default allow/deny/approval behavior by tool path pattern.
2179 </CardDescription>
2180 </CardHeader>
2181 <CardContent className="space-y-4">
2182 <form className="space-y-3" onSubmit={handleUpsertPolicy}>
2183 <div className="grid gap-3 sm:grid-cols-2">
2184 <div className="grid gap-1.5 sm:col-span-2">
2185 <label className="text-xs text-muted-foreground" htmlFor="policy-pattern">Tool path pattern</label>
2186 <Input
2187 id="policy-pattern"
2188 value={policyPattern}
2189 onChange={(event) => setPolicyPattern(event.target.value)}
2190 placeholder="source:* or source:github/*"
2191 required
2192 />
2193 </div>
2194
2195 <div className="grid gap-1.5">
2196 <label className="text-xs text-muted-foreground" htmlFor="policy-decision">Decision</label>
2197 <Select
2198 id="policy-decision"
2199 value={policyDecision}
2200 onChange={(event) =>
2201 setPolicyDecision(event.target.value as PolicyDecision)
2202 }
2203 >
2204 <option value="allow">allow</option>
2205 <option value="require_approval">require_approval</option>
2206 <option value="deny">deny</option>
2207 </Select>
2208 </div>
2209 </div>
2210
2211 <div className="grid gap-2 sm:grid-cols-2">
2212 <Button type="submit" disabled={policyBusyId !== null}>
2213 {policyBusyId !== null
2214 ? "Saving..."
2215 : policyEditingId
2216 ? "Save Policy"
2217 : "Add Policy"}
2218 </Button>
2219
2220 {policyEditingId ? (
2221 <Button
2222 type="button"
2223 variant="outline"
2224 onClick={handleCancelPolicyEdit}
2225 disabled={policyBusyId !== null}
2226 >
2227 Cancel
2228 </Button>
2229 ) : null}
2230 </div>
2231 </form>
2232
2233 {policyStatusText ? (
2234 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2235 {policyStatusText}
2236 </div>
2237 ) : null}
2238
2239 <div className="grid gap-1.5">
2240 <label className="text-xs text-muted-foreground" htmlFor="policy-search">Search</label>
2241 <Input
2242 id="policy-search"
2243 value={policySearchQuery}
2244 onChange={(event) => setPolicySearchQuery(event.target.value)}
2245 placeholder="pattern or decision"
2246 />
2247 </div>
2248
2249 {policiesState.state === "loading" ? (
2250 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2251 Loading policies...
2252 </div>
2253 ) : null}
2254
2255 {policiesState.state === "error" ? (
2256 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
2257 {policiesState.message}
2258 </div>
2259 ) : null}
2260
2261 {policiesState.state !== "loading" && filteredPolicies.length === 0 ? (
2262 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2263 No policies configured.
2264 </div>
2265 ) : null}
2266
2267 <div className="space-y-2">
2268 {filteredPolicies.map((policy) => (
2269 <div
2270 key={policy.id}
2271 className="rounded-lg border border-border bg-background/70 p-3"
2272 >
2273 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
2274 <div className="min-w-0 space-y-1">
2275 <p className="break-all text-sm font-medium">{policy.toolPathPattern}</p>
2276 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2277 <Badge variant="outline">{policy.decision}</Badge>
2278 <span>updated {formatTimestamp(policy.updatedAt)}</span>
2279 </div>
2280 </div>
2281
2282 <div className="flex items-center gap-2">
2283 <Button
2284 type="button"
2285 size="sm"
2286 variant="outline"
2287 onClick={() => handleEditPolicy(policy.id)}
2288 disabled={policyBusyId !== null}
2289 >
2290 Edit
2291 </Button>
2292 <Button
2293 type="button"
2294 size="sm"
2295 variant="destructive"
2296 onClick={() => handleRemovePolicy(policy.id)}
2297 disabled={policyBusyId !== null}
2298 >
2299 Remove
2300 </Button>
2301 </div>
2302 </div>
2303 </div>
2304 ))}
2305 </div>
2306 </CardContent>
2307 </Card>
2308 ) : null}
2309
2310 {activeTab === "organizations" ? (
2311 <div className="grid gap-5 lg:grid-cols-2">
2312 <Card className="border-border/70">
2313 <CardHeader className="pb-3">
2314 <CardTitle>Organization Profile</CardTitle>
2315 <CardDescription>
2316 Create or update organization records used by workspaces.
2317 </CardDescription>
2318 </CardHeader>
2319 <CardContent className="space-y-4">
2320 <form className="space-y-3" onSubmit={handleUpsertOrganization}>
2321 <div className="grid gap-1.5">
2322 <label className="text-xs text-muted-foreground" htmlFor="organization-id">ID (optional)</label>
2323 <Input
2324 id="organization-id"
2325 value={organizationIdInput}
2326 onChange={(event) => setOrganizationIdInput(event.target.value)}
2327 placeholder="org_local"
2328 />
2329 </div>
2330 <div className="grid gap-1.5">
2331 <label className="text-xs text-muted-foreground" htmlFor="organization-slug">Slug</label>
2332 <Input
2333 id="organization-slug"
2334 value={organizationSlugInput}
2335 onChange={(event) => setOrganizationSlugInput(event.target.value)}
2336 placeholder="acme"
2337 required
2338 />
2339 </div>
2340 <div className="grid gap-1.5">
2341 <label className="text-xs text-muted-foreground" htmlFor="organization-name">Name</label>
2342 <Input
2343 id="organization-name"
2344 value={organizationNameInput}
2345 onChange={(event) => setOrganizationNameInput(event.target.value)}
2346 placeholder="Acme"
2347 required
2348 />
2349 </div>
2350 <div className="grid gap-1.5">
2351 <label className="text-xs text-muted-foreground" htmlFor="organization-status">Status</label>
2352 <Select
2353 id="organization-status"
2354 value={organizationStatusInput}
2355 onChange={(event) =>
2356 setOrganizationStatusInput(event.target.value as Organization["status"])
2357 }
2358 >
2359 <option value="active">active</option>
2360 <option value="suspended">suspended</option>
2361 <option value="archived">archived</option>
2362 </Select>
2363 </div>
2364 <div className="flex gap-2">
2365 <Button type="submit">Save Organization</Button>
2366 <Button type="button" variant="outline" onClick={() => refreshOrganizations()}>
2367 Refresh
2368 </Button>
2369 </div>
2370 </form>
2371 {organizationStatusText ? (
2372 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2373 {organizationStatusText}
2374 </div>
2375 ) : null}
2376 </CardContent>
2377 </Card>
2378
2379 <Card className="border-border/70">
2380 <CardHeader className="pb-3">
2381 <CardTitle>Organizations</CardTitle>
2382 <CardDescription>Available organizations from the control plane.</CardDescription>
2383 </CardHeader>
2384 <CardContent className="space-y-2">
2385 {organizations.state === "loading" ? (
2386 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2387 Loading organizations...
2388 </div>
2389 ) : null}
2390 {organizations.state === "error" ? (
2391 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
2392 {organizations.message}
2393 </div>
2394 ) : null}
2395 {organizations.state !== "loading" && organizations.items.length === 0 ? (
2396 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2397 No organizations yet.
2398 </div>
2399 ) : null}
2400 {organizations.items.map((organization) => (
2401 <div key={organization.id} className="rounded-lg border border-border bg-background/70 p-3">
2402 <div className="flex items-center justify-between gap-3">
2403 <div className="space-y-1">
2404 <p className="text-sm font-medium">{organization.name}</p>
2405 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2406 <Badge variant="outline">{organization.status}</Badge>
2407 <span>{organization.slug}</span>
2408 <span>{organization.id}</span>
2409 </div>
2410 </div>
2411 <Button
2412 type="button"
2413 size="sm"
2414 variant="outline"
2415 onClick={() => handleLoadOrganization(organization)}
2416 >
2417 Load
2418 </Button>
2419 </div>
2420 </div>
2421 ))}
2422 </CardContent>
2423 </Card>
2424 </div>
2425 ) : null}
2426
2427 {activeTab === "workspaces" ? (
2428 <div className="grid gap-5 lg:grid-cols-2">
2429 <Card className="border-border/70">
2430 <CardHeader className="pb-3">
2431 <CardTitle>Workspace Profile</CardTitle>
2432 <CardDescription>Create or update workspace records.</CardDescription>
2433 </CardHeader>
2434 <CardContent className="space-y-4">
2435 <form className="space-y-3" onSubmit={handleUpsertWorkspace}>
2436 <div className="grid gap-1.5">
2437 <label className="text-xs text-muted-foreground" htmlFor="workspace-edit-id">ID (optional)</label>
2438 <Input
2439 id="workspace-edit-id"
2440 value={workspaceEditIdInput}
2441 onChange={(event) => setWorkspaceEditIdInput(event.target.value)}
2442 placeholder="ws_local"
2443 />
2444 </div>
2445 <div className="grid gap-1.5">
2446 <label className="text-xs text-muted-foreground" htmlFor="workspace-name">Name</label>
2447 <Input
2448 id="workspace-name"
2449 value={workspaceNameInput}
2450 onChange={(event) => setWorkspaceNameInput(event.target.value)}
2451 placeholder="Local Workspace"
2452 required
2453 />
2454 </div>
2455 <div className="grid gap-1.5">
2456 <label className="text-xs text-muted-foreground" htmlFor="workspace-organization-id">Organization ID</label>
2457 <Input
2458 id="workspace-organization-id"
2459 value={workspaceOrganizationIdInput}
2460 onChange={(event) =>
2461 setWorkspaceOrganizationIdInput(event.target.value)
2462 }
2463 placeholder="org_local (optional)"
2464 />
2465 </div>
2466 <div className="flex gap-2">
2467 <Button type="submit">Save Workspace</Button>
2468 <Button type="button" variant="outline" onClick={() => refreshWorkspaces()}>
2469 Refresh
2470 </Button>
2471 </div>
2472 </form>
2473 {workspaceStatusText ? (
2474 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2475 {workspaceStatusText}
2476 </div>
2477 ) : null}
2478 </CardContent>
2479 </Card>
2480
2481 <Card className="border-border/70">
2482 <CardHeader className="pb-3">
2483 <CardTitle>Workspaces</CardTitle>
2484 <CardDescription>Available workspaces from the control plane.</CardDescription>
2485 </CardHeader>
2486 <CardContent className="space-y-2">
2487 {workspaces.state === "loading" ? (
2488 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2489 Loading workspaces...
2490 </div>
2491 ) : null}
2492 {workspaces.state === "error" ? (
2493 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
2494 {workspaces.message}
2495 </div>
2496 ) : null}
2497 {workspaces.state !== "loading" && workspaces.items.length === 0 ? (
2498 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2499 No workspaces yet.
2500 </div>
2501 ) : null}
2502 {workspaces.items.map((workspace) => (
2503 <div key={workspace.id} className="rounded-lg border border-border bg-background/70 p-3">
2504 <div className="flex items-center justify-between gap-3">
2505 <div className="space-y-1">
2506 <p className="text-sm font-medium">{workspace.name}</p>
2507 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2508 <span>{workspace.id}</span>
2509 <span>org {workspace.organizationId ?? "-"}</span>
2510 </div>
2511 </div>
2512 <Button
2513 type="button"
2514 size="sm"
2515 variant="outline"
2516 onClick={() => handleLoadWorkspace(workspace)}
2517 >
2518 Load
2519 </Button>
2520 </div>
2521 </div>
2522 ))}
2523 </CardContent>
2524 </Card>
2525 </div>
2526 ) : null}
2527
2528 {activeTab === "storage" ? (
2529 <div className="grid gap-5 lg:grid-cols-2">
2530 <Card className="border-border/70">
2531 <CardHeader className="pb-3">
2532 <CardTitle>Open Storage Instance</CardTitle>
2533 <CardDescription>
2534 Provision workspace storage for files, KV, and SQLite workloads.
2535 </CardDescription>
2536 </CardHeader>
2537 <CardContent className="space-y-4">
2538 <form className="space-y-3" onSubmit={handleOpenStorage}>
2539 <div className="grid gap-3 sm:grid-cols-2">
2540 <div className="grid gap-1.5">
2541 <label className="text-xs text-muted-foreground" htmlFor="storage-scope">Scope</label>
2542 <Select
2543 id="storage-scope"
2544 value={storageScopeType}
2545 onChange={(event) =>
2546 setStorageScopeType(event.target.value as StorageScopeType)
2547 }
2548 >
2549 <option value="scratch">scratch</option>
2550 <option value="workspace">workspace</option>
2551 <option value="organization">organization</option>
2552 <option value="account">account</option>
2553 </Select>
2554 </div>
2555
2556 <div className="grid gap-1.5">
2557 <label className="text-xs text-muted-foreground" htmlFor="storage-durability">Durability</label>
2558 <Select
2559 id="storage-durability"
2560 value={storageDurability}
2561 onChange={(event) =>
2562 setStorageDurability(event.target.value as StorageDurability)
2563 }
2564 >
2565 <option value="ephemeral">ephemeral</option>
2566 <option value="durable">durable</option>
2567 </Select>
2568 </div>
2569 </div>
2570
2571 <div className="grid gap-1.5">
2572 <label className="text-xs text-muted-foreground" htmlFor="storage-provider">Provider</label>
2573 <Select
2574 id="storage-provider"
2575 value={storageProvider}
2576 onChange={(event) =>
2577 setStorageProvider(event.target.value as StorageInstance["provider"])
2578 }
2579 >
2580 <option value="agentfs-local">agentfs-local</option>
2581 <option value="agentfs-cloudflare">agentfs-cloudflare</option>
2582 </Select>
2583 </div>
2584
2585 <div className="grid gap-1.5">
2586 <label className="text-xs text-muted-foreground" htmlFor="storage-purpose">Purpose (optional)</label>
2587 <Input
2588 id="storage-purpose"
2589 value={storagePurposeInput}
2590 onChange={(event) => setStoragePurposeInput(event.target.value)}
2591 placeholder="tool execution workspace"
2592 />
2593 </div>
2594
2595 {storageDurability === "ephemeral" ? (
2596 <div className="grid gap-1.5">
2597 <label className="text-xs text-muted-foreground" htmlFor="storage-ttl-hours">TTL hours</label>
2598 <Input
2599 id="storage-ttl-hours"
2600 value={storageTtlHoursInput}
2601 onChange={(event) => setStorageTtlHoursInput(event.target.value)}
2602 placeholder="24"
2603 inputMode="numeric"
2604 />
2605 </div>
2606 ) : null}
2607
2608 {storageScopeType === "account" ? (
2609 <div className="grid gap-1.5">
2610 <label className="text-xs text-muted-foreground" htmlFor="storage-account-id">Account id</label>
2611 <Input
2612 id="storage-account-id"
2613 value={storageAccountIdInput}
2614 onChange={(event) => setStorageAccountIdInput(event.target.value)}
2615 placeholder="acct_123"
2616 required
2617 />
2618 </div>
2619 ) : null}
2620
2621 <Button type="submit" disabled={storageBusyId !== null}>
2622 {storageBusyId === "create" ? "Opening..." : "Open Storage"}
2623 </Button>
2624 </form>
2625
2626 {storageStatusText ? (
2627 <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
2628 {storageStatusText}
2629 </div>
2630 ) : null}
2631 </CardContent>
2632 </Card>
2633
2634 <Card className="border-border/70">
2635 <CardHeader className="pb-3">
2636 <CardTitle>Storage Instances</CardTitle>
2637 <CardDescription>Inspect and manage workspace storage instances.</CardDescription>
2638 </CardHeader>
2639 <CardContent className="space-y-4">
2640 <div className="grid gap-1.5">
2641 <label className="text-xs text-muted-foreground" htmlFor="storage-search">Search</label>
2642 <Input
2643 id="storage-search"
2644 value={storageSearchQuery}
2645 onChange={(event) => setStorageSearchQuery(event.target.value)}
2646 placeholder="scope, durability, status, provider"
2647 />
2648 </div>
2649
2650 <div className="grid gap-1.5">
2651 <label className="text-xs text-muted-foreground" htmlFor="storage-selected">Inspector target</label>
2652 <Select
2653 id="storage-selected"
2654 value={selectedStorageInstance?.id ?? ""}
2655 onChange={(event) =>
2656 handleSelectStorageInstance(event.target.value as StorageInstance["id"])
2657 }
2658 disabled={storageItems.length === 0}
2659 >
2660 {storageItems.length === 0 ? (
2661 <option value="">No storage instances</option>
2662 ) : (
2663 storageItems.map((storageInstance) => (
2664 <option key={storageInstance.id} value={storageInstance.id}>
2665 {storageInstance.id}
2666 </option>
2667 ))
2668 )}
2669 </Select>
2670 </div>
2671
2672 {storageState.state === "loading" ? (
2673 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2674 Loading storage instances...
2675 </div>
2676 ) : null}
2677
2678 {storageState.state === "error" ? (
2679 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
2680 {storageState.message}
2681 </div>
2682 ) : null}
2683
2684 {storageState.state !== "loading" && filteredStorageItems.length === 0 ? (
2685 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2686 No storage instances found.
2687 </div>
2688 ) : null}
2689
2690 <div className="space-y-2">
2691 {filteredStorageItems.map((storageInstance) => {
2692 const busy = storageBusyId === storageInstance.id;
2693
2694 return (
2695 <div
2696 key={storageInstance.id}
2697 className={cn(
2698 "rounded-lg border border-border bg-background/70 p-3",
2699 busy && "opacity-80",
2700 )}
2701 >
2702 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
2703 <div className="min-w-0 space-y-1">
2704 <p className="truncate text-sm font-medium">{storageInstance.id}</p>
2705 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2706 <Badge variant="outline">{storageInstance.scopeType}</Badge>
2707 <Badge variant="outline">{storageInstance.durability}</Badge>
2708 <Badge variant="outline">{storageInstance.status}</Badge>
2709 <span>{storageInstance.provider}</span>
2710 </div>
2711 <p className="break-all text-xs text-muted-foreground">
2712 backend {storageInstance.backendKey}
2713 </p>
2714 {storageInstance.purpose ? (
2715 <p className="break-all text-xs text-muted-foreground">
2716 purpose {storageInstance.purpose}
2717 </p>
2718 ) : null}
2719 <p className="break-all text-xs text-muted-foreground">
2720 updated {formatTimestamp(storageInstance.updatedAt)}
2721 </p>
2722 </div>
2723
2724 <div className="flex items-center gap-2">
2725 <Button
2726 type="button"
2727 size="sm"
2728 variant="outline"
2729 onClick={() => handleSelectStorageInstance(storageInstance.id)}
2730 disabled={storageBusyId !== null}
2731 >
2732 Inspect
2733 </Button>
2734 <Button
2735 type="button"
2736 size="sm"
2737 variant="outline"
2738 onClick={() => handleCloseStorage(storageInstance.id)}
2739 disabled={
2740 storageBusyId !== null || storageInstance.status !== "active"
2741 }
2742 >
2743 Close
2744 </Button>
2745 <Button
2746 type="button"
2747 size="sm"
2748 variant="destructive"
2749 onClick={() => handleRemoveStorage(storageInstance.id)}
2750 disabled={storageBusyId !== null}
2751 >
2752 {busy ? "Working..." : "Remove"}
2753 </Button>
2754 </div>
2755 </div>
2756 </div>
2757 );
2758 })}
2759 </div>
2760
2761 {selectedStorageInstance ? (
2762 <div className="space-y-4 rounded-lg border border-border bg-muted/20 p-3">
2763 <div className="flex flex-wrap items-center justify-between gap-2">
2764 <p className="text-sm font-medium">Inspector {selectedStorageInstance.id}</p>
2765 <Button
2766 type="button"
2767 size="sm"
2768 variant="outline"
2769 onClick={() => handleListStorageDirectory()}
2770 disabled={storageDirectoryBusy}
2771 >
2772 {storageDirectoryBusy ? "Loading..." : "Load Directory"}
2773 </Button>
2774 </div>
2775
2776 <div className="grid gap-2 sm:grid-cols-[1fr_auto]">
2777 <Input
2778 value={storageDirectoryPath}
2779 onChange={(event) => setStorageDirectoryPath(event.target.value)}
2780 placeholder="/"
2781 />
2782 <Button
2783 type="button"
2784 size="sm"
2785 variant="outline"
2786 onClick={() => handleListStorageDirectory()}
2787 disabled={storageDirectoryBusy}
2788 >
2789 Open Path
2790 </Button>
2791 </div>
2792
2793 <div className="max-h-52 space-y-1 overflow-auto rounded-md border border-border bg-background/70 p-2">
2794 {storageDirectoryEntries.length === 0 ? (
2795 <p className="text-xs text-muted-foreground">No directory entries loaded.</p>
2796 ) : (
2797 storageDirectoryEntries.map((entry) => (
2798 <div
2799 key={`${entry.path}:${entry.kind}`}
2800 className="flex items-center justify-between gap-2 rounded border border-border/60 px-2 py-1"
2801 >
2802 <div className="min-w-0">
2803 <p className="truncate text-xs font-medium">{entry.name}</p>
2804 <p className="truncate text-[11px] text-muted-foreground">{entry.path}</p>
2805 </div>
2806 {entry.kind === "file" ? (
2807 <Button
2808 type="button"
2809 size="sm"
2810 variant="outline"
2811 onClick={() => handleReadStorageFile(entry.path)}
2812 disabled={storageFilePreviewBusy}
2813 >
2814 Read
2815 </Button>
2816 ) : (
2817 <Button
2818 type="button"
2819 size="sm"
2820 variant="outline"
2821 onClick={() => handleListStorageDirectory(entry.path)}
2822 disabled={storageDirectoryBusy}
2823 >
2824 Open
2825 </Button>
2826 )}
2827 </div>
2828 ))
2829 )}
2830 </div>
2831
2832 <div className="space-y-1">
2833 <p className="text-xs text-muted-foreground">
2834 File preview {storageFilePreviewPath ?? "-"}
2835 </p>
2836 <pre className="max-h-40 overflow-auto rounded-md border border-border bg-background/70 p-2 text-[11px] text-muted-foreground">
2837 {storageFilePreviewContent.length > 0
2838 ? storageFilePreviewContent
2839 : "No file loaded."}
2840 </pre>
2841 </div>
2842
2843 <div className="grid gap-2 sm:grid-cols-[1fr_auto_auto]">
2844 <Input
2845 value={storageKvPrefix}
2846 onChange={(event) => setStorageKvPrefix(event.target.value)}
2847 placeholder="KV prefix"
2848 />
2849 <Input
2850 value={storageKvLimit}
2851 onChange={(event) => setStorageKvLimit(event.target.value)}
2852 inputMode="numeric"
2853 placeholder="100"
2854 />
2855 <Button
2856 type="button"
2857 size="sm"
2858 variant="outline"
2859 onClick={handleListStorageKv}
2860 disabled={storageKvBusy}
2861 >
2862 {storageKvBusy ? "Loading..." : "Load KV"}
2863 </Button>
2864 </div>
2865
2866 <pre className="max-h-40 overflow-auto rounded-md border border-border bg-background/70 p-2 text-[11px] text-muted-foreground">
2867 {JSON.stringify(storageKvItems, null, 2)}
2868 </pre>
2869
2870 <div className="space-y-2">
2871 <textarea
2872 className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
2873 value={storageSqlText}
2874 onChange={(event) => setStorageSqlText(event.target.value)}
2875 />
2876 <div className="grid gap-2 sm:grid-cols-[1fr_auto]">
2877 <Input
2878 value={storageSqlMaxRows}
2879 onChange={(event) => setStorageSqlMaxRows(event.target.value)}
2880 inputMode="numeric"
2881 placeholder="200"
2882 />
2883 <Button
2884 type="button"
2885 size="sm"
2886 variant="outline"
2887 onClick={handleQueryStorageSql}
2888 disabled={storageSqlBusy}
2889 >
2890 {storageSqlBusy ? "Running..." : "Run SQL"}
2891 </Button>
2892 </div>
2893 <pre className="max-h-48 overflow-auto rounded-md border border-border bg-background/70 p-2 text-[11px] text-muted-foreground">
2894 {JSON.stringify(storageSqlResult, null, 2)}
2895 </pre>
2896 </div>
2897 </div>
2898 ) : null}
2899 </CardContent>
2900 </Card>
2901 </div>
2902 ) : null}
2903
2904 {activeTab === "approvals" ? (
2905 <Card className="border-border/70">
2906 <CardHeader className="pb-3">
2907 <CardTitle>Approvals</CardTitle>
2908 <CardDescription>Review and resolve pending tool approvals.</CardDescription>
2909 </CardHeader>
2910 <CardContent className="space-y-4">
2911 <div className="grid gap-3 sm:grid-cols-2">
2912 <div className="grid gap-1.5">
2913 <label className="text-xs text-muted-foreground" htmlFor="approval-filter">Filter</label>
2914 <Select
2915 id="approval-filter"
2916 value={approvalFilter}
2917 onChange={(event) =>
2918 setApprovalFilter(event.target.value as ApprovalFilter)
2919 }
2920 >
2921 <option value="pending">pending</option>
2922 <option value="resolved">resolved</option>
2923 <option value="all">all</option>
2924 </Select>
2925 </div>
2926
2927 <div className="grid gap-1.5">
2928 <label className="text-xs text-muted-foreground" htmlFor="approval-search">Search</label>
2929 <Input
2930 id="approval-search"
2931 value={approvalSearchQuery}
2932 onChange={(event) => setApprovalSearchQuery(event.target.value)}
2933 placeholder="tool path, status, call id"
2934 />
2935 </div>
2936 </div>
2937
2938 {approvalStatusText ? (
2939 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2940 {approvalStatusText}
2941 </div>
2942 ) : null}
2943
2944 {approvalsState.state === "loading" ? (
2945 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2946 Loading approvals...
2947 </div>
2948 ) : null}
2949
2950 {approvalsState.state === "error" ? (
2951 <div className="rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
2952 {approvalsState.message}
2953 </div>
2954 ) : null}
2955
2956 {approvalsState.state !== "loading" && filteredApprovals.length === 0 ? (
2957 <div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
2958 No approvals for this filter.
2959 </div>
2960 ) : null}
2961
2962 <div className="space-y-2">
2963 {filteredApprovals.map((approval) => {
2964 const busy = approvalBusyId === approval.id;
2965 const canResolve = approval.status === "pending";
2966
2967 return (
2968 <div
2969 className={cn(
2970 "rounded-lg border border-border bg-background/70 p-3",
2971 busy && "opacity-80",
2972 )}
2973 key={approval.id}
2974 >
2975 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
2976 <div className="min-w-0 space-y-1">
2977 <p className="break-all text-sm font-medium">{approval.toolPath}</p>
2978 <div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
2979 <Badge variant={statusBadgeVariant(approval.status)}>
2980 {approval.status}
2981 </Badge>
2982 <span>requested {formatTimestamp(approval.requestedAt)}</span>
2983 <span>resolved {formatTimestamp(approval.resolvedAt)}</span>
2984 </div>
2985 <p className="break-all text-xs text-muted-foreground">task {approval.taskRunId}</p>
2986 <p className="break-all text-xs text-muted-foreground">call {approval.callId}</p>
2987 {approval.reason ? (
2988 <p className="break-all text-xs text-muted-foreground">reason: {approval.reason}</p>
2989 ) : null}
2990 </div>
2991
2992 <div className="flex items-center gap-2">
2993 <Button
2994 type="button"
2995 size="sm"
2996 variant="outline"
2997 onClick={() => handleResolveApproval(approval.id, "approved")}
2998 disabled={!canResolve || busy}
2999 >
3000 {busy && canResolve ? "Working..." : "Approve"}
3001 </Button>
3002 <Button
3003 type="button"
3004 size="sm"
3005 variant="destructive"
3006 onClick={() => handleResolveApproval(approval.id, "denied")}
3007 disabled={!canResolve || busy}
3008 >
3009 Deny
3010 </Button>
3011 </div>
3012 </div>
3013
3014 <pre className="mt-3 max-h-56 overflow-auto rounded-md border border-border bg-muted/45 p-3 text-[11px] leading-5 text-muted-foreground">
3015 {previewFromInputJson(approval.inputPreviewJson)}
3016 </pre>
3017 </div>
3018 );
3019 })}
3020 </div>
3021 </CardContent>
3022 </Card>
3023 ) : null}
3024 </CardContent>
3025 </Card>
3026 </div>
246 </main>3027 </main>
247 );3028 );
248};3029};
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 @@
1import type { HTMLAttributes } from "react";
2import { cva, type VariantProps } from "class-variance-authority";
3
4import { cn } from "../../lib/utils";
5
6const 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
25type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>;
26
27export 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 @@
1import type { ButtonHTMLAttributes } from "react";
2import { cva, type VariantProps } from "class-variance-authority";
3
4import { cn } from "../../lib/utils";
5
6const 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
30type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>
31 & VariantProps<typeof buttonVariants>;
32
33export 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 @@
1import type { HTMLAttributes } from "react";
2
3import { cn } from "../../lib/utils";
4
5export 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
17export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
18 return <div className={cn("px-6 pt-5 pb-3", className)} {...props} />;
19}
20
21export 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
30export 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
39export 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 @@
1import type { InputHTMLAttributes } from "react";
2
3import { cn } from "../../lib/utils";
4
5export 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 @@
1import type { SelectHTMLAttributes } from "react";
2
3import { cn } from "../../lib/utils";
4
5export 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 @@
1import { Atom, Result } from "@effect-atom/atom";1import { Atom, Result } from "@effect-atom/atom";
2import type { UpsertSourcePayload } from "@executor-v2/management-api";2import type { ResolveApprovalPayload } from "@executor-v2/management-api/approvals/api";
3import type { Source, SourceId, WorkspaceId } from "@executor-v2/schema";3import type {
4 RemoveCredentialBindingResult,
5 UpsertCredentialBindingPayload,
6} from "@executor-v2/management-api/credentials/api";
7import type {
8 UpsertOrganizationPayload,
9} from "@executor-v2/management-api/organizations/api";
10import type {
11 RemovePolicyResult,
12 UpsertPolicyPayload,
13} from "@executor-v2/management-api/policies/api";
14import 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";
26import type { UpsertSourcePayload } from "@executor-v2/management-api/sources/api";
27import type { SourceToolSummary } from "@executor-v2/management-api/tools/api";
28import type {
29 UpsertWorkspacePayload,
30} from "@executor-v2/management-api/workspaces/api";
31import 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";
4import * as Cause from "effect/Cause";50import * as Cause from "effect/Cause";
5import * as Option from "effect/Option";51import * as Option from "effect/Option";
6 52
@@ -195,3 +241,733 @@ export const sourcesByWorkspace = Atom.family((workspaceId: WorkspaceId) => · 188 unmodified lines
195 });241 });
196 }),242 }),
197);243);
244
245
246
247type OrganizationsResult = Result.Result<ReadonlyArray<Organization>, unknown>;
248
249export const organizationsResult =
250 controlPlaneClient.query("organizations", "list", {}) as Atom.Atom<OrganizationsResult>;
251
252export const upsertOrganization = controlPlaneClient.mutation(
253 "organizations",
254 "upsert",
255);
256
257export 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
274const 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
288const 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
309export const organizationsState = Atom.make((get): OrganizationsState =>
310 organizationsStateFromResult(get(organizationsResult))
311);
312
313export 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
325type WorkspacesResult = Result.Result<ReadonlyArray<Workspace>, unknown>;
326
327export const workspacesResult =
328 controlPlaneClient.query("workspaces", "list", {}) as Atom.Atom<WorkspacesResult>;
329
330export const upsertWorkspace = controlPlaneClient.mutation("workspaces", "upsert");
331
332export 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
349const 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
361const 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
380export const workspacesState = Atom.make((get): WorkspacesState =>
381 workspacesStateFromResult(get(workspacesResult))
382);
383
384export 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
398type SourceToolsResult = Result.Result<ReadonlyArray<SourceToolSummary>, unknown>;
399
400export 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
409export 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
422export 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
439const 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
460const 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
479export const workspaceToolsByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
480 Atom.make((get): SourceToolsState =>
481 sourceToolsStateFromResult(get(workspaceToolsResultByWorkspace(workspaceId)))
482 )
483);
484
485type ApprovalsResult = Result.Result<ReadonlyArray<Approval>, unknown>;
486
487export 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
496export const resolveApproval = controlPlaneClient.mutation("approvals", "resolve");
497
498export 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
515const approvalStoreKey = (approval: Approval): string =>
516 `${approval.workspaceId}:${approval.id}`;
517
518const 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
532const 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
551export const approvalsByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
552 Atom.make((get): ApprovalsState => {
553 const result = get(approvalsResultByWorkspace(workspaceId));
554 return approvalsStateFromResult(result);
555 }),
556);
557
558export 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
565export 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
592type PoliciesResult = Result.Result<ReadonlyArray<Policy>, unknown>;
593
594export 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
603export const upsertPolicy = controlPlaneClient.mutation("policies", "upsert");
604export const removePolicy = controlPlaneClient.mutation("policies", "remove");
605
606export 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
623const policyStoreKey = (policy: Policy): string => `${policy.workspaceId}:${policy.id}`;
624
625const 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
637const 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
656export const policiesByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
657 Atom.make((get): PoliciesState => {
658 const result = get(policiesResultByWorkspace(workspaceId));
659 return policiesStateFromResult(result);
660 }),
661);
662
663export 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
688export const optimisticRemovePolicy = (
689 currentPolicies: ReadonlyArray<Policy>,
690 policyId: PolicyId,
691): ReadonlyArray<Policy> => currentPolicies.filter((policy) => policy.id !== policyId);
692
693export 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
703export const toPolicyRemoveResult = (result: RemovePolicyResult): boolean =>
704 result.removed;
705
706
707
708type CredentialBindingsResult = Result.Result<
709 ReadonlyArray<SourceCredentialBinding>,
710 unknown
711>;
712
713export 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
722export const upsertCredentialBinding = controlPlaneClient.mutation(
723 "credentials",
724 "upsert",
725);
726export const removeCredentialBinding = controlPlaneClient.mutation(
727 "credentials",
728 "remove",
729);
730
731export 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
748const credentialBindingStoreKey = (binding: SourceCredentialBinding): string =>
749 `${binding.workspaceId}:${binding.id}`;
750
751const 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
767const 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
788export 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
796export 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
822export const toCredentialBindingRemoveResult = (
823 result: RemoveCredentialBindingResult,
824): boolean => result.removed;
825
826
827type StorageInstancesResult = Result.Result<ReadonlyArray<StorageInstance>, unknown>;
828
829export 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
838export const openStorageInstance = controlPlaneClient.mutation("storage", "open");
839export const closeStorageInstance = controlPlaneClient.mutation("storage", "close");
840export const removeStorageInstance = controlPlaneClient.mutation("storage", "remove");
841
842export 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
859const 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
870const 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
891export const storageByWorkspace = Atom.family((workspaceId: WorkspaceId) =>
892 Atom.make((get): StorageInstancesState => {
893 const result = get(storageResultByWorkspace(workspaceId));
894 return storageStateFromResult(result);
895 }),
896);
897
898export 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
916export const toStorageRemoveResult = (
917 result: RemoveStorageInstanceResult,
918): boolean => result.removed;
919
920export const listStorageDirectory = controlPlaneClient.mutation(
921 "storage",
922 "listDirectory",
923);
924export const readStorageFile = controlPlaneClient.mutation("storage", "readFile");
925export const listStorageKv = controlPlaneClient.mutation("storage", "listKv");
926export const queryStorageSql = controlPlaneClient.mutation("storage", "querySql");
927
928export const toListStorageDirectoryPayload = (input: {
929 path: string;
930}): ListStorageDirectoryPayload => ({
931 path: input.path,
932});
933
934export 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
942export 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
950export const toQueryStorageSqlPayload = (input: {
951 sql: string;
952 maxRows?: number;
953}): QueryStorageSqlPayload => ({
954 sql: input.sql,
955 ...(input.maxRows !== undefined ? { maxRows: input.maxRows } : {}),
956});
957
958export const toStorageDirectoryResult = (
959 result: ListStorageDirectoryResult,
960): ListStorageDirectoryResult => result;
961
962export const toStorageReadFileResult = (
963 result: ReadStorageFileResult,
964): ReadStorageFileResult => result;
965
966export const toStorageKvResult = (
967 result: ListStorageKvResult,
968): ListStorageKvResult => result;
969
970export const toStorageSqlResult = (
971 result: QueryStorageSqlResult,
972): QueryStorageSqlResult => result;
973
v2/apps/web/lib/control-plane/client.ts+2−4
@@ -1,7 +1,5 @@
1import {1import { createControlPlaneAtomClient } from "@executor-v2/management-api/client";
2 ControlPlaneAuthHeaders,2import { ControlPlaneAuthHeaders } from "@executor-v2/management-api/auth/principal";
3 createControlPlaneAtomClient,
4} from "@executor-v2/management-api";
5 3
6const controlPlaneBaseUrl =4const controlPlaneBaseUrl =
7 typeof window === "undefined"5 typeof window === "undefined"
v2/apps/web/lib/control-plane/legacy-source.ts+266−0
@@ -0,0 +1,266 @@
1import type { UpsertSourcePayload } from "@executor-v2/management-api";
2import type {
3 Source,
4 SourceId,
5 SourceKind,
6 SourceStatus,
7 WorkspaceId,
8} from "@executor-v2/schema";
9
10export type LegacySourceType = "openapi" | "mcp" | "graphql";
11export type LegacyAuthType = "none" | "bearer" | "apiKey" | "basic";
12export type LegacyAuthMode = "workspace" | "organization" | "account";
13
14export 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
29export 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
42const supportedKinds: ReadonlyArray<LegacySourceType> = [
43 "openapi",
44 "mcp",
45 "graphql",
46];
47
48const normalizeString = (value: unknown): string =>
49 typeof value === "string" ? value.trim() : "";
50
51const 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
60const normalizeSourceType = (kind: SourceKind): LegacySourceType => {
61 if (supportedKinds.includes(kind as LegacySourceType)) {
62 return kind as LegacySourceType;
63 }
64
65 return "openapi";
66};
67
68const 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
97const 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
133export 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
154export 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
210export 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
234export 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 @@
1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export 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
3 return candidate && candidate.length > 0 ? candidate : undefined;3 return candidate && candidate.length > 0 ? candidate : undefined;
4};4};
5 5
6export const isWorkosEnabled = (): boolean =>6const isTruthy = (value: string | undefined): boolean => {
7 Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY));7 const normalized = trim(value)?.toLowerCase();
8 return normalized === "1" || normalized === "true" || normalized === "yes";
9};
10
11export 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
28export const isWorkosEnabled = (): boolean => {
29 if (isTruthy(process.env.WORKOS_FORCE_ENABLED)) {
30 return Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY));
31 }
32
33 if (isLocalControlPlaneUpstream()) {
34 return false;
35 }
36
37 return Boolean(trim(process.env.WORKOS_CLIENT_ID) && trim(process.env.WORKOS_API_KEY));
38};
8 39
9export const externalOriginFromRequest = (request: Request): string => {40export const externalOriginFromRequest = (request: Request): string => {
10 const forwardedHost = trim(request.headers.get("x-forwarded-host") ?? undefined);41 const forwardedHost = trim(request.headers.get("x-forwarded-host") ?? undefined);
v2/apps/web/package.json+10−3
@@ -3,7 +3,7 @@ · 2 unmodified lines
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "dev": "next dev --port 4312",6 "dev": "next dev --turbopack --port 4312",
7 "build": "next build",7 "build": "next build",
8 "start": "next start --port 4312",8 "start": "next start --port 4312",
9 "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json"9 "typecheck": "next typegen && bunx tsc --noEmit -p tsconfig.json"
@@ -14,13 +14,20 @@ · 4 unmodified lines
14 "@executor-v2/management-api": "workspace:*",14 "@executor-v2/management-api": "workspace:*",
15 "@executor-v2/schema": "workspace:*",15 "@executor-v2/schema": "workspace:*",
16 "@workos-inc/authkit-nextjs": "^2.13.0",16 "@workos-inc/authkit-nextjs": "^2.13.0",
17 "class-variance-authority": "^0.7.1",
18 "clsx": "^2.1.1",
17 "effect": "catalog:",19 "effect": "catalog:",
18 "next": "^15.4.6",20 "next": "^15.4.6",
19 "react": "^19.1.1",21 "react": "^19.1.1",
20 "react-dom": "^19.1.1"22 "react-dom": "^19.1.1",
23 "tailwind-merge": "^3.3.1"
21 },24 },
22 "devDependencies": {25 "devDependencies": {
26 "@tailwindcss/postcss": "^4.1.12",
23 "@types/react": "^19.1.10",27 "@types/react": "^19.1.10",
24 "@types/react-dom": "^19.1.7"28 "@types/react-dom": "^19.1.7",
29 "postcss": "^8.5.6",
30 "tailwindcss": "^4.1.12",
31 "tw-animate-css": "^1.4.0"
25 }32 }
26}33}
v2/apps/web/postcss.config.mjs+7−0
@@ -0,0 +1,7 @@
1const config = {
2 plugins: {
3 "@tailwindcss/postcss": {},
4 },
5};
6
7export default config;
v2/bun.lock+119−20
@@ -35,7 +35,7 @@ · 34 unmodified lines
35 },35 },
36 "devDependencies": {36 "devDependencies": {
37 "@effect/vitest": "catalog:",37 "@effect/vitest": "catalog:",
38 "convex-test": "^0.0.41",38 "convex-test": "workspace:*",
39 "vitest": "catalog:",39 "vitest": "catalog:",
40 },40 },
41 },41 },
@@ -69,14 +69,21 @@ · 27 unmodified lines
69 "@executor-v2/management-api": "workspace:*",69 "@executor-v2/management-api": "workspace:*",
70 "@executor-v2/schema": "workspace:*",70 "@executor-v2/schema": "workspace:*",
71 "@workos-inc/authkit-nextjs": "^2.13.0",71 "@workos-inc/authkit-nextjs": "^2.13.0",
72 "class-variance-authority": "^0.7.1",
73 "clsx": "^2.1.1",
72 "effect": "catalog:",74 "effect": "catalog:",
73 "next": "^15.4.6",75 "next": "^15.4.6",
74 "react": "^19.1.1",76 "react": "^19.1.1",
75 "react-dom": "^19.1.1",77 "react-dom": "^19.1.1",
78 "tailwind-merge": "^3.3.1",
76 },79 },
77 "devDependencies": {80 "devDependencies": {
81 "@tailwindcss/postcss": "^4.1.12",
78 "@types/react": "^19.1.10",82 "@types/react": "^19.1.10",
79 "@types/react-dom": "^19.1.7",83 "@types/react-dom": "^19.1.7",
84 "postcss": "^8.5.6",
85 "tailwindcss": "^4.1.12",
86 "tw-animate-css": "^1.4.0",
80 },87 },
81 },88 },
82 "apps/www": {89 "apps/www": {
@@ -132,6 +139,13 @@ · 49 unmodified lines
132 "vitest": "catalog:",139 "vitest": "catalog:",
133 },140 },
134 },141 },
142 "packages/convex-test": {
143 "name": "convex-test",
144 "version": "0.0.41-patched",
145 "peerDependencies": {
146 "convex": "^1.32.0",
147 },
148 },
135 "packages/domain": {149 "packages/domain": {
136 "name": "@executor-v2/domain",150 "name": "@executor-v2/domain",
137 "dependencies": {151 "dependencies": {
@@ -167,6 +181,7 @@ · 29 unmodified lines
167 "@executor-v2/persistence-ports": "workspace:*",181 "@executor-v2/persistence-ports": "workspace:*",
168 "@executor-v2/schema": "workspace:*",182 "@executor-v2/schema": "workspace:*",
169 "effect": "catalog:",183 "effect": "catalog:",
184 "yaml": "^2.8.1",
170 },185 },
171 "devDependencies": {186 "devDependencies": {
172 "@effect/vitest": "catalog:",187 "@effect/vitest": "catalog:",
@@ -782,6 +797,36 @@ · 609 unmodified lines
782 797
783 "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],798 "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
784 799
800 "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
801
802 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
803
804 "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
805
806 "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
807
808 "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
809
810 "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
811
812 "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
813
814 "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
815
816 "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
817
818 "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
819
820 "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
821
822 "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
823
824 "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
825
826 "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
827
828 "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
829
785 "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],830 "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
786 831
787 "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],832 "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
@@ -1048,6 +1093,8 @@ · 260 unmodified lines
1048 1093
1049 "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="],1094 "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="],
1050 1095
1096 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
1097
1051 "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],1098 "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
1052 1099
1053 "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],1100 "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -1084,7 +1131,7 @@ · 30 unmodified lines
1084 1131
1085 "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="],1132 "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="],
1086 1133
1087 "convex-test": ["convex-test@0.0.41", "", { "peerDependencies": { "convex": "^1.16.4" } }, "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ=="],1134 "convex-test": ["convex-test@workspace:packages/convex-test"],
1088 1135
1089 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],1136 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
1090 1137
@@ -1174,6 +1221,8 @@ · 83 unmodified lines
1174 1221
1175 "end-of-stream": ["end-of-stream@1.1.0", "", { "dependencies": { "once": "~1.3.0" } }, "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ=="],1222 "end-of-stream": ["end-of-stream@1.1.0", "", { "dependencies": { "once": "~1.3.0" } }, "sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ=="],
1176 1223
1224 "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
1225
1177 "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],1226 "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
1178 1227
1179 "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],1228 "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
@@ -1412,6 +1461,30 @@ · 232 unmodified lines
1412 1461
1413 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],1462 "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
1414 1463
1464 "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
1465
1466 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
1467
1468 "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
1469
1470 "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
1471
1472 "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
1473
1474 "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
1475
1476 "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
1477
1478 "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
1479
1480 "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
1481
1482 "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
1483
1484 "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
1485
1486 "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
1487
1415 "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],1488 "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
1416 1489
1417 "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],1490 "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1680,7 +1753,7 @@ · 262 unmodified lines
1680 1753
1681 "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],1754 "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
1682 1755
1683 "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],1756 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
1684 1757
1685 "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],1758 "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
1686 1759
@@ -1882,7 +1955,11 @@ · 195 unmodified lines
1882 1955
1883 "svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="],1956 "svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="],
1884 1957
1885 "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],1958 "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
1959
1960 "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
1961
1962 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
1886 1963
1887 "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],1964 "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
1888 1965
@@ -1946,6 +2023,8 @@ · 57 unmodified lines
1946 2023
1947 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="],2024 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOM4uF2vuLsJUvibdR6X9QqdZr6BhC6Nhlrw4LKFPsXZZI/9HHLoqAiYRpE4MuzIwldCH/jVySnWXrI1SKto0g=="],
1948 2025
2026 "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
2027
1949 "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],2028 "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
1950 2029
1951 "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],2030 "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
@@ -2132,7 +2211,7 @@ · 180 unmodified lines
2132 2211
2133 "@astrojs/react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],2212 "@astrojs/react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
2134 2213
2135 "@astrojs/tailwind/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],2214 "@astrojs/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
2136 2215
2137 "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],2216 "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
2138 2217
@@ -2146,6 +2225,8 @@ · 7 unmodified lines
2146 2225
2147 "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],2226 "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
2148 2227
2228 "@executor-v2/app-www/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
2229
2149 "@executor-v2/confect/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],2230 "@executor-v2/confect/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
2150 2231
2151 "@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],2232 "@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
@@ -2156,6 +2237,20 @@ · 4 unmodified lines
2156 2237
2157 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],2238 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
2158 2239
2240 "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
2241
2242 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
2243
2244 "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
2245
2246 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
2247
2248 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
2249
2250 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
2251
2252 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
2253
2159 "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],2254 "@ts-morph/common/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
2160 2255
2161 "@vercel/backends/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],2256 "@vercel/backends/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -2274,6 +2369,8 @@ · 112 unmodified lines
2274 2369
2275 "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],2370 "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
2276 2371
2372 "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
2373
2277 "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],2374 "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
2278 2375
2279 "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],2376 "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -2288,18 +2385,12 @@ · 8 unmodified lines
2288 2385
2289 "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],2386 "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
2290 2387
2291 "tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
2292
2293 "tailwindcss/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
2294
2295 "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],2388 "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
2296 2389
2297 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],2390 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
2298 2391
2299 "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],2392 "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
2300 2393
2301 "vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
2302
2303 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],2394 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
2304 2395
2305 "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],2396 "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
@@ -2318,10 +2409,12 @@ · 12 unmodified lines
2318 2409
2319 "@astrojs/react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],2410 "@astrojs/react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
2320 2411
2321 "@astrojs/react/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],2412 "@astrojs/tailwind/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
2322 2413
2323 "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],2414 "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
2324 2415
2416 "@executor-v2/app-www/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
2417
2325 "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],2418 "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
2326 2419
2327 "@executor-v2/confect/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="],2420 "@executor-v2/confect/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="],
@@ -2340,6 +2433,8 @@ · 12 unmodified lines
2340 2433
2341 "@executor-v2/confect/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],2434 "@executor-v2/confect/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
2342 2435
2436 "@executor-v2/confect/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
2437
2343 "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],2438 "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
2344 2439
2345 "@vercel/express/@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],2440 "@vercel/express/@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@@ -2420,8 +2515,6 @@ · 74 unmodified lines
2420 2515
2421 "astro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],2516 "astro/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
2422 2517
2423 "astro/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
2424
2425 "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],2518 "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
2426 2519
2427 "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],2520 "boxen/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -2440,10 +2533,6 @@ · 12 unmodified lines
2440 2533
2441 "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],2534 "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
2442 2535
2443 "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
2444
2445 "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
2446
2447 "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],2536 "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
2448 2537
2449 "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],2538 "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@@ -2560,6 +2649,14 @@ · 110 unmodified lines
2560 2649
2561 "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],2650 "@astrojs/react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
2562 2651
2652 "@astrojs/tailwind/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
2653
2654 "@astrojs/tailwind/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
2655
2656 "@executor-v2/app-www/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
2657
2658 "@executor-v2/app-www/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
2659
2563 "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],2660 "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
2564 2661
2565 "@vercel/fun/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],2662 "@vercel/fun/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -2626,8 +2723,10 @@ · 60 unmodified lines
2626 2723
2627 "micro/raw-body/http-errors/toidentifier": ["toidentifier@1.0.0", "", {}, "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="],2724 "micro/raw-body/http-errors/toidentifier": ["toidentifier@1.0.0", "", {}, "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="],
2628 2725
2629 "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
2630
2631 "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],2726 "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
2727
2728 "@astrojs/tailwind/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
2729
2730 "@executor-v2/app-www/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
2632 }2731 }
2633}2732}
v2/packages/convex-test/compare.d.ts+3−0
@@ -0,0 +1,3 @@
1import { Value } from "convex/values";
2export 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
4export function compareValues(k1, k2) {
5 return compareAsTuples(makeComparable(k1), makeComparable(k2));
6}
7function 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}
16function 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
47function 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 @@
1import { DataModelFromSchemaDefinition, FunctionReference, FunctionReturnType, GenericActionCtx, GenericDataModel, GenericMutationCtx, GenericSchema, OptionalRestArgs, SchemaDefinition, UserIdentity } from "convex/server";
2export type TestConvex<SchemaDef extends SchemaDefinition<any, boolean>> = TestConvexForDataModelAndIdentity<DataModelFromSchemaDefinition<SchemaDef>>;
3export 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};
74export 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 */
97export 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 @@
1import { actionGeneric, getFunctionAddress, httpActionGeneric, makeFunctionReference, mutationGeneric, queryGeneric, } from "convex/server";
2import { convexToJson, jsonToConvex, } from "convex/values";
3import { compareValues } from "./compare.js";
4const ROOT_COMPONENT_PATH = "";
5class 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}
391function tableNameFromId(id) {
392 const parts = id.split(";");
393 if (parts.length !== 2) {
394 return null;
395 }
396 return id.split(";")[1];
397}
398function 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}
408function 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}
419function 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}
479function 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}
495function evaluateValue(value) {
496 if (typeof value === "object" && value !== null && "$undefined" in value) {
497 return undefined;
498 }
499 return jsonToConvex(value);
500}
501function 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}
518function 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.
539function 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}
582function printIndexOperator(filter) {
583 return `.${filter.type.toLowerCase()}(${filter.fieldPath}, ${JSON.stringify(filter.value)})`;
584}
585function 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}
597function isValidIdentifier(name) {
598 return /(^_(id|creationTime)$)|^[a-zA-Z][a-zA-Z0-9_]*$/.test(name);
599}
600function 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}
708function 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}
734class AuthFake {
735 _userIdentity;
736 constructor(_userIdentity = null) {
737 this._userIdentity = _userIdentity;
738 }
739 getUserIdentity() {
740 return Promise.resolve(this._userIdentity);
741 }
742}
743function 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}
982function 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.
1008async 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}
1019async 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}
1025async 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}
1047function 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}
1054function getDbForComponent(componentPath) {
1055 return getComponentInfo(componentPath).db;
1056}
1057function getDb() {
1058 return getDbForComponent(getCurrentComponentPath());
1059}
1060function getTransactionManager() {
1061 return getConvexGlobal().transactionManager;
1062}
1063function getCurrentComponentPath() {
1064 const functionStack = getTransactionManager().functionStack;
1065 const currentFunctionPath = functionStack[functionStack.length - 1];
1066 return currentFunctionPath?.componentPath ?? ROOT_COMPONENT_PATH;
1067}
1068function getModules() {
1069 return getModulesForComponent(getCurrentComponentPath());
1070}
1071function getModulesForComponent(componentPath) {
1072 return getComponentInfo(componentPath).modules;
1073}
1074function getSyscalls() {
1075 return global.Convex;
1076}
1077function 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}
1092function 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}
1106function getConvexGlobal() {
1107 return global.Convex;
1108}
1109function 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}
1117class 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 */
1196export const convexTest = (schema,
1197// For example `import.meta.glob("./**/*.*s")`
1198modules) => {
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};
1229function 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}
1405function 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}
1414function createFunctionHandle(functionPath) {
1415 return `function://${functionPath.componentPath};${functionPath.udfPath}`;
1416}
1417function parseFunctionHandle(handle) {
1418 const [componentPath, udfPath] = handle.split("function://")[1].split(";");
1419 return { componentPath, udfPath };
1420}
1421async 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}
1453async 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}
1460function getHandler(func) {
1461 return "_handler" in func ? func["_handler"] : func;
1462}
1463async 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}
1499function 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
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 ".": "./src/index.ts"6 ".": "./src/index.ts",
7 "./api": "./src/api.ts",
8 "./approvals/api": "./src/approvals/api.ts",
9 "./auth/principal": "./src/auth/principal.ts",
10 "./client": "./src/client.ts",
11 "./credentials/api": "./src/credentials/api.ts",
12 "./organizations/api": "./src/organizations/api.ts",
13 "./policies/api": "./src/policies/api.ts",
14 "./storage/api": "./src/storage/api.ts",
15 "./sources/api": "./src/sources/api.ts",
16 "./tools/api": "./src/tools/api.ts",
17 "./workspaces/api": "./src/workspaces/api.ts"
7 },18 },
8 "scripts": {19 "scripts": {
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json"20 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
@@ -14,7 +25,8 @@ · 4 unmodified lines
14 "@executor-v2/domain": "workspace:*",25 "@executor-v2/domain": "workspace:*",
15 "@executor-v2/persistence-ports": "workspace:*",26 "@executor-v2/persistence-ports": "workspace:*",
16 "@executor-v2/schema": "workspace:*",27 "@executor-v2/schema": "workspace:*",
17 "effect": "catalog:"28 "effect": "catalog:",
29 "yaml": "^2.8.1"
18 },30 },
19 "devDependencies": {31 "devDependencies": {
20 "@effect/vitest": "catalog:",32 "@effect/vitest": "catalog:",
v2/packages/management-api/src/api.ts+64−0
@@ -1,7 +1,13 @@
1import { HttpApi, OpenApi } from "@effect/platform";1import { HttpApi, OpenApi } from "@effect/platform";
2 2
3import { ApprovalsApi } from "./approvals/api";3import { ApprovalsApi } from "./approvals/api";
4import { CredentialsApi } from "./credentials/api";
5import { OrganizationsApi } from "./organizations/api";
6import { PoliciesApi } from "./policies/api";
7import { StorageApi } from "./storage/api";
4import { SourcesApi } from "./sources/api";8import { SourcesApi } from "./sources/api";
9import { ToolsApi } from "./tools/api";
10import { WorkspacesApi } from "./workspaces/api";
5 11
6export {12export {
7 ControlPlaneBadRequestError,13 ControlPlaneBadRequestError,
@@ -17,6 +23,58 @@ export { · 9 unmodified lines
17 type UpsertSourcePayload,23 type UpsertSourcePayload,
18} from "./sources/api";24} from "./sources/api";
19 25
26export {
27 RemoveCredentialBindingResultSchema,
28 UpsertCredentialBindingPayloadSchema,
29 type RemoveCredentialBindingResult,
30 type UpsertCredentialBindingPayload,
31} from "./credentials/api";
32
33export {
34 RemovePolicyResultSchema,
35 UpsertPolicyPayloadSchema,
36 type RemovePolicyResult,
37 type UpsertPolicyPayload,
38} from "./policies/api";
39
40export {
41 UpsertOrganizationPayloadSchema,
42 type UpsertOrganizationPayload,
43} from "./organizations/api";
44
45export {
46 SourceToolSummarySchema,
47 type SourceToolSummary,
48} from "./tools/api";
49
50export {
51 UpsertWorkspacePayloadSchema,
52 type UpsertWorkspacePayload,
53} from "./workspaces/api";
54
55export {
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
20export {78export {
21 ResolveApprovalPayloadSchema,79 ResolveApprovalPayloadSchema,
22 ResolveApprovalStatusSchema,80 ResolveApprovalStatusSchema,
@@ -25,6 +83,12 @@ export { · 2 unmodified lines
25 83
26export class ControlPlaneApi extends HttpApi.make("controlPlane")84export class ControlPlaneApi extends HttpApi.make("controlPlane")
27 .add(SourcesApi)85 .add(SourcesApi)
86 .add(CredentialsApi)
87 .add(PoliciesApi)
88 .add(OrganizationsApi)
89 .add(WorkspacesApi)
90 .add(ToolsApi)
91 .add(StorageApi)
28 .add(ApprovalsApi)92 .add(ApprovalsApi)
29 .annotateContext(93 .annotateContext(
30 OpenApi.annotations({94 OpenApi.annotations({
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
7import * as Context from "effect/Context";7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9 9
10export type ResolveActorInput = {
11 headers: PlatformHeaders.Headers;
12};
13
10export type ResolveWorkspaceActorInput = {14export type ResolveWorkspaceActorInput = {
11 workspaceId: WorkspaceId;15 workspaceId: WorkspaceId;
12 headers: PlatformHeaders.Headers;16 headers: PlatformHeaders.Headers;
13};17};
14 18
15export type ControlPlaneActorResolverShape = {19export type ControlPlaneActorResolverShape = {
20 resolveActor: (
21 input: ResolveActorInput,
22 ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
16 resolveWorkspaceActor: (23 resolveWorkspaceActor: (
17 input: ResolveWorkspaceActorInput,24 input: ResolveWorkspaceActorInput,
18 ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;25 ) => Effect.Effect<ActorShape, ActorUnauthenticatedError>;
v2/packages/management-api/src/credentials/api.ts+74−0
@@ -0,0 +1,74 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 CredentialBindingIdSchema,
4 SourceCredentialBindingSchema,
5 WorkspaceIdSchema,
6} from "@executor-v2/schema";
7import * as Schema from "effect/Schema";
8
9import {
10 ControlPlaneBadRequestError,
11 ControlPlaneForbiddenError,
12 ControlPlaneStorageError,
13 ControlPlaneUnauthorizedError,
14} from "../errors";
15
16const RequiredUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSchema.pipe(
17 Schema.pick("credentialId", "scopeType", "sourceKey", "provider", "secretRef"),
18);
19
20const OptionalUpsertCredentialBindingPayloadSchema = SourceCredentialBindingSchema.pipe(
21 Schema.pick("id", "accountId", "additionalHeadersJson", "boundAuthFingerprint"),
22 Schema.partialWith({ exact: true }),
23);
24
25export const UpsertCredentialBindingPayloadSchema =
26 RequiredUpsertCredentialBindingPayloadSchema.pipe(
27 Schema.extend(OptionalUpsertCredentialBindingPayloadSchema),
28 );
29
30export type UpsertCredentialBindingPayload =
31 typeof UpsertCredentialBindingPayloadSchema.Type;
32
33export const RemoveCredentialBindingResultSchema = Schema.Struct({
34 removed: Schema.Boolean,
35});
36
37export type RemoveCredentialBindingResult =
38 typeof RemoveCredentialBindingResultSchema.Type;
39
40const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
41const credentialBindingIdParam = HttpApiSchema.param(
42 "credentialBindingId",
43 CredentialBindingIdSchema,
44);
45
46export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import {
3 Actor,
4 ActorForbiddenError,
5 ActorUnauthenticatedError,
6 requirePermission,
7 withPolicy,
8} from "@executor-v2/domain";
9import { type SourceStoreError } from "@executor-v2/persistence-ports";
10import { type WorkspaceId } from "@executor-v2/schema";
11import * as Effect from "effect/Effect";
12
13import {
14 ControlPlaneForbiddenError,
15 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,
17} from "../errors";
18import { ControlPlaneApi } from "../api";
19import { ControlPlaneActorResolver } from "../auth/actor-resolver";
20import { ControlPlaneService } from "../service";
21
22const 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
32const 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
42const toUnauthorizedError = (
43 operation: string,
44 cause: ActorUnauthenticatedError,
45): ControlPlaneUnauthorizedError =>
46 new ControlPlaneUnauthorizedError({
47 operation,
48 message: cause.message,
49 details: "Authentication required",
50 });
51
52const 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
63const requireReadCredentials = (workspaceId: WorkspaceId) =>
64 requirePermission({
65 permission: "credentials:read",
66 workspaceId,
67 });
68
69const requireWriteCredentials = (workspaceId: WorkspaceId) =>
70 requirePermission({
71 permission: "credentials:write",
72 workspaceId,
73 });
74
75export 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 @@
1export {
2 CredentialsApi,
3 RemoveCredentialBindingResultSchema,
4 UpsertCredentialBindingPayloadSchema,
5 type RemoveCredentialBindingResult,
6 type UpsertCredentialBindingPayload,
7} from "./api";
8
9export {
10 makeControlPlaneCredentialsService,
11 type ControlPlaneCredentialsServiceShape,
12 type RemoveCredentialBindingInput,
13 type UpsertCredentialBindingInput,
14} from "./service";
15
16export { ControlPlaneCredentialsLive } from "./http";
v2/packages/management-api/src/credentials/service.ts+38−0
@@ -0,0 +1,38 @@
1import { type SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 type CredentialBindingId,
4 type SourceCredentialBinding,
5 type WorkspaceId,
6} from "@executor-v2/schema";
7import * as Effect from "effect/Effect";
8
9import type {
10 RemoveCredentialBindingResult,
11 UpsertCredentialBindingPayload,
12} from "./api";
13
14export type UpsertCredentialBindingInput = {
15 workspaceId: WorkspaceId;
16 payload: UpsertCredentialBindingPayload;
17};
18
19export type RemoveCredentialBindingInput = {
20 workspaceId: WorkspaceId;
21 credentialBindingId: CredentialBindingId;
22};
23
24export 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
36export 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
8} from "./auth/actor-resolver";8} from "./auth/actor-resolver";
9import { ControlPlaneService } from "./service";9import { ControlPlaneService } from "./service";
10import { ControlPlaneApprovalsLive } from "./approvals/http";10import { ControlPlaneApprovalsLive } from "./approvals/http";
11import { ControlPlaneCredentialsLive } from "./credentials/http";
12import { ControlPlaneOrganizationsLive } from "./organizations/http";
13import { ControlPlanePoliciesLive } from "./policies/http";
14import { ControlPlaneStorageLive } from "./storage/http";
11import { ControlPlaneSourcesLive } from "./sources/http";15import { ControlPlaneSourcesLive } from "./sources/http";
16import { ControlPlaneToolsLive } from "./tools/http";
17import { ControlPlaneWorkspacesLive } from "./workspaces/http";
12 18
13export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(19export const ControlPlaneApiLive = HttpApiBuilder.api(ControlPlaneApi).pipe(
14 Layer.provide(ControlPlaneSourcesLive),20 Layer.provide(ControlPlaneSourcesLive),
21 Layer.provide(ControlPlaneCredentialsLive),
22 Layer.provide(ControlPlanePoliciesLive),
23 Layer.provide(ControlPlaneOrganizationsLive),
24 Layer.provide(ControlPlaneWorkspacesLive),
25 Layer.provide(ControlPlaneToolsLive),
26 Layer.provide(ControlPlaneStorageLive),
15 Layer.provide(ControlPlaneApprovalsLive),27 Layer.provide(ControlPlaneApprovalsLive),
16);28);
17 29
v2/packages/management-api/src/index.ts+84−0
@@ -1,13 +1,47 @@
1export {1export {
2 ControlPlaneApi,2 ControlPlaneApi,
3 ListStorageDirectoryPayloadSchema,
4 ListStorageDirectoryResultSchema,
5 ListStorageKvPayloadSchema,
6 ListStorageKvResultSchema,
7 OpenStorageInstancePayloadSchema,
8 QueryStorageSqlPayloadSchema,
9 QueryStorageSqlResultSchema,
10 ReadStorageFilePayloadSchema,
11 ReadStorageFileResultSchema,
12 RemoveCredentialBindingResultSchema,
13 RemovePolicyResultSchema,
3 RemoveSourceResultSchema,14 RemoveSourceResultSchema,
15 RemoveStorageInstanceResultSchema,
4 ResolveApprovalPayloadSchema,16 ResolveApprovalPayloadSchema,
5 ResolveApprovalStatusSchema,17 ResolveApprovalStatusSchema,
18 SourceToolSummarySchema,
19 UpsertCredentialBindingPayloadSchema,
20 UpsertOrganizationPayloadSchema,
21 UpsertPolicyPayloadSchema,
6 UpsertSourcePayloadSchema,22 UpsertSourcePayloadSchema,
23 UpsertWorkspacePayloadSchema,
7 controlPlaneOpenApiSpec,24 controlPlaneOpenApiSpec,
25 type ListStorageDirectoryPayload,
26 type ListStorageDirectoryResult,
27 type ListStorageKvPayload,
28 type ListStorageKvResult,
29 type OpenStorageInstancePayload,
30 type QueryStorageSqlPayload,
31 type QueryStorageSqlResult,
32 type ReadStorageFilePayload,
33 type ReadStorageFileResult,
34 type RemoveCredentialBindingResult,
35 type RemovePolicyResult,
8 type RemoveSourceResult,36 type RemoveSourceResult,
37 type RemoveStorageInstanceResult,
9 type ResolveApprovalPayload,38 type ResolveApprovalPayload,
39 type SourceToolSummary,
40 type UpsertCredentialBindingPayload,
41 type UpsertOrganizationPayload,
42 type UpsertPolicyPayload,
10 type UpsertSourcePayload,43 type UpsertSourcePayload,
44 type UpsertWorkspacePayload,
11} from "./api";45} from "./api";
12 46
13export {47export {
@@ -32,6 +66,7 @@ export { · 18 unmodified lines
32export {66export {
33 ControlPlaneActorResolver,67 ControlPlaneActorResolver,
34 type ControlPlaneActorResolverShape,68 type ControlPlaneActorResolverShape,
69 type ResolveActorInput,
35 type ResolveWorkspaceActorInput,70 type ResolveWorkspaceActorInput,
36} from "./auth/actor-resolver";71} from "./auth/actor-resolver";
37 72
@@ -51,6 +86,52 @@ export { · 13 unmodified lines
51 type UpsertSourceInput,86 type UpsertSourceInput,
52} from "./sources";87} from "./sources";
53 88
89export {
90 ControlPlaneCredentialsLive,
91 makeControlPlaneCredentialsService,
92 type ControlPlaneCredentialsServiceShape,
93 type RemoveCredentialBindingInput,
94 type UpsertCredentialBindingInput,
95} from "./credentials";
96
97export {
98 ControlPlanePoliciesLive,
99 makeControlPlanePoliciesService,
100 type ControlPlanePoliciesServiceShape,
101 type RemovePolicyInput,
102 type UpsertPolicyInput,
103} from "./policies";
104
105export {
106 ControlPlaneOrganizationsLive,
107 makeControlPlaneOrganizationsService,
108 type ControlPlaneOrganizationsServiceShape,
109 type UpsertOrganizationInput,
110} from "./organizations";
111
112export {
113 ControlPlaneWorkspacesLive,
114 makeControlPlaneWorkspacesService,
115 type ControlPlaneWorkspacesServiceShape,
116 type UpsertWorkspaceInput,
117} from "./workspaces";
118
119export {
120 ControlPlaneToolsLive,
121 makeControlPlaneToolsService,
122 type ControlPlaneToolsServiceShape,
123 type ListSourceToolsInput,
124} from "./tools";
125
126export {
127 ControlPlaneStorageLive,
128 makeControlPlaneStorageService,
129 type CloseStorageInstanceInput,
130 type ControlPlaneStorageServiceShape,
131 type OpenStorageInstanceInput,
132 type RemoveStorageInstanceInput,
133} from "./storage";
134
54export {135export {
55 ControlPlaneApprovalsLive,136 ControlPlaneApprovalsLive,
56 makeControlPlaneApprovalsService,137 makeControlPlaneApprovalsService,
@@ -90,3 +171,6 @@ export { · 33 unmodified lines
90 type SourceManagerService,171 type SourceManagerService,
91 type ToolManifestDiff,172 type ToolManifestDiff,
92} from "./source-manager";173} from "./source-manager";
174
175export { fetchOpenApiDocument, parseOpenApiDocument } from "./openapi-document";
176
v2/packages/management-api/src/openapi-document.ts+36−0
@@ -0,0 +1,36 @@
1import { parse as parseYaml } from "yaml";
2
3const parseJsonDocument = (input: string): unknown => JSON.parse(input);
4
5const parseYamlDocument = (input: string): unknown => parseYaml(input);
6
7export 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
28export 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 @@
1import { createHash } from "node:crypto";
2
3import {1import {
4 ToolArtifactStoreError,2 ToolArtifactStoreError,
5 type ToolArtifactStore,3 type ToolArtifactStore,
@@ -473,8 +471,20 @@ const toStableValue = (value: unknown): unknown => { · 467 unmodified lines
473 return value;471 return value;
474};472};
475 473
474const 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
476const hashUnknown = (value: unknown): string =>486const hashUnknown = (value: unknown): string =>
477 createHash("sha256").update(JSON.stringify(toStableValue(value))).digest("hex");487 hashString(JSON.stringify(toStableValue(value)));
478 488
479const normalizePathForToolId = (pathValue: string): string =>489const normalizePathForToolId = (pathValue: string): string =>
480 pathValue490 pathValue
v2/packages/management-api/src/organizations/api.ts+47−0
@@ -0,0 +1,47 @@
1import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
2import { OrganizationSchema } from "@executor-v2/schema";
3import * as Schema from "effect/Schema";
4
5import {
6 ControlPlaneBadRequestError,
7 ControlPlaneForbiddenError,
8 ControlPlaneStorageError,
9 ControlPlaneUnauthorizedError,
10} from "../errors";
11
12const RequiredUpsertOrganizationPayloadSchema = OrganizationSchema.pipe(
13 Schema.pick("slug", "name"),
14);
15
16const OptionalUpsertOrganizationPayloadSchema = OrganizationSchema.pipe(
17 Schema.pick("id", "status"),
18 Schema.partialWith({ exact: true }),
19);
20
21export const UpsertOrganizationPayloadSchema =
22 RequiredUpsertOrganizationPayloadSchema.pipe(
23 Schema.extend(OptionalUpsertOrganizationPayloadSchema),
24 );
25
26export type UpsertOrganizationPayload =
27 typeof UpsertOrganizationPayloadSchema.Type;
28
29export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import {
3 Actor,
4 ActorForbiddenError,
5 ActorUnauthenticatedError,
6 requirePermission,
7 type ActorShape,
8 withPolicy,
9} from "@executor-v2/domain";
10import { type SourceStoreError } from "@executor-v2/persistence-ports";
11import {
12 type Organization,
13 type OrganizationId,
14} from "@executor-v2/schema";
15import * as Effect from "effect/Effect";
16
17import {
18 ControlPlaneBadRequestError,
19 ControlPlaneForbiddenError,
20 ControlPlaneStorageError,
21 ControlPlaneUnauthorizedError,
22} from "../errors";
23import { ControlPlaneApi } from "../api";
24import { ControlPlaneActorResolver } from "../auth/actor-resolver";
25import { ControlPlaneService } from "../service";
26
27const 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
37const 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
47const toUnauthorizedError = (
48 operation: string,
49 cause: ActorUnauthenticatedError,
50): ControlPlaneUnauthorizedError =>
51 new ControlPlaneUnauthorizedError({
52 operation,
53 message: cause.message,
54 details: "Authentication required",
55 });
56
57const 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
66const requireReadOrganizations = requirePermission({
67 permission: "organizations:read",
68});
69
70const requireManageOrganization = (organizationId: OrganizationId) =>
71 requirePermission({
72 permission: "organizations:manage",
73 organizationId,
74 });
75
76const 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
87export 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 @@
1export {
2 UpsertOrganizationPayloadSchema,
3 type UpsertOrganizationPayload,
4} from "./api";
5
6export {
7 makeControlPlaneOrganizationsService,
8 type ControlPlaneOrganizationsServiceShape,
9 type UpsertOrganizationInput,
10} from "./service";
11
12export { ControlPlaneOrganizationsLive } from "./http";
v2/packages/management-api/src/organizations/service.ts+20−0
@@ -0,0 +1,20 @@
1import { type SourceStoreError } from "@executor-v2/persistence-ports";
2import { type Organization } from "@executor-v2/schema";
3import * as Effect from "effect/Effect";
4
5import type { UpsertOrganizationPayload } from "./api";
6
7export type UpsertOrganizationInput = {
8 payload: UpsertOrganizationPayload;
9};
10
11export type ControlPlaneOrganizationsServiceShape = {
12 listOrganizations: () => Effect.Effect<ReadonlyArray<Organization>, SourceStoreError>;
13 upsertOrganization: (
14 input: UpsertOrganizationInput,
15 ) => Effect.Effect<Organization, SourceStoreError>;
16};
17
18export const makeControlPlaneOrganizationsService = (
19 service: ControlPlaneOrganizationsServiceShape,
20): ControlPlaneOrganizationsServiceShape => service;
v2/packages/management-api/src/policies/api.ts+62−0
@@ -0,0 +1,62 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { PolicyIdSchema, PolicySchema, WorkspaceIdSchema } from "@executor-v2/schema";
3import * as Schema from "effect/Schema";
4
5import {
6 ControlPlaneBadRequestError,
7 ControlPlaneForbiddenError,
8 ControlPlaneStorageError,
9 ControlPlaneUnauthorizedError,
10} from "../errors";
11
12const RequiredUpsertPolicyPayloadSchema = PolicySchema.pipe(
13 Schema.pick("toolPathPattern", "decision"),
14);
15
16const OptionalUpsertPolicyPayloadSchema = PolicySchema.pipe(
17 Schema.pick("id"),
18 Schema.partialWith({ exact: true }),
19);
20
21export const UpsertPolicyPayloadSchema = RequiredUpsertPolicyPayloadSchema.pipe(
22 Schema.extend(OptionalUpsertPolicyPayloadSchema),
23);
24
25export type UpsertPolicyPayload = typeof UpsertPolicyPayloadSchema.Type;
26
27export const RemovePolicyResultSchema = Schema.Struct({
28 removed: Schema.Boolean,
29});
30
31export type RemovePolicyResult = typeof RemovePolicyResultSchema.Type;
32
33const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
34const policyIdParam = HttpApiSchema.param("policyId", PolicyIdSchema);
35
36export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import {
3 Actor,
4 ActorForbiddenError,
5 ActorUnauthenticatedError,
6 requirePermission,
7 withPolicy,
8} from "@executor-v2/domain";
9import { type SourceStoreError } from "@executor-v2/persistence-ports";
10import { type WorkspaceId } from "@executor-v2/schema";
11import * as Effect from "effect/Effect";
12
13import {
14 ControlPlaneForbiddenError,
15 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,
17} from "../errors";
18import { ControlPlaneApi } from "../api";
19import { ControlPlaneActorResolver } from "../auth/actor-resolver";
20import { ControlPlaneService } from "../service";
21
22const 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
32const 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
42const toUnauthorizedError = (
43 operation: string,
44 cause: ActorUnauthenticatedError,
45): ControlPlaneUnauthorizedError =>
46 new ControlPlaneUnauthorizedError({
47 operation,
48 message: cause.message,
49 details: "Authentication required",
50 });
51
52const 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
63const requireReadPolicies = (workspaceId: WorkspaceId) =>
64 requirePermission({
65 permission: "policies:read",
66 workspaceId,
67 });
68
69const requireWritePolicies = (workspaceId: WorkspaceId) =>
70 requirePermission({
71 permission: "policies:write",
72 workspaceId,
73 });
74
75export 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 @@
1export {
2 PoliciesApi,
3 RemovePolicyResultSchema,
4 UpsertPolicyPayloadSchema,
5 type RemovePolicyResult,
6 type UpsertPolicyPayload,
7} from "./api";
8
9export {
10 makeControlPlanePoliciesService,
11 type ControlPlanePoliciesServiceShape,
12 type RemovePolicyInput,
13 type UpsertPolicyInput,
14} from "./service";
15
16export { ControlPlanePoliciesLive } from "./http";
v2/packages/management-api/src/policies/service.ts+31−0
@@ -0,0 +1,31 @@
1import { type SourceStoreError } from "@executor-v2/persistence-ports";
2import { type Policy, type PolicyId, type WorkspaceId } from "@executor-v2/schema";
3import * as Effect from "effect/Effect";
4
5import type { RemovePolicyResult, UpsertPolicyPayload } from "./api";
6
7export type UpsertPolicyInput = {
8 workspaceId: WorkspaceId;
9 payload: UpsertPolicyPayload;
10};
11
12export type RemovePolicyInput = {
13 workspaceId: WorkspaceId;
14 policyId: PolicyId;
15};
16
17export 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
29export const makeControlPlanePoliciesService = (
30 service: ControlPlanePoliciesServiceShape,
31): ControlPlanePoliciesServiceShape => service;
v2/packages/management-api/src/service.ts+24−0
@@ -1,9 +1,21 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2 2
3import { type ControlPlaneApprovalsServiceShape } from "./approvals/service";3import { type ControlPlaneApprovalsServiceShape } from "./approvals/service";
4import { type ControlPlaneCredentialsServiceShape } from "./credentials/service";
5import { type ControlPlaneOrganizationsServiceShape } from "./organizations/service";
6import { type ControlPlanePoliciesServiceShape } from "./policies/service";
7import { type ControlPlaneStorageServiceShape } from "./storage/service";
4import { type ControlPlaneSourcesServiceShape } from "./sources/service";8import { type ControlPlaneSourcesServiceShape } from "./sources/service";
9import { type ControlPlaneToolsServiceShape } from "./tools/service";
10import { type ControlPlaneWorkspacesServiceShape } from "./workspaces/service";
5 11
6export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape &12export type ControlPlaneServiceShape = ControlPlaneSourcesServiceShape &
13 ControlPlaneCredentialsServiceShape &
14 ControlPlanePoliciesServiceShape &
15 ControlPlaneOrganizationsServiceShape &
16 ControlPlaneWorkspacesServiceShape &
17 ControlPlaneToolsServiceShape &
18 ControlPlaneStorageServiceShape &
7 ControlPlaneApprovalsServiceShape;19 ControlPlaneApprovalsServiceShape;
8 20
9export class ControlPlaneService extends Context.Tag("@executor-v2/management-api/ControlPlaneService")<21export class ControlPlaneService extends Context.Tag("@executor-v2/management-api/ControlPlaneService")<
@@ -13,8 +25,20 @@ export class ControlPlaneService extends Context.Tag("@executor-v2/management-ap · 3 unmodified lines
13 25
14export const makeControlPlaneService = (services: {26export const makeControlPlaneService = (services: {
15 sources: ControlPlaneSourcesServiceShape;27 sources: ControlPlaneSourcesServiceShape;
28 credentials: ControlPlaneCredentialsServiceShape;
29 policies: ControlPlanePoliciesServiceShape;
30 organizations: ControlPlaneOrganizationsServiceShape;
31 workspaces: ControlPlaneWorkspacesServiceShape;
32 tools: ControlPlaneToolsServiceShape;
33 storage: ControlPlaneStorageServiceShape;
16 approvals: ControlPlaneApprovalsServiceShape;34 approvals: ControlPlaneApprovalsServiceShape;
17}): ControlPlaneServiceShape => ({35}): ControlPlaneServiceShape => ({
18 ...services.sources,36 ...services.sources,
37 ...services.credentials,
38 ...services.policies,
39 ...services.organizations,
40 ...services.workspaces,
41 ...services.tools,
42 ...services.storage,
19 ...services.approvals,43 ...services.approvals,
20});44});
v2/packages/management-api/src/storage/api.ts+235−0
@@ -0,0 +1,235 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 AccountIdSchema,
4 StorageDurabilitySchema,
5 StorageInstanceIdSchema,
6 StorageInstanceSchema,
7 StorageProviderSchema,
8 StorageScopeTypeSchema,
9 WorkspaceIdSchema,
10} from "@executor-v2/schema";
11import * as Schema from "effect/Schema";
12
13import {
14 ControlPlaneBadRequestError,
15 ControlPlaneForbiddenError,
16 ControlPlaneStorageError,
17 ControlPlaneUnauthorizedError,
18} from "../errors";
19
20const RequiredOpenStorageInstancePayloadSchema = Schema.Struct({
21 scopeType: StorageScopeTypeSchema,
22 durability: StorageDurabilitySchema,
23});
24
25const 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
33export const OpenStorageInstancePayloadSchema =
34 RequiredOpenStorageInstancePayloadSchema.pipe(
35 Schema.extend(OptionalOpenStorageInstancePayloadSchema),
36 );
37
38export type OpenStorageInstancePayload =
39 typeof OpenStorageInstancePayloadSchema.Type;
40
41export const RemoveStorageInstanceResultSchema = Schema.Struct({
42 removed: Schema.Boolean,
43});
44
45export type RemoveStorageInstanceResult =
46 typeof RemoveStorageInstanceResultSchema.Type;
47
48export const ListStorageDirectoryPayloadSchema = Schema.Struct({
49 path: Schema.String,
50});
51
52export type ListStorageDirectoryPayload =
53 typeof ListStorageDirectoryPayloadSchema.Type;
54
55export 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
63export type StorageDirectoryEntry = typeof StorageDirectoryEntrySchema.Type;
64
65export const ListStorageDirectoryResultSchema = Schema.Struct({
66 path: Schema.String,
67 entries: Schema.Array(StorageDirectoryEntrySchema),
68});
69
70export type ListStorageDirectoryResult =
71 typeof ListStorageDirectoryResultSchema.Type;
72
73const RequiredReadStorageFilePayloadSchema = Schema.Struct({
74 path: Schema.String,
75});
76
77const OptionalReadStorageFilePayloadSchema = Schema.Struct({
78 encoding: Schema.Literal("utf8", "base64"),
79}).pipe(Schema.partialWith({ exact: true }));
80
81export const ReadStorageFilePayloadSchema =
82 RequiredReadStorageFilePayloadSchema.pipe(
83 Schema.extend(OptionalReadStorageFilePayloadSchema),
84 );
85
86export type ReadStorageFilePayload = typeof ReadStorageFilePayloadSchema.Type;
87
88export const ReadStorageFileResultSchema = Schema.Struct({
89 path: Schema.String,
90 encoding: Schema.Literal("utf8", "base64"),
91 content: Schema.String,
92 bytes: Schema.Number,
93});
94
95export type ReadStorageFileResult = typeof ReadStorageFileResultSchema.Type;
96
97export const ListStorageKvPayloadSchema = Schema.Struct({
98 prefix: Schema.String,
99 limit: Schema.Number,
100}).pipe(Schema.partialWith({ exact: true }));
101
102export type ListStorageKvPayload = typeof ListStorageKvPayloadSchema.Type;
103
104export const StorageKvItemSchema = Schema.Struct({
105 key: Schema.String,
106 value: Schema.Unknown,
107});
108
109export type StorageKvItem = typeof StorageKvItemSchema.Type;
110
111export const ListStorageKvResultSchema = Schema.Struct({
112 items: Schema.Array(StorageKvItemSchema),
113});
114
115export type ListStorageKvResult = typeof ListStorageKvResultSchema.Type;
116
117const RequiredQueryStorageSqlPayloadSchema = Schema.Struct({
118 sql: Schema.String,
119});
120
121const OptionalQueryStorageSqlPayloadSchema = Schema.Struct({
122 maxRows: Schema.Number,
123}).pipe(Schema.partialWith({ exact: true }));
124
125export const QueryStorageSqlPayloadSchema =
126 RequiredQueryStorageSqlPayloadSchema.pipe(
127 Schema.extend(OptionalQueryStorageSqlPayloadSchema),
128 );
129
130export type QueryStorageSqlPayload = typeof QueryStorageSqlPayloadSchema.Type;
131
132const SqlRowSchema = Schema.Record({
133 key: Schema.String,
134 value: Schema.Unknown,
135});
136
137export type SqlRow = typeof SqlRowSchema.Type;
138
139export const QueryStorageSqlResultSchema = Schema.Struct({
140 rows: Schema.Array(SqlRowSchema),
141 columns: Schema.Array(Schema.String),
142 rowCount: Schema.Number,
143});
144
145export type QueryStorageSqlResult = typeof QueryStorageSqlResultSchema.Type;
146
147const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
148const storageInstanceIdParam = HttpApiSchema.param(
149 "storageInstanceId",
150 StorageInstanceIdSchema,
151);
152
153export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import {
3 Actor,
4 ActorForbiddenError,
5 ActorUnauthenticatedError,
6 requirePermission,
7 withPolicy,
8} from "@executor-v2/domain";
9import { type SourceStoreError } from "@executor-v2/persistence-ports";
10import { type WorkspaceId } from "@executor-v2/schema";
11import * as Effect from "effect/Effect";
12
13import {
14 ControlPlaneForbiddenError,
15 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,
17} from "../errors";
18import { ControlPlaneApi } from "../api";
19import { ControlPlaneActorResolver } from "../auth/actor-resolver";
20import { ControlPlaneService } from "../service";
21
22const 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
32const 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
42const toUnauthorizedError = (
43 operation: string,
44 cause: ActorUnauthenticatedError,
45): ControlPlaneUnauthorizedError =>
46 new ControlPlaneUnauthorizedError({
47 operation,
48 message: cause.message,
49 details: "Authentication required",
50 });
51
52const 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
63const requireReadStorage = (workspaceId: WorkspaceId) =>
64 requirePermission({
65 permission: "storage:read",
66 workspaceId,
67 });
68
69const requireWriteStorage = (workspaceId: WorkspaceId) =>
70 requirePermission({
71 permission: "storage:write",
72 workspaceId,
73 });
74
75export 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 @@
1export {
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
25export {
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
37export { ControlPlaneStorageLive } from "./http";
v2/packages/management-api/src/storage/service.ts+90−0
@@ -0,0 +1,90 @@
1import { type SourceStoreError } from "@executor-v2/persistence-ports";
2import {
3 type StorageInstance,
4 type StorageInstanceId,
5 type WorkspaceId,
6} from "@executor-v2/schema";
7import * as Effect from "effect/Effect";
8
9import 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
22export type OpenStorageInstanceInput = {
23 workspaceId: WorkspaceId;
24 payload: OpenStorageInstancePayload;
25};
26
27export type CloseStorageInstanceInput = {
28 workspaceId: WorkspaceId;
29 storageInstanceId: StorageInstanceId;
30};
31
32export type RemoveStorageInstanceInput = {
33 workspaceId: WorkspaceId;
34 storageInstanceId: StorageInstanceId;
35};
36
37export type ListStorageDirectoryInput = {
38 workspaceId: WorkspaceId;
39 storageInstanceId: StorageInstanceId;
40 payload: ListStorageDirectoryPayload;
41};
42
43export type ReadStorageFileInput = {
44 workspaceId: WorkspaceId;
45 storageInstanceId: StorageInstanceId;
46 payload: ReadStorageFilePayload;
47};
48
49export type ListStorageKvInput = {
50 workspaceId: WorkspaceId;
51 storageInstanceId: StorageInstanceId;
52 payload: ListStorageKvPayload;
53};
54
55export type QueryStorageSqlInput = {
56 workspaceId: WorkspaceId;
57 storageInstanceId: StorageInstanceId;
58 payload: QueryStorageSqlPayload;
59};
60
61export 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
88export const makeControlPlaneStorageService = (
89 service: ControlPlaneStorageServiceShape,
90): ControlPlaneStorageServiceShape => service;
v2/packages/management-api/src/tools/api.ts+53−0
@@ -0,0 +1,53 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {
3 OpenApiHttpMethodSchema,
4 SourceIdSchema,
5 SourceKindSchema,
6 WorkspaceIdSchema,
7} from "@executor-v2/schema";
8import * as Schema from "effect/Schema";
9
10import {
11 ControlPlaneBadRequestError,
12 ControlPlaneForbiddenError,
13 ControlPlaneStorageError,
14 ControlPlaneUnauthorizedError,
15} from "../errors";
16
17export 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
29export type SourceToolSummary = typeof SourceToolSummarySchema.Type;
30
31const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
32const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
33
34export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import {
3 Actor,
4 ActorForbiddenError,
5 ActorUnauthenticatedError,
6 requirePermission,
7 withPolicy,
8} from "@executor-v2/domain";
9import { type SourceStoreError } from "@executor-v2/persistence-ports";
10import { type WorkspaceId } from "@executor-v2/schema";
11import * as Effect from "effect/Effect";
12
13import {
14 ControlPlaneForbiddenError,
15 ControlPlaneStorageError,
16 ControlPlaneUnauthorizedError,
17} from "../errors";
18import { ControlPlaneApi } from "../api";
19import { ControlPlaneActorResolver } from "../auth/actor-resolver";
20import { ControlPlaneService } from "../service";
21
22const 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
32const 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
42const toUnauthorizedError = (
43 operation: string,
44 cause: ActorUnauthenticatedError,
45): ControlPlaneUnauthorizedError =>
46 new ControlPlaneUnauthorizedError({
47 operation,
48 message: cause.message,
49 details: "Authentication required",
50 });
51
52const 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
63const requireReadTools = (workspaceId: WorkspaceId) =>
64 requirePermission({
65 permission: "sources:read",
66 workspaceId,
67 });
68
69export 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 @@
1export {
2 SourceToolSummarySchema,
3 type SourceToolSummary,
4} from "./api";
5
6export {
7 makeControlPlaneToolsService,
8 type ControlPlaneToolsServiceShape,
9 type ListSourceToolsInput,
10} from "./service";
11
12export { ControlPlaneToolsLive } from "./http";
v2/packages/management-api/src/tools/service.ts+23−0
@@ -0,0 +1,23 @@
1import { type SourceStoreError } from "@executor-v2/persistence-ports";
2import { type SourceId, type WorkspaceId } from "@executor-v2/schema";
3import * as Effect from "effect/Effect";
4
5import type { SourceToolSummary } from "./api";
6
7export type ListSourceToolsInput = {
8 workspaceId: WorkspaceId;
9 sourceId: SourceId;
10};
11
12export 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
21export const makeControlPlaneToolsService = (
22 service: ControlPlaneToolsServiceShape,
23): ControlPlaneToolsServiceShape => service;
v2/packages/management-api/src/workspaces/api.ts+45−0
@@ -0,0 +1,45 @@
1import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
2import { WorkspaceSchema } from "@executor-v2/schema";
3import * as Schema from "effect/Schema";
4
5import {
6 ControlPlaneBadRequestError,
7 ControlPlaneForbiddenError,
8 ControlPlaneStorageError,
9 ControlPlaneUnauthorizedError,
10} from "../errors";
11
12const RequiredUpsertWorkspacePayloadSchema = WorkspaceSchema.pipe(
13 Schema.pick("name"),
14);
15
16const OptionalUpsertWorkspacePayloadSchema = WorkspaceSchema.pipe(
17 Schema.pick("id", "organizationId"),
18 Schema.partialWith({ exact: true }),
19);
20
21export const UpsertWorkspacePayloadSchema = RequiredUpsertWorkspacePayloadSchema.pipe(
22 Schema.extend(OptionalUpsertWorkspacePayloadSchema),
23);
24
25export type UpsertWorkspacePayload = typeof UpsertWorkspacePayloadSchema.Type;
26
27export 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 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import {
3 Actor,
4 ActorForbiddenError,
5 ActorUnauthenticatedError,
6 any,
7 requirePermission,
8 type ActorShape,
9 withPolicy,
10} from "@executor-v2/domain";
11import { type SourceStoreError } from "@executor-v2/persistence-ports";
12import {
13 type OrganizationId,
14 type Workspace,
15 type WorkspaceId,
16} from "@executor-v2/schema";
17import * as Effect from "effect/Effect";
18
19import {
20 ControlPlaneBadRequestError,
21 ControlPlaneForbiddenError,
22 ControlPlaneStorageError,
23 ControlPlaneUnauthorizedError,
24} from "../errors";
25import { ControlPlaneApi } from "../api";
26import { ControlPlaneActorResolver } from "../auth/actor-resolver";
27import { ControlPlaneService } from "../service";
28
29const 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
39const 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
49const toUnauthorizedError = (
50 operation: string,
51 cause: ActorUnauthenticatedError,
52): ControlPlaneUnauthorizedError =>
53 new ControlPlaneUnauthorizedError({
54 operation,
55 message: cause.message,
56 details: "Authentication required",
57 });
58
59const 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
68const requireReadWorkspaces = requirePermission({
69 permission: "workspace:read",
70});
71
72const requireReadWorkspace = (workspaceId: WorkspaceId) =>
73 requirePermission({
74 permission: "workspace:read",
75 workspaceId,
76 });
77
78const requireReadWorkspaceInOrganization = (organizationId: OrganizationId) =>
79 requirePermission({
80 permission: "workspace:read",
81 organizationId,
82 });
83
84const requireManageWorkspace = (workspaceId: WorkspaceId) =>
85 requirePermission({
86 permission: "workspace:manage",
87 workspaceId,
88 });
89
90const requireManageWorkspaceInOrganization = (organizationId: OrganizationId) =>
91 requirePermission({
92 permission: "workspace:manage",
93 organizationId,
94 });
95
96const 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
114export 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 @@
1export {
2 UpsertWorkspacePayloadSchema,
3 type UpsertWorkspacePayload,
4} from "./api";
5
6export {
7 makeControlPlaneWorkspacesService,
8 type ControlPlaneWorkspacesServiceShape,
9 type UpsertWorkspaceInput,
10} from "./service";
11
12export { ControlPlaneWorkspacesLive } from "./http";
v2/packages/management-api/src/workspaces/service.ts+20−0
@@ -0,0 +1,20 @@
1import { type SourceStoreError } from "@executor-v2/persistence-ports";
2import { type Workspace } from "@executor-v2/schema";
3import * as Effect from "effect/Effect";
4
5import type { UpsertWorkspacePayload } from "./api";
6
7export type UpsertWorkspaceInput = {
8 payload: UpsertWorkspacePayload;
9};
10
11export type ControlPlaneWorkspacesServiceShape = {
12 listWorkspaces: () => Effect.Effect<ReadonlyArray<Workspace>, SourceStoreError>;
13 upsertWorkspace: (
14 input: UpsertWorkspaceInput,
15 ) => Effect.Effect<Workspace, SourceStoreError>;
16};
17
18export 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
18 "organization",18 "organization",
19 "account",19 "account",
20);20);
21export const StorageScopeTypeSchema = Schema.Literal(
22 "scratch",
23 "account",
24 "workspace",
25 "organization",
26);
27export const StorageDurabilitySchema = Schema.Literal("ephemeral", "durable");
28export const StorageInstanceStatusSchema = Schema.Literal("active", "closed", "deleted");
29export const StorageProviderSchema = Schema.Literal("agentfs-local", "agentfs-cloudflare");
21export const OrganizationStatusSchema = Schema.Literal("active", "suspended", "archived");30export const OrganizationStatusSchema = Schema.Literal("active", "suspended", "archived");
22export const OrganizationMemberStatusSchema = Schema.Literal(31export const OrganizationMemberStatusSchema = Schema.Literal(
23 "invited",32 "invited",
@@ -36,6 +45,10 @@ export type TaskRunStatus = typeof TaskRunStatusSchema.Type; · 12 unmodified lines
36export type CredentialMode = typeof CredentialModeSchema.Type;45export type CredentialMode = typeof CredentialModeSchema.Type;
37export type CredentialProvider = typeof CredentialProviderSchema.Type;46export type CredentialProvider = typeof CredentialProviderSchema.Type;
38export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type;47export type CredentialScopeType = typeof CredentialScopeTypeSchema.Type;
48export type StorageScopeType = typeof StorageScopeTypeSchema.Type;
49export type StorageDurability = typeof StorageDurabilitySchema.Type;
50export type StorageInstanceStatus = typeof StorageInstanceStatusSchema.Type;
51export type StorageProvider = typeof StorageProviderSchema.Type;
39export type OrganizationStatus = typeof OrganizationStatusSchema.Type;52export type OrganizationStatus = typeof OrganizationStatusSchema.Type;
40export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type;53export type OrganizationMemberStatus = typeof OrganizationMemberStatusSchema.Type;
41export type SyncTarget = typeof SyncTargetSchema.Type;54export 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
18export const ApprovalIdSchema = Schema.String.pipe(Schema.brand("ApprovalId"));18export const ApprovalIdSchema = Schema.String.pipe(Schema.brand("ApprovalId"));
19export const TaskRunIdSchema = Schema.String.pipe(Schema.brand("TaskRunId"));19export const TaskRunIdSchema = Schema.String.pipe(Schema.brand("TaskRunId"));
20export const SyncStateIdSchema = Schema.String.pipe(Schema.brand("SyncStateId"));20export const SyncStateIdSchema = Schema.String.pipe(Schema.brand("SyncStateId"));
21export const StorageInstanceIdSchema = Schema.String.pipe(
22 Schema.brand("StorageInstanceId"),
23);
21export const EventIdSchema = Schema.String.pipe(Schema.brand("EventId"));24export const EventIdSchema = Schema.String.pipe(Schema.brand("EventId"));
22 25
23export type AccountId = typeof AccountIdSchema.Type;26export type AccountId = typeof AccountIdSchema.Type;
@@ -34,4 +37,5 @@ export type PolicyId = typeof PolicyIdSchema.Type; · 10 unmodified lines
34export type ApprovalId = typeof ApprovalIdSchema.Type;37export type ApprovalId = typeof ApprovalIdSchema.Type;
35export type TaskRunId = typeof TaskRunIdSchema.Type;38export type TaskRunId = typeof TaskRunIdSchema.Type;
36export type SyncStateId = typeof SyncStateIdSchema.Type;39export type SyncStateId = typeof SyncStateIdSchema.Type;
40export type StorageInstanceId = typeof StorageInstanceIdSchema.Type;
37export type EventId = typeof EventIdSchema.Type;41export type EventId = typeof EventIdSchema.Type;
v2/packages/schema/src/index.ts+1−0
@@ -12,6 +12,7 @@ export * from "./models/policy"; · 11 unmodified lines
12export * from "./models/approval";12export * from "./models/approval";
13export * from "./models/task-run";13export * from "./models/task-run";
14export * from "./models/sync-state";14export * from "./models/sync-state";
15export * from "./models/storage-instance";
15export * from "./models/event-envelope";16export * from "./models/event-envelope";
16export * from "./models/runtime-tool";17export * from "./models/runtime-tool";
17export * from "./models/auth";18export * from "./models/auth";
v2/packages/schema/src/models/auth/permission.ts+1−0
@@ -6,6 +6,7 @@ const permissionDomainActions = { · 5 unmodified lines
6 approvals: ["read", "resolve", "manage"],6 approvals: ["read", "resolve", "manage"],
7 runs: ["read", "cancel", "manage"],7 runs: ["read", "cancel", "manage"],
8 credentials: ["read", "write", "manage"],8 credentials: ["read", "write", "manage"],
9 storage: ["read", "write", "manage"],
9 organizations: ["read", "manage"],10 organizations: ["read", "manage"],
10 memberships: ["read", "write", "manage"],11 memberships: ["read", "write", "manage"],
11 workspace: ["read", "manage"],12 workspace: ["read", "manage"],
v2/packages/schema/src/models/storage-instance.ts+38−0
@@ -0,0 +1,38 @@
1import { Schema } from "effect";
2
3import { TimestampMsSchema } from "../common";
4import {
5 StorageDurabilitySchema,
6 StorageInstanceStatusSchema,
7 StorageProviderSchema,
8 StorageScopeTypeSchema,
9} from "../enums";
10import {
11 AccountIdSchema,
12 OrganizationIdSchema,
13 StorageInstanceIdSchema,
14 WorkspaceIdSchema,
15} from "../ids";
16
17export 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
38export 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
9 SchemaVersionSchema,9 SchemaVersionSchema,
10 SourceCredentialBindingSchema,10 SourceCredentialBindingSchema,
11 SourceSchema,11 SourceSchema,
12 StorageInstanceSchema,
12 SyncStateSchema,13 SyncStateSchema,
13 TaskRunSchema,14 TaskRunSchema,
14 TimestampMsSchema,15 TimestampMsSchema,
@@ -31,6 +32,7 @@ export const LocalStateSnapshotSchema = Schema.Struct({ · 16 unmodified lines
31 policies: Schema.Array(PolicySchema),32 policies: Schema.Array(PolicySchema),
32 approvals: Schema.Array(ApprovalSchema),33 approvals: Schema.Array(ApprovalSchema),
33 taskRuns: Schema.Array(TaskRunSchema),34 taskRuns: Schema.Array(TaskRunSchema),
35 storageInstances: Schema.Array(StorageInstanceSchema),
34 syncStates: Schema.Array(SyncStateSchema),36 syncStates: Schema.Array(SyncStateSchema),
35});37});
36 38
v2/packages/state-local-file/src/state-store.test.ts+1−0
@@ -74,6 +74,7 @@ describe("makeLocalStateStore", () => { · 73 unmodified lines
74 policies: [],74 policies: [],
75 approvals: [],75 approvals: [],
76 taskRuns: [],76 taskRuns: [],
77 storageInstances: [],
77 syncStates: [],78 syncStates: [],
78 });79 });
79 80