From 52fdc4be16a81798f926dbc2391db71a7b195896 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 10:59:29 -0700 Subject: [PATCH 01/28] improvement(api): pull in the v2 external endpoint surface Cherry-picks improvement/v2-endpoints (98c85677f5) onto the current base. The v2 surface standardizes one response family across every endpoint: `{ data }`, `{ data, nextCursor }`, and `{ error: { code, message, details? } }`, rendered through apps/sim/app/api/v2/lib/response.ts. v1 auth and rate limiting are reused as-is; the workspace-access and enterprise-audit checks are split into `resolve*` cores returning structured failures, with thin v1 wrappers that render the old `{ error }` body so v1 behavior is unchanged. The branch's own /api/v2/tables/** is dropped. Staging's tables v2 (#6067, typed predicate grammar + POST /api/v2/tables/[tableId]/query) supersedes it and lands in the following merge; the two are reconciled onto the shared envelope separately. Conflict resolutions: - v1/middleware.ts: keeps resolveWorkspaceRequestActor alongside the new resolveWorkspaceAccess/resolveWorkspaceScope split - v1/audit-logs/auth.ts: keeps the newer targetOrganizationId parameter and isOrganizationBillingBlocked check inside the structured resolver - bun.lock: taken from HEAD; the branch's lock churn was unrelated lucide-react hoisting Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .../docs/de/api-reference/getting-started.mdx | 2 +- .../content/docs/de/api-reference/meta.json | 9 +- .../(generated)/execution/meta.json | 3 + .../(generated)/workflows/meta.json | 6 +- .../docs/en/api-reference/getting-started.mdx | 2 +- .../content/docs/en/api-reference/meta.json | 8 +- .../en/platform/enterprise/audit-logs.mdx | 13 +- .../docs/es/api-reference/getting-started.mdx | 2 +- .../content/docs/es/api-reference/meta.json | 11 +- .../docs/fr/api-reference/getting-started.mdx | 2 +- .../content/docs/fr/api-reference/meta.json | 11 +- .../docs/ja/api-reference/getting-started.mdx | 2 +- .../content/docs/ja/api-reference/meta.json | 11 +- .../docs/zh/api-reference/getting-started.mdx | 2 +- .../content/docs/zh/api-reference/meta.json | 11 +- apps/docs/lib/openapi.ts | 80 +- apps/docs/openapi-core.json | 2948 +++++++++++++++++ apps/docs/openapi-v2-files-audit.json | 1125 +++++++ apps/docs/openapi-v2-knowledge.json | 1802 ++++++++++ apps/docs/openapi-v2-logs.json | 1065 ++++++ apps/docs/openapi-v2-tables.json | 2339 +++++++++++++ apps/docs/openapi-v2-workflows.json | 1024 ++++++ apps/sim/app/api/v1/admin/audit-logs/route.ts | 11 +- .../admin/organizations/[id]/billing/route.ts | 3 +- .../[id]/members/[memberId]/route.ts | 3 +- .../api/v1/admin/outbox/[id]/requeue/route.ts | 5 +- apps/sim/app/api/v1/admin/outbox/route.ts | 5 +- .../api/v1/admin/referral-campaigns/route.ts | 3 +- apps/sim/app/api/v1/audit-logs/auth.ts | 80 +- apps/sim/app/api/v1/logs/filters.ts | 12 +- apps/sim/app/api/v1/logs/route.ts | 2 +- apps/sim/app/api/v1/middleware.ts | 73 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 76 + apps/sim/app/api/v2/audit-logs/route.ts | 103 + apps/sim/app/api/v2/files/[fileId]/route.ts | 124 + apps/sim/app/api/v2/files/route.ts | 236 ++ .../[id]/documents/[documentId]/route.ts | 209 ++ .../api/v2/knowledge/[id]/documents/route.ts | 306 ++ apps/sim/app/api/v2/knowledge/[id]/route.ts | 193 ++ apps/sim/app/api/v2/knowledge/route.ts | 140 + apps/sim/app/api/v2/knowledge/search/route.ts | 299 ++ apps/sim/app/api/v2/lib/response.ts | 144 + apps/sim/app/api/v2/logs/[id]/route.ts | 109 + .../v2/logs/executions/[executionId]/route.ts | 74 + apps/sim/app/api/v2/logs/route.ts | 168 + .../app/api/v2/workflows/[id]/deploy/route.ts | 169 + .../api/v2/workflows/[id]/rollback/route.ts | 122 + apps/sim/app/api/v2/workflows/[id]/route.ts | 81 + apps/sim/app/api/v2/workflows/route.ts | 142 + .../api/contracts/v1/admin/organizations.ts | 9 +- apps/sim/lib/api/contracts/v1/audit-logs.ts | 70 +- apps/sim/lib/api/contracts/v1/shared.ts | 44 + apps/sim/lib/api/contracts/v2/audit-logs.ts | 58 + apps/sim/lib/api/contracts/v2/files.ts | 112 + apps/sim/lib/api/contracts/v2/knowledge.ts | 270 ++ apps/sim/lib/api/contracts/v2/logs.ts | 123 + apps/sim/lib/api/contracts/v2/shared.ts | 39 + apps/sim/lib/api/contracts/v2/workflows.ts | 112 + .../orchestration/file-folder-lifecycle.ts | 10 +- 59 files changed, 14070 insertions(+), 147 deletions(-) create mode 100644 apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json create mode 100644 apps/docs/openapi-core.json create mode 100644 apps/docs/openapi-v2-files-audit.json create mode 100644 apps/docs/openapi-v2-knowledge.json create mode 100644 apps/docs/openapi-v2-logs.json create mode 100644 apps/docs/openapi-v2-tables.json create mode 100644 apps/docs/openapi-v2-workflows.json create mode 100644 apps/sim/app/api/v2/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/audit-logs/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.ts create mode 100644 apps/sim/app/api/v2/files/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/documents/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/[id]/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/route.ts create mode 100644 apps/sim/app/api/v2/knowledge/search/route.ts create mode 100644 apps/sim/app/api/v2/lib/response.ts create mode 100644 apps/sim/app/api/v2/logs/[id]/route.ts create mode 100644 apps/sim/app/api/v2/logs/executions/[executionId]/route.ts create mode 100644 apps/sim/app/api/v2/logs/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/deploy/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/rollback/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/route.ts create mode 100644 apps/sim/app/api/v2/workflows/route.ts create mode 100644 apps/sim/lib/api/contracts/v1/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/audit-logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/files.ts create mode 100644 apps/sim/lib/api/contracts/v2/knowledge.ts create mode 100644 apps/sim/lib/api/contracts/v2/logs.ts create mode 100644 apps/sim/lib/api/contracts/v2/shared.ts create mode 100644 apps/sim/lib/api/contracts/v2/workflows.ts diff --git a/apps/docs/content/docs/de/api-reference/getting-started.mdx b/apps/docs/content/docs/de/api-reference/getting-started.mdx index 25c8cfdbf2e..7e94ab0d7bd 100644 --- a/apps/docs/content/docs/de/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/de/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d8a1fb142c6..74cedc72725 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,9 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", - "(generated)/files" + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json new file mode 100644 index 00000000000..52458d430c3 --- /dev/null +++ b/apps/docs/content/docs/en/api-reference/(generated)/execution/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["executeWorkflow", "getWorkflowExecution", "cancelExecution", "getJobStatus"] +} diff --git a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json index 8e2caa1abe8..ca2603a1d54 100644 --- a/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json +++ b/apps/docs/content/docs/en/api-reference/(generated)/workflows/meta.json @@ -1,15 +1,11 @@ { "pages": [ - "executeWorkflow", - "getWorkflowExecution", - "cancelExecution", "listWorkflows", "getWorkflow", "exportWorkflow", "importWorkflow", "deployWorkflow", "undeployWorkflow", - "rollbackWorkflow", - "getJobStatus" + "rollbackWorkflow" ] } diff --git a/apps/docs/content/docs/en/api-reference/getting-started.mdx b/apps/docs/content/docs/en/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/en/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/en/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index c99ab8eb13f..74cedc72725 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -10,12 +10,14 @@ "typescript", "---Endpoints---", "(generated)/workflows", - "(generated)/human-in-the-loop", "(generated)/logs", - "(generated)/usage", "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/knowledge-bases" + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx index 9bcf9dfb0ed..b9d039c2a56 100644 --- a/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/audit-logs.mdx @@ -33,7 +33,7 @@ Audit logs are also accessible through the Sim API for integration with external ```http GET /api/v1/audit-logs -Authorization: Bearer +X-API-Key: ``` **Query parameters:** @@ -71,11 +71,18 @@ Authorization: Bearer "createdAt": "2026-04-20T21:16:00.000Z" } ], - "nextCursor": "eyJpZCI6ImFiYzEyMyJ9" + "nextCursor": "eyJpZCI6ImFiYzEyMyJ9", + "limits": { + "workflowExecutionRateLimit": { + "sync": { "requestsPerMinute": 60, "maxBurst": 10, "remaining": 59, "resetAt": "2026-04-20T21:17:00.000Z" }, + "async": { "requestsPerMinute": 30, "maxBurst": 5, "remaining": 30, "resetAt": "2026-04-20T21:17:00.000Z" } + }, + "usage": { "currentPeriodCost": 1.25, "limit": 50, "plan": "enterprise", "isExceeded": false } + } } ``` -Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. +Paginate by passing the `nextCursor` value as the `cursor` parameter in the next request. When `nextCursor` is absent, you have reached the last page. Each entry also includes `actorName`; `metadata` is an arbitrary per-action JSON object. The `limits` object reports your current rate-limit and usage status. The API accepts both personal and workspace-scoped API keys. Rate limits apply — the response includes `X-RateLimit-*` headers with your current limit and remaining quota. diff --git a/apps/docs/content/docs/es/api-reference/getting-started.mdx b/apps/docs/content/docs/es/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/es/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/es/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/fr/api-reference/getting-started.mdx b/apps/docs/content/docs/fr/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/fr/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/fr/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/ja/api-reference/getting-started.mdx b/apps/docs/content/docs/ja/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/ja/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/ja/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/content/docs/zh/api-reference/getting-started.mdx b/apps/docs/content/docs/zh/api-reference/getting-started.mdx index 038998853cf..c8093e72c14 100644 --- a/apps/docs/content/docs/zh/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/zh/api-reference/getting-started.mdx @@ -121,7 +121,7 @@ This returns immediately with a `jobId` and `statusUrl`: } ``` -Poll the [Get Job Status](/api-reference/workflows/getJobStatus) endpoint until the status is `completed` or `failed`: +Poll the [Get Job Status](/api-reference/execution/getJobStatus) endpoint until the status is `completed` or `failed`: ```bash curl https://www.sim.ai/api/jobs/{jobId} \ diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index c96dc5d2edc..74cedc72725 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -2,6 +2,7 @@ "title": "API Reference", "root": true, "pages": [ + "---Getting Started---", "getting-started", "authentication", "---SDKs---", @@ -10,7 +11,13 @@ "---Endpoints---", "(generated)/workflows", "(generated)/logs", - "(generated)/usage", - "(generated)/audit-logs" + "(generated)/audit-logs", + "(generated)/tables", + "(generated)/files", + "(generated)/knowledge-bases", + "---Execution and Usage---", + "(generated)/execution", + "(generated)/human-in-the-loop", + "(generated)/usage" ] } diff --git a/apps/docs/lib/openapi.ts b/apps/docs/lib/openapi.ts index af5f7a2b4c8..41f0687139a 100644 --- a/apps/docs/lib/openapi.ts +++ b/apps/docs/lib/openapi.ts @@ -2,8 +2,17 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createOpenAPI } from 'fumadocs-openapi/server' +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-logs.json', + 'openapi-v2-workflows.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] as const + export const openapi = createOpenAPI({ - input: ['./openapi.json'], + input: SPEC_FILES.map((file) => `./${file}`), }) interface OpenAPIOperation { @@ -24,20 +33,34 @@ function resolveRef(ref: string, spec: Record): unknown { return current } -function resolveRefs(obj: unknown, spec: Record, depth = 0): unknown { - if (depth > 10) return obj +function resolveRefs( + obj: unknown, + spec: Record, + seen: Set = new Set(), + depth = 0 +): unknown { + // Generous backstop against pathological fan-out; real schemas nest far shallower. + if (depth > 50) return obj if (Array.isArray(obj)) { - return obj.map((item) => resolveRefs(item, spec, depth + 1)) + return obj.map((item) => resolveRefs(item, spec, seen, depth + 1)) } if (obj && typeof obj === 'object') { const record = obj as Record - if ('$ref' in record && typeof record.$ref === 'string') { - const resolved = resolveRef(record.$ref, spec) - return resolveRefs(resolved, spec, depth + 1) + if (typeof record.$ref === 'string') { + const ref = record.$ref + // Break reference cycles: if this $ref is already being expanded above us, + // leave it untouched instead of recursing forever. + if (seen.has(ref)) return record + const resolved = resolveRef(ref, spec) + if (resolved === undefined) return record + seen.add(ref) + const out = resolveRefs(resolved, spec, seen, depth + 1) + seen.delete(ref) + return out } const result: Record = {} for (const [key, value] of Object.entries(record)) { - result[key] = resolveRefs(value, spec, depth + 1) + result[key] = resolveRefs(value, spec, seen, depth + 1) } return result } @@ -48,14 +71,34 @@ function formatSchema(schema: unknown): string { return JSON.stringify(schema, null, 2) } -let cachedSpec: Record | null = null +let cachedSpecs: Record[] | null = null + +function getSpecs(): Record[] { + if (!cachedSpecs) { + cachedSpecs = SPEC_FILES.map( + (file) => + JSON.parse(readFileSync(join(process.cwd(), file), 'utf8')) as Record + ) + } + return cachedSpecs +} -function getSpec(): Record { - if (!cachedSpec) { - const specPath = join(process.cwd(), 'openapi.json') - cachedSpec = JSON.parse(readFileSync(specPath, 'utf8')) as Record +/** + * Locate an operation by path + method across every rendered spec, returning the + * operation together with the spec that owns it so `$ref`s resolve within the + * correct document (each spec carries its own `components`). + */ +function findOperation( + path: string, + method: string +): { operation: Record; spec: Record } | undefined { + const key = method.toLowerCase() + for (const spec of getSpecs()) { + const pathObj = (spec.paths as Record> | undefined)?.[path] + const operation = pathObj?.[key] as Record | undefined + if (operation) return { operation, spec } } - return cachedSpec + return undefined } export function getApiSpecContent( @@ -63,22 +106,19 @@ export function getApiSpecContent( description: string | undefined, operations: OpenAPIOperation[] ): string { - const spec = getSpec() - if (!operations || operations.length === 0) { return `# ${title}\n\n${description || ''}` } const op = operations[0] const method = op.method.toUpperCase() - const pathObj = (spec.paths as Record>)?.[op.path] - const operation = pathObj?.[op.method.toLowerCase()] as Record | undefined + const found = findOperation(op.path, op.method) - if (!operation) { + if (!found) { return `# ${title}\n\n${description || ''}` } - const resolved = resolveRefs(operation, spec) as Record + const resolved = resolveRefs(found.operation, found.spec) as Record const lines: string[] = [] lines.push(`# ${title}`) diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json new file mode 100644 index 00000000000..53a99c2e866 --- /dev/null +++ b/apps/docs/openapi-core.json @@ -0,0 +1,2948 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API — Execution & Usage", + "description": "Run workflows, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", + "version": "1.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Execution", + "description": "Run workflows, poll execution status, and cancel runs" + }, + { + "name": "Human in the Loop", + "description": "Manage paused workflow executions and resume them with input" + }, + { + "name": "Usage", + "description": "Check rate limits and billing usage" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/workflows/{id}/execute": { + "post": { + "operationId": "executeWorkflow", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow. Supports synchronous, asynchronous, and streaming modes. For async execution, the response includes a statusUrl you can poll for results.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/execute\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"key\": \"value\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the deployed workflow to execute.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + ], + "requestBody": { + "description": "Execution configuration including input values and execution mode options.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs matching the workflow's defined input fields. Use the Get Workflow endpoint to discover available input fields.", + "additionalProperties": true + }, + "triggerType": { + "type": "string", + "description": "How this execution was triggered. Defaults to api when called via the REST API. Recorded in execution logs for filtering." + }, + "stream": { + "type": "boolean", + "description": "When true, returns results as Server-Sent Events (SSE) for real-time block-by-block output streaming." + }, + "selectedOutputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific block IDs whose outputs to include in the response. When omitted, all block outputs are returned." + } + } + }, + "example": { + "input": { + "query": "What is the weather in Tokyo?" + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous execution completed successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionResult" + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74", + "output": { + "content": "The weather in Tokyo is sunny, 22°C." + }, + "error": null, + "metadata": { + "startTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:30:01Z", + "duration": 1250 + } + } + } + } + }, + "202": { + "description": "Asynchronous execution has been queued. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}": { + "get": { + "operationId": "getWorkflowExecution", + "summary": "Get Execution Status", + "description": "Get the current status of a workflow execution. Returns the run's lifecycle state (`running`, `paused`, `completed`, `failed`, etc.), timing, error, and optionally per-block outputs. Designed for polling — works for any execution, including ones that pause and resume.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + }, + { + "id": "curl-with-outputs", + "label": "cURL (with block outputs)", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}?selectedOutputs=blockId,blockId.field&includeOutput=true\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "When `true` and the execution has `status: completed`, include the workflow's final output in the response.", + "schema": { + "type": "string", + "enum": ["true", "false"] + } + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", + "schema": { + "type": "string", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + } + } + ], + "responses": { + "200": { + "description": "Execution status returned.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExecutionStatus" + }, + "examples": { + "completed": { + "summary": "Completed run", + "value": { + "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "completed", + "trigger": "api", + "level": "info", + "startedAt": "2026-05-15T19:43:12.189Z", + "endedAt": "2026-05-15T19:45:45.224Z", + "totalDurationMs": 153035, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "paused": { + "summary": "Currently paused run", + "value": { + "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "paused", + "trigger": "manual", + "level": "info", + "startedAt": "2026-05-15T22:25:57.178Z", + "endedAt": "2026-05-15T22:25:57.215Z", + "totalDurationMs": 1, + "paused": { + "pausedAt": "2026-05-15T22:25:57.216Z", + "resumeAt": "2026-05-16T18:25:57.200Z", + "pauseKind": "time", + "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", + "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "pausePointCount": 1, + "resumedCount": 0 + }, + "cost": { + "total": 0.005 + }, + "error": null, + "finalOutput": null, + "blockOutputs": null + } + }, + "failed": { + "summary": "Failed run", + "value": { + "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", + "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "status": "failed", + "trigger": "api", + "level": "error", + "startedAt": "2026-05-15T22:24:50.991Z", + "endedAt": "2026-05-15T22:24:50.999Z", + "totalDurationMs": 2, + "paused": null, + "cost": { + "total": 0.005 + }, + "error": "Wait 1: Wait time exceeds maximum of 5 minutes; enable async mode to wait up to 30 days", + "finalOutput": null, + "blockOutputs": null + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/executions/{executionId}/cancel": { + "post": { + "operationId": "cancelExecution", + "summary": "Cancel Execution", + "description": "Cancel a running workflow execution. Only effective for executions that are still in progress.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/workflows/{id}/executions/{executionId}/cancel\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique identifier of the execution to cancel.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Execution was successfully cancelled.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the cancellation was successful." + }, + "executionId": { + "type": "string", + "description": "The ID of the cancelled execution." + } + } + }, + "example": { + "success": true, + "executionId": "c7a92e15-3f4b-4d8c-a1e6-9b0d5f2c8e74" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/jobs/{jobId}": { + "get": { + "operationId": "getJobStatus", + "summary": "Get Job Status", + "description": "Poll the status of an asynchronous workflow execution. Use the jobId returned from the Execute Workflow endpoint when the execution is queued asynchronously.", + "tags": ["Execution"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/jobs/{jobId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "The job identifier returned in the async execution response.", + "schema": { + "type": "string", + "example": "job_4a3b2c1d0e" + } + } + ], + "responses": { + "200": { + "description": "Current status of the job. When completed, includes the execution output.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatus" + }, + "example": { + "success": true, + "taskId": "job_abc123", + "status": "completed", + "output": { + "content": "Done" + }, + "metadata": { + "startTime": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused": { + "get": { + "operationId": "listPausedExecutions", + "summary": "List Paused Executions", + "description": "List all paused executions for a workflow. Workflows pause at Human in the Loop blocks and wait for input before continuing. Use this endpoint to discover which executions need attention.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused?status=paused\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter paused executions by status.", + "schema": { + "type": "string", + "example": "paused" + } + } + ], + "responses": { + "200": { + "description": "List of paused executions.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pausedExecutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausedExecutionSummary" + } + } + } + }, + "example": { + "pausedExecutions": [ + { + "id": "pe_abc123", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "status": "paused", + "totalPauseCount": 1, + "resumedCount": 0, + "pausedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "expiresAt": null, + "metadata": null, + "triggerIds": [], + "pausePoints": [ + { + "contextId": "ctx_xyz789", + "blockId": "block_hitl_1", + "registeredAt": "2026-01-15T10:30:00Z", + "resumeStatus": "paused", + "snapshotReady": true, + "resumeLinks": { + "apiUrl": "https://www.sim.ai/api/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13/ctx_xyz789", + "uiUrl": "https://www.sim.ai/resume/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "contextId": "ctx_xyz789", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "response": { + "displayData": { + "title": "Approval Required", + "message": "Please review this request" + }, + "formFields": [] + } + } + ] + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/workflows/{id}/paused/{executionId}": { + "get": { + "operationId": "getPausedExecution", + "summary": "Get Paused Execution", + "description": "Get detailed information about a specific paused execution, including its pause points, execution snapshot, and resume queue. Use this to inspect the state before resuming.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/workflows/{id}/paused/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/resume/{workflowId}/{executionId}": { + "get": { + "operationId": "getPausedExecutionByResumePath", + "summary": "Get Paused Execution (Resume Path)", + "description": "Get detailed information about a specific paused execution using the resume URL path. Returns the same data as the workflow paused execution detail endpoint.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "Paused execution details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PausedExecutionDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + } + } + } + }, + "/api/resume/{workflowId}/{executionId}/{contextId}": { + "get": { + "operationId": "getPauseContext", + "summary": "Get Pause Context", + "description": "Get detailed information about a specific pause context within a paused execution. Returns the pause point details, resume queue state, and any active resume entry.", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to retrieve details for.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "responses": { + "200": { + "description": "Pause context details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PauseContextDetail" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "resumeExecution", + "summary": "Resume Execution", + "description": "Resume a paused workflow execution by providing input for a specific pause context. The execution continues from where it paused, using the provided input. Supports synchronous, asynchronous, and streaming modes (determined by the original execution's configuration).", + "tags": ["Human in the Loop"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/resume/{workflowId}/{executionId}/{contextId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"input\": {\n \"approved\": true,\n \"comment\": \"Looks good to me\"\n }\n }'" + } + ], + "parameters": [ + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "The unique identifier of the workflow.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + }, + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The execution ID of the paused execution.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + }, + { + "name": "contextId", + "in": "path", + "required": true, + "description": "The pause context ID to resume. Found in the pause point's contextId field or resumeLinks.", + "schema": { + "type": "string", + "example": "ctx_xyz789" + } + } + ], + "requestBody": { + "description": "Input data for the resumed execution. The structure depends on the workflow's Human in the Loop block configuration.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "input": { + "type": "object", + "description": "Key-value pairs to pass as input to the resumed execution. If omitted, the entire request body is used as input.", + "additionalProperties": true + } + } + }, + "example": { + "input": { + "approved": true, + "comment": "Looks good to me" + } + } + } + } + }, + "responses": { + "200": { + "description": "Resume execution completed synchronously, or resume was queued behind another in-progress resume.", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ResumeResult" + }, + { + "type": "object", + "description": "Resume has been queued behind another in-progress resume.", + "properties": { + "status": { + "type": "string", + "enum": ["queued"], + "description": "Indicates the resume is queued." + }, + "executionId": { + "type": "string", + "description": "The execution ID assigned to this resume." + }, + "queuePosition": { + "type": "integer", + "description": "Position in the resume queue." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + }, + { + "type": "object", + "description": "Resume execution started (non-API-key callers). The execution runs asynchronously.", + "properties": { + "status": { + "type": "string", + "enum": ["started"], + "description": "Indicates the resume execution has started." + }, + "executionId": { + "type": "string", + "description": "The execution ID for the resumed workflow." + }, + "message": { + "type": "string", + "description": "Human-readable status message." + } + } + } + ] + }, + "examples": { + "sync": { + "summary": "Synchronous completion", + "value": { + "success": true, + "status": "completed", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "output": { + "result": "Approved and processed" + }, + "error": null, + "metadata": { + "duration": 850, + "startTime": "2026-01-15T10:35:00Z", + "endTime": "2026-01-15T10:35:01Z" + } + } + }, + "queued": { + "summary": "Queued behind another resume", + "value": { + "status": "queued", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "queuePosition": 2, + "message": "Resume queued. It will run after current resumes finish." + } + }, + "started": { + "summary": "Execution started (fire and forget)", + "value": { + "status": "started", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution started." + } + } + } + } + } + }, + "202": { + "description": "Resume execution has been queued for asynchronous processing. Poll the statusUrl for results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncExecutionResult" + }, + "example": { + "success": true, + "async": true, + "jobId": "job_4a3b2c1d0e", + "executionId": "f0b3d8c2-7e5a-4b9d-8c1f-6a4e2d0b9c58", + "message": "Resume execution queued", + "statusUrl": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "503": { + "description": "Failed to queue the resume execution. Retry the request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message." + } + } + } + } + } + } + } + } + }, + "/api/users/me/usage-limits": { + "get": { + "operationId": "getUsageLimits", + "summary": "Get Usage Limits", + "description": "Retrieve your current rate limits, usage spending, and storage consumption for the billing period.", + "tags": ["Usage"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/users/me/usage-limits\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "Current rate limits, usage, and storage information.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageLimits" + }, + "example": { + "success": true, + "rateLimit": { + "sync": { + "limit": 100, + "remaining": 95, + "reset": "2026-01-15T11:00:00Z" + }, + "async": { + "limit": 50, + "remaining": 48, + "reset": "2026-01-15T11:00:00Z" + } + }, + "usage": { + "currentPeriodCost": 12.5, + "limit": 100, + "plan": "pro" + }, + "storage": { + "usedBytes": 5242880, + "limitBytes": 1073741824, + "percentUsed": 0.49 + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The unique identifier of the workspace." + } + }, + "schemas": { + "ColumnDefinition": { + "type": "object", + "description": "Definition of a table column including its type and constraints.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Column name. Must start with a letter or underscore.", + "example": "email", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert.", + "default": false + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows.", + "default": false + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed schema.", + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": "string", + "description": "Optional description of the table.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ColumnDefinition" + }, + "description": "Array of column definitions for the table." + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "TableRow": { + "type": "object", + "description": "A single row in a table.", + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "type": "object", + "additionalProperties": true, + "description": "Row data as key-value pairs matching the table schema." + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "WorkflowSummary": { + "type": "object", + "description": "Summary representation of a workflow returned in list operations.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation including input field definitions and configuration.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. null if at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. null if never deployed.", + "example": "2025-06-15T10:30:00Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. null if never run.", + "example": "2025-06-20T14:15:22Z" + }, + "variables": { + "type": "object", + "description": "Workflow-level variables and their current values.", + "example": {} + }, + "inputs": { + "type": "object", + "description": "The workflow's input field definitions. Use these to construct the input object when executing the workflow.", + "properties": { + "fields": { + "type": "object", + "description": "Map of field names to their type definitions and configuration.", + "additionalProperties": true, + "example": {} + } + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "WorkflowDeployment": { + "type": "object", + "description": "Deployment state of a workflow after a deploy, undeploy, or rollback operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. null after an undeploy.", + "example": "2026-06-12T10:30:00Z" + }, + "version": { + "type": "integer", + "description": "The deployment version that is now active. Omitted for undeploy.", + "example": 4 + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy." + } + } + }, + "ExecutionResult": { + "type": "object", + "description": "Result of a synchronous workflow execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the workflow executed successfully without errors.", + "example": true + }, + "executionId": { + "type": "string", + "description": "Unique identifier for this execution. Use this to query logs or cancel the execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "output": { + "type": "object", + "description": "Workflow output keyed by block name and output field. Structure depends on the workflow's block configuration.", + "additionalProperties": true, + "example": { + "result": "Hello, world!" + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed. null on success.", + "example": null + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + } + } + } + } + }, + "AsyncExecutionResult": { + "type": "object", + "description": "Response returned when a workflow execution is queued for asynchronous processing.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the execution was successfully queued.", + "example": true + }, + "async": { + "type": "boolean", + "description": "Always true for async executions. Use this to distinguish from synchronous responses.", + "example": true + }, + "jobId": { + "type": "string", + "description": "Internal job queue identifier for tracking the execution.", + "example": "job_4a3b2c1d0e" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier. Use this to query execution status or cancel.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "message": { + "type": "string", + "description": "Human-readable status message (e.g., \"Execution queued\").", + "example": "Execution queued" + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "URL to poll for execution status and results. Returns the full execution result once complete.", + "example": "https://www.sim.ai/api/jobs/job_4a3b2c1d0e" + } + } + }, + "LogEntry": { + "type": "object", + "description": "Summary of a single workflow execution log entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "cost": { + "type": "object", + "description": "Cost summary for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + } + } + }, + "files": { + "type": "object", + "nullable": true, + "description": "File outputs produced during execution. null if no files were generated.", + "example": null + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full execution data, workflow metadata, and cost breakdown.", + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": "string", + "description": "The workflow that was executed.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, manual, webhook, schedule, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2025-06-20T14:15:22Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed.", + "example": "2025-06-20T14:15:23Z" + }, + "totalDurationMs": { + "type": "integer", + "description": "Total execution duration in milliseconds.", + "example": 1250 + }, + "workflow": { + "type": "object", + "description": "Summary metadata about the workflow at the time of execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name at the time of execution.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Workflow description at the time of execution.", + "example": "Routes incoming support tickets and drafts responses" + } + } + }, + "executionData": { + "type": "object", + "description": "Detailed execution data including block-level traces and final output.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs for each block that ran.", + "items": { + "type": "object" + } + }, + "finalOutput": { + "type": "object", + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "type": "object", + "description": "Detailed cost breakdown for this execution.", + "properties": { + "total": { + "type": "number", + "description": "Total cost of this execution in USD.", + "example": 0.0032 + }, + "tokens": { + "type": "object", + "description": "Aggregate token usage across all AI model calls in this execution.", + "properties": { + "prompt": { + "type": "integer", + "description": "Total prompt (input) tokens consumed.", + "example": 450 + }, + "completion": { + "type": "integer", + "description": "Total completion (output) tokens generated.", + "example": 120 + }, + "total": { + "type": "integer", + "description": "Total tokens (prompt + completion).", + "example": 570 + } + } + }, + "models": { + "type": "object", + "description": "Per-model cost and token breakdown. Keys are model identifiers (e.g., gpt-4o, claude-sonnet-4-20250514).", + "additionalProperties": { + "type": "object", + "description": "Cost and token details for a specific model.", + "properties": { + "input": { + "type": "number", + "description": "Cost of prompt tokens for this model in USD." + }, + "output": { + "type": "number", + "description": "Cost of completion tokens for this model in USD." + }, + "total": { + "type": "number", + "description": "Total cost for this model in USD." + }, + "tokens": { + "type": "object", + "description": "Token usage for this specific model.", + "properties": { + "prompt": { + "type": "integer", + "description": "Prompt tokens consumed by this model." + }, + "completion": { + "type": "integer", + "description": "Completion tokens generated by this model." + }, + "total": { + "type": "integer", + "description": "Total tokens for this model." + } + } + } + } + } + } + } + } + } + }, + "Limits": { + "type": "object", + "description": "Rate limit and usage information included in every API response.", + "properties": { + "workflowExecutionRateLimit": { + "type": "object", + "description": "Current rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "$ref": "#/components/schemas/RateLimitBucket" + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage and plan limits.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD.", + "example": 1.25 + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD.", + "example": 50 + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team).", + "example": "pro" + }, + "isExceeded": { + "type": "boolean", + "description": "Whether the usage limit has been exceeded. Executions may be blocked when true.", + "example": false + } + } + } + } + }, + "RateLimitBucket": { + "type": "object", + "description": "Rate limit status for a specific execution type.", + "properties": { + "requestsPerMinute": { + "type": "integer", + "description": "Maximum number of requests allowed per minute.", + "example": 60 + }, + "maxBurst": { + "type": "integer", + "description": "Maximum number of concurrent requests allowed in a burst.", + "example": 10 + }, + "remaining": { + "type": "integer", + "description": "Number of requests remaining in the current rate limit window.", + "example": 59 + }, + "resetAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the rate limit window resets.", + "example": "2025-06-20T14:16:00Z" + } + } + }, + "JobStatus": { + "type": "object", + "description": "Status of an asynchronous job.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful.", + "example": true + }, + "taskId": { + "type": "string", + "description": "The unique identifier of the job.", + "example": "job_4a3b2c1d0e" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "completed", "failed"], + "description": "Current status of the job.", + "example": "completed" + }, + "metadata": { + "type": "object", + "description": "Timing metadata for the job.", + "properties": { + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job started processing.", + "example": "2025-06-20T14:15:22Z" + }, + "completedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the job completed. Present only when status is completed or failed.", + "example": "2025-06-20T14:15:23Z" + }, + "duration": { + "type": "integer", + "description": "Duration of the job in milliseconds. Present only when status is completed or failed.", + "example": 1250 + } + } + }, + "output": { + "description": "The workflow execution output. Present only when status is completed.", + "type": "object", + "example": { + "result": "Hello, world!" + } + }, + "error": { + "description": "Error details. Present only when status is failed.", + "type": "string", + "example": null + }, + "estimatedDuration": { + "type": "integer", + "description": "Estimated duration in milliseconds. Present only when status is queued or processing.", + "example": 2000 + } + } + }, + "WorkflowExecutionStatus": { + "type": "object", + "description": "Current status of a workflow execution.", + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier of the execution.", + "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + }, + "workflowId": { + "type": "string", + "description": "The unique identifier of the workflow.", + "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + }, + "status": { + "type": "string", + "enum": ["pending", "running", "paused", "completed", "failed", "cancelled"], + "description": "Current normalized lifecycle status. `paused` is set when a row exists in pausedExecutions with status `paused` or `partially_resumed`; otherwise the workflowExecutionLogs row's status field is used.", + "example": "completed" + }, + "trigger": { + "type": "string", + "enum": ["api", "manual", "schedule", "webhook", "chat"], + "description": "What triggered the execution.", + "example": "api" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Log level of the execution.", + "example": "info" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-05-15T19:43:12.189Z" + }, + "endedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp when execution ended. Null while the run is in flight.", + "example": "2026-05-15T19:45:45.224Z" + }, + "totalDurationMs": { + "type": "integer", + "nullable": true, + "description": "Total duration of the execution in milliseconds. Null while the run is in flight.", + "example": 153035 + }, + "paused": { + "type": "object", + "nullable": true, + "description": "Pause-state details. Present only when status is `paused`.", + "properties": { + "pausedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was paused.", + "example": "2026-05-15T22:25:57.216Z" + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Earliest scheduled resume time across active pause points. Null for human-only pauses.", + "example": "2026-05-16T18:25:57.200Z" + }, + "pauseKind": { + "type": "string", + "enum": ["time", "human"], + "nullable": true, + "description": "What kind of pause the workflow is waiting on.", + "example": "time" + }, + "blockedOnBlockId": { + "type": "string", + "nullable": true, + "description": "The block currently blocking resume.", + "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + }, + "pausedExecutionId": { + "type": "string", + "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", + "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + }, + "pausePointCount": { + "type": "integer", + "description": "Total number of pause points recorded for this execution.", + "example": 1 + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points already resumed.", + "example": 0 + } + } + }, + "cost": { + "type": "object", + "nullable": true, + "description": "Cost summary. Detailed token / model breakdown lives on the /v1/logs detail endpoint.", + "properties": { + "total": { + "type": "number", + "description": "Total cost in USD.", + "example": 0.005 + } + } + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message. Present only when status is `failed`.", + "example": null + }, + "finalOutput": { + "type": "object", + "nullable": true, + "description": "The workflow's final output. Returned only when ?includeOutput=true AND status is `completed`.", + "example": null + }, + "blockOutputs": { + "type": "object", + "nullable": true, + "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", + "additionalProperties": true, + "example": { + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, + "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + } + } + } + }, + "AuditLogEntry": { + "type": "object", + "description": "An enterprise audit log entry recording an action taken in the workspace.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": "string", + "nullable": true, + "description": "The workspace where the action occurred.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": "string", + "nullable": true, + "description": "The user ID of the person who performed the action.", + "example": "user_abc123" + }, + "actorName": { + "type": "string", + "nullable": true, + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": "string", + "nullable": true, + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., workflow.created, member.invited).", + "example": "workflow.deployed" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., workflow, workspace, member).", + "example": "workflow" + }, + "resourceId": { + "type": "string", + "nullable": true, + "description": "The unique identifier of the affected resource.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "resourceName": { + "type": "string", + "nullable": true, + "description": "Display name of the affected resource.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Human-readable description of the action.", + "example": "Deployed workflow Customer Support Agent" + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional context about the action.", + "example": null + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2025-06-20T14:15:22Z" + } + } + }, + "UsageLimits": { + "type": "object", + "description": "Current rate limits, usage, and storage information for the authenticated user.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful." + }, + "rateLimit": { + "type": "object", + "description": "Rate limit status for workflow executions.", + "properties": { + "sync": { + "description": "Rate limit bucket for synchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "async": { + "description": "Rate limit bucket for asynchronous executions.", + "allOf": [ + { + "$ref": "#/components/schemas/RateLimitBucket" + }, + { + "type": "object", + "properties": { + "isLimited": { + "type": "boolean", + "description": "Whether the rate limit has been reached." + } + } + } + ] + }, + "authType": { + "type": "string", + "description": "The authentication type used (api or manual)." + } + } + }, + "usage": { + "type": "object", + "description": "Current billing period usage.", + "properties": { + "currentPeriodCost": { + "type": "number", + "description": "Total spend in the current billing period in USD." + }, + "limit": { + "type": "number", + "description": "Maximum allowed spend for the current billing period in USD." + }, + "plan": { + "type": "string", + "description": "Your current subscription plan (e.g., free, pro, team)." + } + } + }, + "storage": { + "type": "object", + "description": "File storage usage.", + "properties": { + "usedBytes": { + "type": "integer", + "description": "Total storage used in bytes." + }, + "limitBytes": { + "type": "integer", + "description": "Maximum storage allowed in bytes." + }, + "percentUsed": { + "type": "number", + "description": "Percentage of storage used (0-100)." + } + } + } + } + }, + "FileMetadata": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/abc-123/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader." + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded." + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base for storing and searching document embeddings.", + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier." + }, + "name": { + "type": "string", + "description": "Knowledge base name." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count across all documents." + }, + "embeddingModel": { + "type": "string", + "description": "Embedding model used (e.g. text-embedding-3-small)." + }, + "embeddingDimension": { + "type": "integer", + "description": "Embedding vector dimension." + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base." + }, + "connectorTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Types of connectors attached to this knowledge base." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified." + } + } + }, + "ChunkingConfig": { + "type": "object", + "description": "Configuration for how documents are split into chunks for embedding.", + "properties": { + "maxSize": { + "type": "integer", + "minimum": 100, + "maximum": 4000, + "default": 1024, + "description": "Maximum chunk size in tokens." + }, + "minSize": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 100, + "description": "Minimum chunk size in characters." + }, + "overlap": { + "type": "integer", + "minimum": 0, + "maximum": 500, + "default": 200, + "description": "Overlap between chunks in tokens." + } + } + }, + "KnowledgeDocument": { + "type": "object", + "description": "A document in a knowledge base.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created from this document." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "KnowledgeDocumentDetail": { + "type": "object", + "description": "Detailed document information including processing and connector details.", + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier." + }, + "knowledgeBaseId": { + "type": "string", + "description": "Knowledge base this document belongs to." + }, + "filename": { + "type": "string", + "description": "Original filename." + }, + "fileSize": { + "type": "integer", + "description": "File size in bytes." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file." + }, + "processingStatus": { + "type": "string", + "enum": ["pending", "processing", "completed", "failed"], + "description": "Current processing status." + }, + "processingError": { + "type": "string", + "nullable": true, + "description": "Error message if processing failed." + }, + "processingStartedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing started." + }, + "processingCompletedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When processing completed." + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks created." + }, + "tokenCount": { + "type": "integer", + "description": "Total token count." + }, + "characterCount": { + "type": "integer", + "description": "Total character count." + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search." + }, + "connectorId": { + "type": "string", + "nullable": true, + "description": "Connector ID if sourced from an external connector." + }, + "connectorType": { + "type": "string", + "nullable": true, + "description": "Connector type (e.g. google-drive, notion)." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "Original source URL for connector-sourced documents." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded." + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search result from knowledge base vector search.", + "properties": { + "documentId": { + "type": "string", + "description": "ID of the source document." + }, + "documentName": { + "type": "string", + "description": "Filename of the source document." + }, + "sourceUrl": { + "type": "string", + "nullable": true, + "description": "URL to the original source document for connector-synced documents (e.g., a Confluence page, Google Doc, or Notion page). Null for documents without an external source." + }, + "content": { + "type": "string", + "description": "The matched chunk content." + }, + "chunkIndex": { + "type": "integer", + "description": "Index of the chunk within the document." + }, + "metadata": { + "type": "object", + "description": "Tag metadata associated with the chunk (display names mapped to values)." + }, + "similarity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Similarity score (0-1, where 1 is most similar)." + } + } + }, + "TagFilter": { + "type": "object", + "description": "A tag-based filter for knowledge base search.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "Display name of the tag to filter by." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "default": "text", + "description": "Data type of the tag field." + }, + "operator": { + "type": "string", + "default": "eq", + "description": "Comparison operator (e.g. eq, neq, gt, lt, gte, lte, contains, between)." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "description": "Value to filter by." + }, + "valueTo": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "description": "Upper bound value for 'between' operator." + } + } + }, + "PausedExecutionSummary": { + "type": "object", + "description": "Summary of a paused workflow execution.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the paused execution record." + }, + "workflowId": { + "type": "string", + "description": "The workflow this execution belongs to." + }, + "executionId": { + "type": "string", + "description": "The execution that was paused." + }, + "status": { + "type": "string", + "description": "Current status of the paused execution.", + "example": "paused" + }, + "totalPauseCount": { + "type": "integer", + "description": "Total number of pause points in this execution." + }, + "resumedCount": { + "type": "integer", + "description": "Number of pause points that have been resumed." + }, + "pausedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution was paused." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution record was last updated." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the paused execution will expire and be cleaned up." + }, + "metadata": { + "type": "object", + "nullable": true, + "description": "Additional metadata associated with the paused execution.", + "additionalProperties": true + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of triggers that initiated the original execution." + }, + "pausePoints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PausePoint" + }, + "description": "List of pause points in the execution." + } + } + }, + "PausePoint": { + "type": "object", + "description": "A point in the workflow where execution has been paused awaiting human input.", + "properties": { + "contextId": { + "type": "string", + "description": "Unique identifier for this pause context. Used when resuming execution." + }, + "blockId": { + "type": "string", + "description": "The block ID where execution paused." + }, + "response": { + "description": "Data returned by the block before pausing, including display data and form fields." + }, + "registeredAt": { + "type": "string", + "format": "date-time", + "description": "When this pause point was registered." + }, + "resumeStatus": { + "type": "string", + "enum": ["paused", "resumed", "failed", "queued", "resuming"], + "description": "Current status of this pause point." + }, + "snapshotReady": { + "type": "boolean", + "description": "Whether the execution snapshot is ready for resumption." + }, + "resumeLinks": { + "type": "object", + "description": "Links for resuming this pause point.", + "properties": { + "apiUrl": { + "type": "string", + "format": "uri", + "description": "API endpoint URL to POST resume input to." + }, + "uiUrl": { + "type": "string", + "format": "uri", + "description": "UI URL for a human to review and approve." + }, + "contextId": { + "type": "string", + "description": "The context ID for this pause point." + }, + "executionId": { + "type": "string", + "description": "The execution ID." + }, + "workflowId": { + "type": "string", + "description": "The workflow ID." + } + } + }, + "queuePosition": { + "type": "integer", + "nullable": true, + "description": "Position in the resume queue, if queued." + }, + "latestResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The most recent resume queue entry for this pause point." + }, + "parallelScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a parallel branch.", + "properties": { + "parallelId": { + "type": "string", + "description": "Identifier of the parallel execution group." + }, + "branchIndex": { + "type": "integer", + "description": "Index of the branch within the parallel group." + }, + "branchTotal": { + "type": "integer", + "description": "Total number of branches in the parallel group." + } + } + }, + "loopScope": { + "type": "object", + "description": "Scope information when the pause occurs inside a loop.", + "properties": { + "loopId": { + "type": "string", + "description": "Identifier of the loop." + }, + "iteration": { + "type": "integer", + "description": "Current loop iteration number." + } + } + } + } + }, + "ResumeQueueEntry": { + "type": "object", + "description": "An entry in the resume execution queue.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this queue entry." + }, + "pausedExecutionId": { + "type": "string", + "description": "The paused execution this entry belongs to." + }, + "parentExecutionId": { + "type": "string", + "description": "The original execution that was paused." + }, + "newExecutionId": { + "type": "string", + "description": "The new execution ID created for the resume." + }, + "contextId": { + "type": "string", + "description": "The pause context ID being resumed." + }, + "resumeInput": { + "description": "The input provided when resuming." + }, + "status": { + "type": "string", + "description": "Status of this queue entry (e.g., pending, claimed, completed, failed)." + }, + "queuedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the entry was added to the queue." + }, + "claimedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution started processing this entry." + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When execution completed." + }, + "failureReason": { + "type": "string", + "nullable": true, + "description": "Reason for failure, if the resume failed." + } + } + }, + "PausedExecutionDetail": { + "type": "object", + "description": "Detailed information about a paused execution, including the execution snapshot and resume queue.", + "allOf": [ + { + "$ref": "#/components/schemas/PausedExecutionSummary" + }, + { + "type": "object", + "properties": { + "executionSnapshot": { + "type": "object", + "description": "Serialized execution state for resumption.", + "properties": { + "snapshot": { + "type": "string", + "description": "Serialized execution snapshot data." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger IDs from the snapshot." + } + } + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this execution." + } + } + } + ] + }, + "PauseContextDetail": { + "type": "object", + "description": "Detailed information about a specific pause context within a paused execution.", + "properties": { + "execution": { + "$ref": "#/components/schemas/PausedExecutionSummary", + "description": "Summary of the parent paused execution." + }, + "pausePoint": { + "$ref": "#/components/schemas/PausePoint", + "description": "The specific pause point for this context." + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResumeQueueEntry" + }, + "description": "Resume queue entries for this context." + }, + "activeResumeEntry": { + "$ref": "#/components/schemas/ResumeQueueEntry", + "nullable": true, + "description": "The currently active resume entry, if any." + } + } + }, + "ResumeResult": { + "type": "object", + "description": "Result of a synchronous resume execution.", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the resume execution completed successfully." + }, + "status": { + "type": "string", + "description": "Execution status.", + "enum": ["completed", "failed", "paused", "cancelled"], + "example": "completed" + }, + "executionId": { + "type": "string", + "description": "The new execution ID for the resumed workflow." + }, + "output": { + "type": "object", + "description": "Workflow output from the resumed execution.", + "additionalProperties": true + }, + "error": { + "type": "string", + "nullable": true, + "description": "Error message if the execution failed." + }, + "metadata": { + "type": "object", + "description": "Execution timing metadata.", + "properties": { + "duration": { + "type": "integer", + "description": "Total execution duration in milliseconds." + }, + "startTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution started." + }, + "endTime": { + "type": "string", + "format": "date-time", + "description": "When the resume execution completed." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Check the details array for specific validation errors.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message describing the validation failure." + }, + "details": { + "type": "array", + "description": "List of specific validation errors with field-level details.", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access this resource. For audit log endpoints, this requires an Enterprise subscription and organization admin/owner role.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. Verify the ID is correct and belongs to your workspace.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message." + } + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message with rate limit details." + } + } + } + } + } + }, + "RowsUpdated": { + "description": "Rows updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Indicates whether the request was successful." + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Confirmation message describing how many rows were updated." + }, + "updatedCount": { + "type": "integer", + "description": "Number of rows that were updated." + }, + "updatedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of IDs for each row that was updated." + } + }, + "description": "Response payload." + } + } + }, + "example": { + "success": true, + "data": { + "message": "Rows updated successfully", + "updatedCount": 2, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json new file mode 100644 index 00000000000..402866bc262 --- /dev/null +++ b/apps/docs/openapi-v2-files-audit.json @@ -0,0 +1,1125 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Files & Audit Logs", + "description": "Version 2 of the Sim REST API for the Files and Audit Logs surfaces.\n\n## Conventions (v2)\n\nEvery v2 endpoint shares one response family:\n\n- **Single resource:** `{ \"data\": T }`\n- **List:** `{ \"data\": T[], \"nextCursor\": string | null }`\n- **Error:** `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\n### Cursor pagination\n\nLists use an opaque keyset cursor (Stripe/Slack-style): pass `limit` and `cursor` in, receive `data` and `nextCursor` out. Treat `cursor` as opaque — pass back the `nextCursor` from the previous page verbatim. When `nextCursor` is `null` there are no more results. Total counts are not returned on lists.\n\n### Rate limiting\n\nRate-limit state is carried in response headers, not the body: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (an ISO 8601 timestamp). A throttled request returns `429` with a `Retry-After` header (seconds).\n\n### Authentication\n\nAll endpoints authenticate with the `X-API-Key` header (a personal or workspace API key). Files endpoints are workspace-scoped via the required `workspaceId` query parameter. Audit Logs endpoints are organization-scoped enterprise endpoints and require an Enterprise subscription plus an organization admin or owner role.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Files", + "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + }, + { + "name": "Audit Logs", + "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/files": { + "get": { + "operationId": "listFiles", + "summary": "List Files", + "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID&limit=100\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of files to return per page. Clamped to the range 1–1000. Defaults to 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of workspace files.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileListResponse" + }, + "example": { + "data": [ + { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadFile", + "summary": "Upload File", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/files?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/file.csv\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload, sent as multipart/form-data.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The file to upload. Maximum size is 100MB." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The file was uploaded successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid `workspaceId` query parameter, a body that is not valid multipart form data, or a missing `file` form field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "A file with the same name already exists in this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file with this name already exists in the workspace" + } + } + } + } + }, + "413": { + "description": "The upload exceeds the 100MB file size limit, or the workspace storage limit would be exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (142.30MB)" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download the raw bytes of a file. The success response body is the file content itself — there is no JSON envelope. The actual `Content-Type` reflects the stored file's MIME type (shown here as `application/octet-stream`); `Content-Disposition` and `Content-Length` describe the attachment, and rate-limit state is returned in the `X-RateLimit-*` headers. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`. Error responses still use the canonical v2 JSON error envelope.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -o downloaded-file.csv" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The raw file content as binary data. The `Content-Type` header reflects the file's stored MIME type.", + "headers": { + "Content-Type": { + "description": "MIME type of the file. Varies per file; defaults to application/octet-stream when unknown.", + "schema": { + "type": "string", + "example": "text/csv" + } + }, + "Content-Disposition": { + "description": "Attachment disposition carrying the (sanitized and RFC 5987 encoded) filename.", + "schema": { + "type": "string", + "example": "attachment; filename=\"data.csv\"; filename*=UTF-8''data.csv" + } + }, + "Content-Length": { + "description": "Size of the file in bytes.", + "schema": { + "type": "string", + "example": "1024" + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Archive (soft delete) a file in a workspace. The operation is workspace-scoped and records its own audit entry. Returns the file id and a `deleted` acknowledgement.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/files/{fileId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "deleted": true + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "description": "The file could not be archived because of a conflicting state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Failed to delete file" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List audit log entries for the authenticated user's organization with opaque cursor pagination. These are organization-scoped (not workspace-scoped) enterprise endpoints: the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. The `ipAddress` and `userAgent` fields are intentionally excluded from entries for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs?limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by action type (e.g., file.uploaded, workflow.deployed, member.invited).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type (e.g., file, workflow, workspace, member).", + "schema": { + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by a specific resource ID.", + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter by a workspace within your organization. Must belong to your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "actorId", + "in": "query", + "required": false, + "description": "Filter by the user who performed the action. Must be a member of your organization, otherwise the request returns 400.", + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only return entries at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only return entries at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "When true, include entries from users who have left the organization. Defaults to false.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of entries to return per page. Must be between 1 and 100. Defaults to 50.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "$ref": "#/components/parameters/Cursor" + } + ], + "responses": { + "200": { + "description": "A page of audit log entries.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogListResponse" + }, + "example": { + "data": [ + { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "description": "The request was malformed: an invalid query parameter, an `actorId` that is not a member of your organization, or a `workspaceId` that does not belong to your organization.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "actorId is not a member of your organization" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/audit-logs/{id}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Retrieve a single audit log entry by ID, scoped to the authenticated user's organization. Organization-scoped (not workspace-scoped): the caller must belong to an organization with an active Enterprise subscription and hold an admin or owner role — otherwise the request returns `403`. An entry outside your organization returns `404` (existence is not leaked). The `ipAddress` and `userAgent` fields are intentionally excluded for privacy.", + "tags": ["Audit Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/audit-logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique audit log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "example": "audit_2c3d4e5f6g" + } + } + ], + "responses": { + "200": { + "description": "The audit log entry.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AuditLogResponse" + }, + "example": { + "data": { + "id": "audit_2c3d4e5f6g", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "actorId": "user_abc123", + "actorName": "Jane Smith", + "actorEmail": "jane@example.com", + "action": "file.uploaded", + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "resourceName": "data.csv", + "description": "Uploaded file \"data.csv\" via API", + "metadata": { + "fileSize": 1024, + "fileType": "text/csv" + }, + "createdAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": ["id", "name", "size", "type", "key", "uploadedBy", "uploadedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2DeleteFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful archive (soft delete).", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the archived file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful archive." + } + } + }, + "V2AuditLogEntry": { + "type": "object", + "description": "A public enterprise audit log entry. The ipAddress and userAgent fields are intentionally excluded for privacy.", + "required": [ + "id", + "workspaceId", + "actorId", + "actorName", + "actorEmail", + "action", + "resourceType", + "resourceId", + "resourceName", + "description", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the audit log entry.", + "example": "audit_2c3d4e5f6g" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace where the action occurred, or null for organization-level actions.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "actorId": { + "type": ["string", "null"], + "description": "The user ID of the person who performed the action, or null when not attributable.", + "example": "user_abc123" + }, + "actorName": { + "type": ["string", "null"], + "description": "Display name of the person who performed the action.", + "example": "Jane Smith" + }, + "actorEmail": { + "type": ["string", "null"], + "description": "Email address of the person who performed the action.", + "example": "jane@example.com" + }, + "action": { + "type": "string", + "description": "The action that was performed (e.g., file.uploaded, workflow.deployed).", + "example": "file.uploaded" + }, + "resourceType": { + "type": "string", + "description": "The type of resource affected (e.g., file, workflow, workspace, member).", + "example": "file" + }, + "resourceId": { + "type": ["string", "null"], + "description": "The unique identifier of the affected resource.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "resourceName": { + "type": ["string", "null"], + "description": "Display name of the affected resource.", + "example": "data.csv" + }, + "description": { + "type": ["string", "null"], + "description": "Human-readable description of the action.", + "example": "Uploaded file \"data.csv\" via API" + }, + "metadata": { + "description": "Arbitrary per-action metadata as JSON. The shape varies by action type and may be null for some actions.", + "example": { + "fileSize": 1024, + "fileType": "text/csv" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the action occurred.", + "example": "2026-01-15T10:30:00Z" + } + } + }, + "V2FileListResponse": { + "type": "object", + "description": "A page of files plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The files in this page.", + "items": { + "$ref": "#/components/schemas/V2File" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2FileResponse": { + "type": "object", + "description": "A single file resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2File" + } + } + }, + "V2DeleteFileResponse": { + "type": "object", + "description": "The result of archiving a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2DeleteFileResult" + } + } + }, + "V2AuditLogListResponse": { + "type": "object", + "description": "A page of audit log entries plus the cursor for the next page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The audit log entries in this page.", + "items": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results." + } + } + }, + "V2AuditLogResponse": { + "type": "object", + "description": "A single audit log entry resource.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2AuditLogEntry" + } + } + }, + "V2Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code (e.g., BAD_REQUEST, NOT_FOUND, RATE_LIMITED)." + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error context. For validation errors this is an array of field-level issues; for rate limiting it carries the reset timestamp." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. Inspect `error.message` and the optional `error.details` for specifics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": ["workspaceId"], + "code": "invalid_type", + "message": "Required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. For Files, the API key lacks access to the workspace. For Audit Logs, this requires an Enterprise subscription and an organization admin or owner role.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Active enterprise subscription required" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found, or it does not belong to the authorized scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "File not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T11:00:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json new file mode 100644 index 00000000000..5c43fd27ff7 --- /dev/null +++ b/apps/docs/openapi-v2-knowledge.json @@ -0,0 +1,1802 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Knowledge Bases", + "description": "The v2 Knowledge Bases API lets you create and manage knowledge bases, upload and inspect documents, and run vector and tag search over your indexed content.\n\n## Conventions\n\nAll endpoints live under the `/api/v2` base path and share a single set of conventions:\n\n- **Authentication** — Send your Sim API key in the `X-API-Key` header on every request. Keys are scoped to a workspace (or are personal keys that target a workspace); `workspaceId` is always required so the request can be tenant-scoped and rate-limited.\n- **Single-resource and mutation responses** return `{ \"data\": ... }`.\n- **List responses** use an opaque-cursor envelope: `{ \"data\": [ ... ], \"nextCursor\": string | null }`. Pass the returned `nextCursor` back as the `cursor` query parameter to fetch the next page. When `nextCursor` is `null` there are no more results. Cursors are opaque — do not parse or construct them.\n- **Errors** use a single envelope: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`. The HTTP status code and the stable `code` field move together (for example `404` ⇄ `NOT_FOUND`).\n- **Rate limiting** — Every response carries the current limiter state in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. A throttled request returns `429` with a `Retry-After` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Knowledge Bases", + "description": "Create and manage knowledge bases, upload and inspect documents, and run vector and tag search (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/knowledge": { + "get": { + "operationId": "listKnowledgeBases", + "summary": "List Knowledge Bases", + "description": "List all knowledge bases in a workspace. The full bounded per-workspace set is returned as a single page, so `nextCursor` is always `null` today; treat the response as a standard cursor list so pagination can be added later without a contract change.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "Knowledge bases for the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The knowledge bases in the workspace.", + "items": { + "$ref": "#/components/schemas/KnowledgeBase" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createKnowledgeBase", + "summary": "Create Knowledge Base", + "description": "Create a new knowledge base in a workspace. The embedding model and dimension are fixed server-side and cannot be supplied. Returns `201` with the created knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Product Documentation\",\n \"description\": \"All product docs and guides\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The knowledge base to create.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "201": { + "description": "The knowledge base was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + } + ], + "get": { + "operationId": "getKnowledgeBase", + "summary": "Get Knowledge Base", + "description": "Retrieve a single knowledge base by ID. A knowledge base that does not exist, belongs to another workspace, or that the caller cannot read is reported as `404` so cross-workspace existence is never leaked.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateKnowledgeBase", + "summary": "Update Knowledge Base", + "description": "Update a knowledge base's name, description, or chunking config. At least one of `name`, `description`, or `chunkingConfig` must be provided. The target workspace is carried in the request body.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/knowledge/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"Updated name\"\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The fields to update. At least one of name, description, or chunkingConfig is required.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateKnowledgeBaseBody" + } + } + } + }, + "responses": { + "200": { + "description": "The updated knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KnowledgeBaseEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeBase", + "summary": "Delete Knowledge Base", + "description": "Delete a knowledge base and all of its documents. Returns a delete acknowledgement with the id of the removed knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The knowledge base was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/search": { + "post": { + "operationId": "searchKnowledge", + "summary": "Search Knowledge", + "description": "Run vector and/or tag search across one or more knowledge bases. Provide a `query` for semantic vector search, `tagFilters` for structured filtering, or both. At least one of `query` or `tagFilters` is required.\n\nNotes and limits:\n- Tag filters are only supported when searching a single knowledge base.\n- When a `query` is supplied, all targeted knowledge bases must use the same embedding model; otherwise the request is rejected. Search such knowledge bases separately.\n- A text query consumes hosted embedding (and optional rerank) usage; tag-only search is free.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/search\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"knowledgeBaseIds\": [\"KB_ID\"],\n \"query\": \"How do I reset my password?\",\n \"topK\": 10\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The search request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchBody" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when neither `query` nor `tagFilters` is provided, when tag filters target more than one knowledge base, when the selected knowledge bases use different embedding models, or when a tag name/value is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "crossModel": { + "summary": "Knowledge bases use different embedding models", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Selected knowledge bases use different embedding models and cannot be searched together. Search them separately." + } + } + }, + "multiKbTagFilter": { + "summary": "Tag filters across multiple knowledge bases", + "value": { + "error": { + "code": "BAD_REQUEST", + "message": "Tag filters are only supported when searching a single knowledge base" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "One or more of the requested knowledge bases do not exist or are not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found or access denied" + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "listKnowledgeDocuments", + "summary": "List Documents", + "description": "List documents in a knowledge base. Supports search, enabled-state filtering, sorting, and cursor pagination. Pass the returned `nextCursor` back as `cursor` to fetch the next page; the total document count is available as `docCount` on the parent knowledge base.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of documents to return per page.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a previous response's `nextCursor`. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against document filenames.", + "schema": { + "type": "string" + } + }, + { + "name": "enabledFilter", + "in": "query", + "required": false, + "description": "Filter documents by their enabled state.", + "schema": { + "type": "string", + "enum": ["all", "enabled", "disabled"], + "default": "all" + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field to sort by.", + "schema": { + "type": "string", + "enum": [ + "filename", + "fileSize", + "tokenCount", + "chunkCount", + "uploadedAt", + "processingStatus", + "enabled" + ], + "default": "uploadedAt" + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "Documents in the knowledge base.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The documents on this page.", + "items": { + "$ref": "#/components/schemas/DocumentSummary" + } + }, + "nextCursor": { + "$ref": "#/components/schemas/NextCursor" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "uploadKnowledgeDocument", + "summary": "Upload Document", + "description": "Upload a single document to a knowledge base as `multipart/form-data`. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the file body is buffered. The maximum file size is 100 MB. Processing is asynchronous: the document is returned with `processingStatus: \"pending\"` and indexing continues in the background — poll the Get Document endpoint to observe progress.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -F \"file=@/path/to/document.pdf\"" + } + ], + "requestBody": { + "required": true, + "description": "The file to upload.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "The document file to upload (max 100 MB)." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The document was accepted and queued for processing.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentSummaryEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request. Returned when the body is not valid multipart form data or the required `file` field is missing.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "file form field is required" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "description": "The uploaded file exceeds the 100 MB limit, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "File size exceeds 100MB limit (123.45MB)" + } + } + } + } + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/knowledge/{id}/documents/{documentId}": { + "parameters": [ + { + "$ref": "#/components/parameters/KnowledgeBaseId" + }, + { + "$ref": "#/components/parameters/DocumentId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "get": { + "operationId": "getKnowledgeDocument", + "summary": "Get Document", + "description": "Retrieve the full detail for a single document, including processing state and connector provenance.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document detail.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteKnowledgeDocument", + "summary": "Delete Document", + "description": "Delete a single document from a knowledge base. Returns a delete acknowledgement with the id of the removed document.", + "tags": ["Knowledge Bases"], + "x-codeSamples": [ + { + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/knowledge/{id}/documents/{documentId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "responses": { + "200": { + "description": "The document was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "KnowledgeBaseId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the knowledge base.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + } + }, + "DocumentId": { + "name": "documentId", + "in": "path", + "required": true, + "description": "The unique identifier of the document.", + "schema": { + "type": "string", + "minLength": 1, + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + } + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace that scopes the request.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2025-06-20T14:16:00Z" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + } + }, + "schemas": { + "NextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more results. Pass it back as the `cursor` query parameter. Do not parse or construct cursors.", + "example": null + }, + "ChunkingConfig": { + "type": "object", + "description": "How documents in this knowledge base are split into chunks before embedding.", + "required": ["maxSize", "minSize", "overlap"], + "additionalProperties": true, + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "example": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "example": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "example": 200 + }, + "strategy": { + "type": "string", + "description": "Chunking strategy applied during processing.", + "enum": ["auto", "text", "regex", "recursive", "sentence", "token"] + } + } + }, + "ChunkingConfigInput": { + "type": "object", + "description": "Chunking configuration for the knowledge base. Defaults are applied when omitted.", + "properties": { + "maxSize": { + "type": "integer", + "description": "Maximum chunk size, in tokens.", + "minimum": 100, + "maximum": 4000, + "default": 1024 + }, + "minSize": { + "type": "integer", + "description": "Minimum chunk size, in characters.", + "minimum": 1, + "maximum": 2000, + "default": 100 + }, + "overlap": { + "type": "integer", + "description": "Number of overlapping characters between adjacent chunks.", + "minimum": 0, + "maximum": 500, + "default": 200 + } + } + }, + "KnowledgeBase": { + "type": "object", + "description": "A knowledge base: a collection of documents indexed for vector and tag search.", + "required": [ + "id", + "name", + "description", + "tokenCount", + "embeddingModel", + "embeddingDimension", + "chunkingConfig", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique knowledge base identifier.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "name": { + "type": "string", + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the knowledge base. null when not set.", + "example": "All product docs and guides" + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens across all indexed documents.", + "example": 48213 + }, + "embeddingModel": { + "type": "string", + "description": "The embedding model used to index documents in this knowledge base.", + "example": "text-embedding-3-small" + }, + "embeddingDimension": { + "type": "integer", + "description": "The dimensionality of the embedding vectors.", + "example": 1536 + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfig" + }, + "docCount": { + "type": "integer", + "description": "Number of documents in the knowledge base.", + "example": 12 + }, + "connectorTypes": { + "type": "array", + "description": "The set of external connector types that have synced documents into this knowledge base.", + "items": { + "type": "string" + }, + "example": ["notion", "google_drive"] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was created.", + "example": "2025-01-10T09:00:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the knowledge base was last modified.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "KnowledgeBaseEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["knowledgeBase"], + "properties": { + "knowledgeBase": { + "$ref": "#/components/schemas/KnowledgeBase" + } + } + } + } + }, + "CreateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for creating a knowledge base.", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable knowledge base name.", + "example": "Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "Optional description of the knowledge base.", + "example": "All product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "UpdateKnowledgeBaseBody": { + "type": "object", + "description": "Request body for updating a knowledge base. At least one of name, description, or chunkingConfig must be provided.", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace the knowledge base belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New knowledge base name.", + "example": "Updated Product Documentation" + }, + "description": { + "type": "string", + "maxLength": 1000, + "description": "New description of the knowledge base.", + "example": "Refreshed product docs and guides" + }, + "chunkingConfig": { + "$ref": "#/components/schemas/ChunkingConfigInput" + } + } + }, + "DocumentSummary": { + "type": "object", + "description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + } + } + }, + "DocumentSummaryEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/DocumentSummary" + } + } + } + } + }, + "Document": { + "type": "object", + "description": "Full document detail: the summary fields plus processing state and connector provenance.", + "required": [ + "id", + "knowledgeBaseId", + "filename", + "fileSize", + "mimeType", + "processingStatus", + "chunkCount", + "tokenCount", + "characterCount", + "enabled", + "createdAt", + "processingError", + "processingStartedAt", + "processingCompletedAt", + "connectorId", + "connectorType", + "sourceUrl" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique document identifier.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "knowledgeBaseId": { + "type": "string", + "description": "The knowledge base this document belongs to.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "filename": { + "type": "string", + "description": "Original filename of the uploaded document.", + "example": "getting-started.pdf" + }, + "fileSize": { + "type": "integer", + "description": "Size of the file in bytes.", + "example": 248913 + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file.", + "example": "application/pdf" + }, + "processingStatus": { + "type": "string", + "description": "Current processing state of the document.", + "enum": ["pending", "processing", "completed", "failed"], + "example": "completed" + }, + "chunkCount": { + "type": "integer", + "description": "Number of chunks the document was split into. 0 until processing completes.", + "example": 24 + }, + "tokenCount": { + "type": "integer", + "description": "Total number of tokens extracted from the document.", + "example": 8123 + }, + "characterCount": { + "type": "integer", + "description": "Total number of characters extracted from the document.", + "example": 41205 + }, + "enabled": { + "type": "boolean", + "description": "Whether the document is enabled for search.", + "example": true + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the document was uploaded.", + "example": "2025-06-18T16:45:00Z" + }, + "processingError": { + "type": ["string", "null"], + "description": "Error message if processing failed, otherwise null.", + "example": null + }, + "processingStartedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing started, or null.", + "example": "2025-06-18T16:45:05Z" + }, + "processingCompletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when processing completed, or null.", + "example": "2025-06-18T16:45:42Z" + }, + "connectorId": { + "type": ["string", "null"], + "description": "Identifier of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "connectorType": { + "type": ["string", "null"], + "description": "Type of the connector that synced this document, or null for direct uploads.", + "example": null + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document for connector-synced documents, or null.", + "example": null + } + } + }, + "DocumentEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["document"], + "properties": { + "document": { + "$ref": "#/components/schemas/Document" + } + } + } + } + }, + "SearchTagFilter": { + "type": "object", + "description": "A structured tag filter applied to search. Tag filters are only supported when searching a single knowledge base.", + "required": ["tagName", "value"], + "properties": { + "tagName": { + "type": "string", + "description": "The display name of the tag to filter on.", + "example": "category" + }, + "fieldType": { + "type": "string", + "description": "The tag's field type.", + "enum": ["text", "number", "date", "boolean"] + }, + "operator": { + "type": "string", + "description": "Comparison operator. Valid operators depend on the field type.", + "default": "eq", + "example": "eq" + }, + "value": { + "description": "The value to compare against.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "example": "billing" + }, + "valueTo": { + "description": "Upper bound for the `between` operator (number or date).", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "SearchBody": { + "type": "object", + "description": "Request body for knowledge search. At least one of `query` or `tagFilters` must be provided.", + "required": ["workspaceId", "knowledgeBaseIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the knowledge bases.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "knowledgeBaseIds": { + "description": "A single knowledge base ID or an array of up to 20 IDs to search.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "description": "A single knowledge base ID." + }, + { + "type": "array", + "description": "An array of knowledge base IDs.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "maxItems": 20 + } + ], + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "query": { + "type": "string", + "description": "The natural-language query for semantic vector search. Required if `tagFilters` is omitted.", + "example": "How do I reset my password?" + }, + "topK": { + "type": "integer", + "description": "Maximum number of results to return.", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "tagFilters": { + "type": "array", + "description": "Structured tag filters. Only supported when searching a single knowledge base. Required if `query` is omitted.", + "items": { + "$ref": "#/components/schemas/SearchTagFilter" + } + } + } + }, + "SearchResult": { + "type": "object", + "description": "A single search hit (a matching document chunk).", + "required": [ + "documentId", + "documentName", + "sourceUrl", + "content", + "chunkIndex", + "metadata", + "similarity" + ], + "properties": { + "documentId": { + "type": "string", + "description": "Identifier of the document the chunk belongs to.", + "example": "b2d4f8a0-1c3e-4a5b-9d7c-2e6f0a8b4c12" + }, + "documentName": { + "type": ["string", "null"], + "description": "Filename of the source document, or null if unavailable.", + "example": "getting-started.pdf" + }, + "sourceUrl": { + "type": ["string", "null"], + "description": "Original source URL of the document, or null for direct uploads.", + "example": null + }, + "content": { + "type": "string", + "description": "The matching chunk's text content.", + "example": "To reset your password, open Settings and choose \"Security\"." + }, + "chunkIndex": { + "type": "integer", + "description": "Zero-based index of the chunk within its document.", + "example": 3 + }, + "metadata": { + "type": "object", + "description": "The document's tag values keyed by tag display name. Values are user-defined and may be strings, numbers, booleans, or dates.", + "additionalProperties": true, + "example": { + "category": "billing", + "priority": 2 + } + }, + "similarity": { + "type": "number", + "description": "Similarity score in the range 0–1 for vector search (higher is more similar). 1 for tag-only matches.", + "example": 0.8423 + } + } + }, + "SearchEnvelope": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["results", "query", "knowledgeBaseIds", "topK", "totalResults"], + "properties": { + "results": { + "type": "array", + "description": "The matching chunks, ordered by relevance.", + "items": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "query": { + "type": "string", + "description": "The query that was executed (empty string for tag-only search).", + "example": "How do I reset my password?" + }, + "knowledgeBaseIds": { + "type": "array", + "description": "The knowledge base IDs that were searched.", + "items": { + "type": "string" + }, + "example": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] + }, + "topK": { + "type": "integer", + "description": "The maximum number of results requested.", + "example": 10 + }, + "totalResults": { + "type": "integer", + "description": "The number of results returned.", + "example": 4 + } + } + } + } + }, + "DeleteEnvelope": { + "type": "object", + "description": "Delete acknowledgement.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "deleted"], + "properties": { + "id": { + "type": "string", + "description": "The id of the resource that was deleted.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "deleted": { + "type": "boolean", + "description": "Always true.", + "enum": [true], + "example": true + } + } + } + } + }, + "Error": { + "type": "object", + "description": "The canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "USAGE_LIMIT_EXCEEDED", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of the error." + }, + "details": { + "description": "Optional structured context for the error, such as field-level validation issues." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "workspaceId query parameter is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "The authenticated caller does not have access to the requested workspace or resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource does not exist or is not accessible from this workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Knowledge base not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with the current state of the resource (for example, a resource with the same name already exists).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Resource already exists" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits. Upgrade the plan to continue.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request payload exceeds the allowed size, or the workspace storage limit has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The uploaded file's MIME type or extension is not supported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Unsupported file type" + } + } + } + } + }, + "RateLimited": { + "description": "The rate limit has been exceeded. Retry after the period indicated by the Retry-After header.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2025-06-20T14:16:00Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred on the server.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json new file mode 100644 index 00000000000..4631df64376 --- /dev/null +++ b/apps/docs/openapi-v2-logs.json @@ -0,0 +1,1065 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Logs", + "description": "Version 2 of the Sim API for workflow execution logs. v2 standardizes every response on a single envelope: a single resource returns `{ data }`, a list returns `{ data, nextCursor }`, and an error returns `{ error: { code, message, details? } }`. Lists use opaque cursor pagination (`limit` + `cursor` in, `nextCursor` out). Rate-limit state is carried in the `X-RateLimit-*` response headers rather than the body. Authenticate every request with the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "security": [ + { + "apiKey": [] + } + ], + "tags": [ + { + "name": "Logs", + "description": "Query workflow execution logs, retrieve a single log entry, and fetch the full execution state snapshot for a run." + } + ], + "paths": { + "/api/v2/logs": { + "get": { + "operationId": "listLogs", + "summary": "List Logs", + "description": "List workflow execution logs for a workspace with filtering and opaque cursor pagination. Returns `{ data, nextCursor }`. By default (`details=basic`) each entry contains summary fields only; pass `details=full` to include the per-execution `workflow` summary, and additionally `includeFinalOutput=true` / `includeTraceSpans=true` to materialize `finalOutput` / `traceSpans` on each entry.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "workflowIds", + "in": "query", + "description": "Comma-separated list of workflow IDs to filter by. Only logs from these workflows are returned.", + "schema": { + "type": "string" + }, + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36,8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + { + "name": "folderIds", + "in": "query", + "description": "Comma-separated list of folder IDs. Returns logs for all workflows within these folders.", + "schema": { + "type": "string" + } + }, + { + "name": "triggers", + "in": "query", + "description": "Comma-separated trigger types to filter by (e.g. api, webhook, schedule, manual, chat).", + "schema": { + "type": "string" + }, + "example": "api,schedule" + }, + { + "name": "level", + "in": "query", + "description": "Filter logs by severity level. info for successful executions, error for failed ones.", + "schema": { + "type": "string", + "enum": ["info", "error"] + } + }, + { + "name": "startDate", + "in": "query", + "description": "Only return logs started at or after this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "description": "Only return logs started at or before this ISO 8601 timestamp.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "executionId", + "in": "query", + "description": "Filter by an exact execution ID. Useful for looking up a specific run.", + "schema": { + "type": "string" + } + }, + { + "name": "minDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at least this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "maxDurationMs", + "in": "query", + "description": "Only return logs where total execution duration was at most this many milliseconds.", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "minCost", + "in": "query", + "description": "Only return logs where execution cost was at least this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "maxCost", + "in": "query", + "description": "Only return logs where execution cost was at most this amount in USD.", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "model", + "in": "query", + "description": "Filter by the AI model used during execution (e.g., gpt-4o, claude-sonnet-4-20250514).", + "schema": { + "type": "string" + } + }, + { + "name": "details", + "in": "query", + "description": "Response detail level. basic returns summary fields only. full additionally includes the per-entry workflow summary and enables the includeFinalOutput / includeTraceSpans materialization flags.", + "schema": { + "type": "string", + "enum": ["basic", "full"], + "default": "basic" + } + }, + { + "name": "includeTraceSpans", + "in": "query", + "description": "When true, includes block-level execution trace spans on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "includeFinalOutput", + "in": "query", + "description": "When true, includes the workflow's final output on each entry. Only applies when details=full.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of log entries to return per page. Values are clamped to the range 1–1000.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque pagination cursor returned from a previous request's nextCursor field. Omit to fetch the first page.", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "in": "query", + "description": "Sort order by execution start time. desc returns newest first.", + "schema": { + "type": "string", + "enum": ["desc", "asc"], + "default": "desc" + } + } + ], + "responses": { + "200": { + "description": "A page of execution logs matching the filter criteria.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Log entries for the current page.", + "items": { + "$ref": "#/components/schemas/LogListItem" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for fetching the next page. null when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "deploymentVersionId": "dep_2c4e6a8b0d1f", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + }, + "files": null + } + ], + "nextCursor": "eyJzdGFydGVkQXQiOiIyMDI2LTAxLTE1VDEwOjMwOjAwLjAwMFoiLCJpZCI6ImxvZ183eDh5OXowYTFiIn0=" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/{id}": { + "get": { + "operationId": "getLog", + "summary": "Get Log", + "description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The unique identifier of the log entry.", + "schema": { + "type": "string", + "example": "log_7x8y9z0a1b" + } + } + ], + "responses": { + "200": { + "description": "The requested log entry with full execution data and cost summary.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/LogDetail" + } + } + }, + "example": { + "data": { + "id": "log_7x8y9z0a1b", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "level": "info", + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "files": null, + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": null, + "userId": "usr_1a2b3c4d5e", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "createdAt": "2025-01-10T09:00:00.000Z", + "updatedAt": "2025-06-18T16:45:00.000Z", + "deleted": false + }, + "executionData": { + "traceSpans": [], + "finalOutput": { + "result": "Hello, world!" + } + }, + "cost": { + "total": 0.0032 + }, + "createdAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/logs/executions/{executionId}": { + "get": { + "operationId": "getExecution", + "summary": "Get Execution", + "description": "Retrieve the full execution state snapshot for a run: the workflow state captured at execution time plus execution metadata (trigger, timing, and cost). Returns `{ data }`.", + "tags": ["Logs"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/logs/executions/{executionId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "name": "executionId", + "in": "path", + "required": true, + "description": "The unique execution identifier.", + "schema": { + "type": "string", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + } + } + ], + "responses": { + "200": { + "description": "The full execution state snapshot with workflow state and metadata.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/Execution" + } + } + }, + "example": { + "data": { + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowState": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {} + }, + "executionMetadata": { + "trigger": "api", + "startedAt": "2026-01-15T10:30:00.000Z", + "endedAt": "2026-01-15T10:30:01.250Z", + "totalDurationMs": 1250, + "cost": { + "total": 0.0032 + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace whose logs to query." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum number of requests allowed in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "Cost": { + "type": ["object", "null"], + "description": "Aggregate execution cost in USD. null when no cost was recorded for the run.", + "required": ["total"], + "properties": { + "total": { + "type": "number", + "description": "Total cost of the execution in USD.", + "example": 0.0032 + } + } + }, + "LogWorkflowSummary": { + "type": "object", + "description": "Workflow summary captured at execution time. Present on a list entry only when details=full.", + "required": ["id", "name", "description", "deleted"], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogWorkflowDetail": { + "type": "object", + "description": "Full workflow metadata captured at execution time.", + "required": [ + "id", + "name", + "description", + "folderId", + "userId", + "workspaceId", + "createdAt", + "updatedAt", + "deleted" + ], + "properties": { + "id": { + "type": ["string", "null"], + "description": "The workflow's identifier. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Workflow name. Falls back to \"Deleted Workflow\" when the workflow no longer exists.", + "example": "Customer Support Agent" + }, + "description": { + "type": ["string", "null"], + "description": "Workflow description, or null if none was set.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": ["string", "null"], + "description": "The folder the workflow belongs to. null if at the workspace root or the workflow is gone.", + "example": null + }, + "userId": { + "type": ["string", "null"], + "description": "The user that owns the workflow. null if the workflow is gone.", + "example": "usr_1a2b3c4d5e" + }, + "workspaceId": { + "type": ["string", "null"], + "description": "The workspace the workflow belongs to. null if the workflow is gone.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "createdAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created. null if the workflow is gone.", + "example": "2025-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified. null if the workflow is gone.", + "example": "2025-06-18T16:45:00.000Z" + }, + "deleted": { + "type": "boolean", + "description": "Whether the workflow has since been deleted.", + "example": false + } + } + }, + "LogListItem": { + "type": "object", + "description": "Summary of a single workflow execution log entry returned by the list endpoint.", + "required": [ + "id", + "workflowId", + "executionId", + "deploymentVersionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "cost", + "files" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "deploymentVersionId": { + "type": ["string", "null"], + "description": "The deployment version that produced this run. null for runs not tied to a deployment.", + "example": "dep_2c4e6a8b0d1f" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "allOf": [ + { + "$ref": "#/components/schemas/LogWorkflowSummary" + } + ], + "description": "Workflow summary. Present only when details=full." + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output. The shape depends on the workflow. Present only when details=full and includeFinalOutput=true." + }, + "traceSpans": { + "type": "array", + "description": "Block-level execution trace spans with timing, inputs, and outputs. Present only when details=full and includeTraceSpans=true.", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "LogDetail": { + "type": "object", + "description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.", + "required": [ + "id", + "workflowId", + "executionId", + "level", + "trigger", + "startedAt", + "endedAt", + "totalDurationMs", + "files", + "workflow", + "executionData", + "cost", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique log entry identifier.", + "example": "log_7x8y9z0a1b" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "executionId": { + "type": "string", + "description": "Unique execution identifier for this run.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "level": { + "type": "string", + "description": "Log severity. info for successful executions, error for failures.", + "example": "info" + }, + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "files": { + "type": ["array", "null"], + "description": "Attachment metadata for files produced during the run. null when the run produced no files.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "workflow": { + "$ref": "#/components/schemas/LogWorkflowDetail" + }, + "executionData": { + "type": "object", + "additionalProperties": true, + "description": "Materialized execution trace for this run (block states, trace spans, and final output). Large blobs stored externally are resolved inline.", + "properties": { + "traceSpans": { + "type": "array", + "description": "Block-level execution traces with timing, inputs, and outputs.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "finalOutput": { + "type": "object", + "additionalProperties": true, + "description": "The workflow's final output after all blocks completed." + } + } + }, + "cost": { + "$ref": "#/components/schemas/Cost" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the log entry was recorded.", + "example": "2026-01-15T10:30:00.000Z" + } + } + }, + "Execution": { + "type": "object", + "description": "Full execution state snapshot: the workflow state at execution time plus execution metadata.", + "required": ["executionId", "workflowId", "workflowState", "executionMetadata"], + "properties": { + "executionId": { + "type": "string", + "description": "The unique identifier for this execution.", + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" + }, + "workflowId": { + "type": ["string", "null"], + "description": "The workflow that was executed. null if the log is not associated with a workflow.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "workflowState": { + "type": "object", + "additionalProperties": true, + "description": "Snapshot of the workflow configuration at the time of execution.", + "properties": { + "blocks": { + "type": "object", + "additionalProperties": true, + "description": "Map of block IDs to their configuration and state during execution." + }, + "edges": { + "type": "array", + "description": "Connections between blocks defining the execution flow.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "loops": { + "type": "object", + "additionalProperties": true, + "description": "Loop configurations defining iterative execution patterns." + }, + "parallels": { + "type": "object", + "additionalProperties": true, + "description": "Parallel execution group configurations." + } + } + }, + "executionMetadata": { + "type": "object", + "description": "Metadata about the execution including trigger, timing, and cost.", + "required": ["trigger", "startedAt", "endedAt", "totalDurationMs", "cost"], + "properties": { + "trigger": { + "type": "string", + "description": "How the execution was triggered (e.g., api, webhook, schedule, manual, chat).", + "example": "api" + }, + "startedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when execution started.", + "example": "2026-01-15T10:30:00.000Z" + }, + "endedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 timestamp when execution completed. null if the run has not finished.", + "example": "2026-01-15T10:30:01.250Z" + }, + "totalDurationMs": { + "type": ["integer", "null"], + "description": "Total execution duration in milliseconds. null if the run has not finished.", + "example": 1250 + }, + "cost": { + "$ref": "#/components/schemas/Cost" + } + } + } + } + }, + "Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code (e.g., BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, INTERNAL_ERROR).", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "description": "Human-readable error message.", + "example": "Log not found" + }, + "details": { + "description": "Optional structured details about the error (e.g., field-level validation issues or rate-limit reset info). Present only on some errors." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request parameters. Inspect error.details for field-level validation issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "workspaceId", + "message": "Workspace ID is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "Forbidden": { + "description": "The API key is authenticated but not authorized for the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "API key is not authorized for this workspace" + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found. An authorization failure on a single resource is also reported as 404 so resource existence is not leaked.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Log not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json new file mode 100644 index 00000000000..fa3daf0cd97 --- /dev/null +++ b/apps/docs/openapi-v2-tables.json @@ -0,0 +1,2339 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim Tables API v2", + "description": "Version 2 of the Sim Tables API for managing tables, their column schemas, and rows of structured data. v2 standardizes every endpoint on a single response family: a single resource is returned as `{ data }`, lists are returned as `{ data, nextCursor }` with opaque cursor pagination, and errors are returned as `{ error: { code, message, details? } }`. Rate-limit state is carried in `X-RateLimit-*` response headers. Authenticate every request with the `X-API-Key` header. Row `data` is always keyed by column name.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Tables", + "description": "Manage tables, columns, and rows for structured data storage (v2 API)." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/tables": { + "get": { + "operationId": "listTables", + "summary": "List Tables", + "description": "List all tables in a workspace. Returns the full bounded set of tables for the workspace as a single page, so `nextCursor` is always null.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The tables in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableListEnvelope" + }, + "example": { + "data": [ + { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contact records", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + } + ] + }, + "rowCount": 2, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTable", + "summary": "Create Table", + "description": "Create a new table with a typed column schema. The schema must contain between 1 and 50 columns.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"name\": \"contacts\",\n \"description\": \"Customer contacts\",\n \"schema\": {\n \"columns\": [\n { \"name\": \"email\", \"type\": \"string\", \"required\": true, \"unique\": true },\n { \"name\": \"name\", \"type\": \"string\", \"required\": true },\n { \"name\": \"age\", \"type\": \"number\" }\n ]\n }\n }'" + } + ], + "requestBody": { + "required": true, + "description": "The table name, optional description, column schema, and target workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTableBody" + } + } + } + }, + "responses": { + "201": { + "description": "The table was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + }, + "example": { + "data": { + "table": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14", + "name": "contacts", + "description": "Customer contacts", + "schema": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true + }, + { + "id": "col_g7h8i9", + "name": "age", + "type": "number", + "required": false, + "unique": false + } + ] + }, + "rowCount": 0, + "maxRows": 100000, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}": { + "get": { + "operationId": "getTable", + "summary": "Get Table", + "description": "Get a single table's metadata and column schema.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested table.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTable", + "summary": "Delete Table", + "description": "Archive a table. Returns the id of the archived table.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The table was archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteTableEnvelope" + }, + "example": { + "data": { + "id": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/columns": { + "post": { + "operationId": "addTableColumn", + "summary": "Add Column", + "description": "Add a column to the table schema. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"phone\",\n \"type\": \"string\",\n \"required\": false,\n \"unique\": false\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the column definition to add.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was added.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + }, + "example": { + "data": { + "columns": [ + { + "id": "col_a1b2c3", + "name": "email", + "type": "string", + "required": true, + "unique": true + }, + { + "id": "col_d4e5f6", + "name": "name", + "type": "string", + "required": true, + "unique": false + }, + { + "id": "col_x9y8z7", + "name": "phone", + "type": "string", + "required": false, + "unique": false + } + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableColumn", + "summary": "Update Column", + "description": "Update a column by name — rename it, change its type, or toggle its required/unique constraints. Provide at least one field in `updates`. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone\",\n \"updates\": {\n \"name\": \"phone_number\",\n \"required\": true\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the current column name, and the fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableColumn", + "summary": "Delete Column", + "description": "Delete a column from the table schema by name. A table must always keep at least one column. Returns the table's full column list after the change.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"phone_number\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the name of the column to delete.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteColumnBody" + } + } + } + }, + "responses": { + "200": { + "description": "The column was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColumnsEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows": { + "get": { + "operationId": "listTableRows", + "summary": "List Rows", + "description": "Query rows from a table with optional filtering, sorting, and cursor pagination. `filter` and `sort` are passed as JSON-encoded query parameters and key on column names. Pagination uses an opaque cursor: pass the `nextCursor` from a previous response to fetch the next page; `nextCursor` is null on the final page. Total row count is available as `rowCount` on the table resource.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows?workspaceId=YOUR_WORKSPACE_ID&limit=50\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "$ref": "#/components/parameters/FilterQuery" + }, + { + "$ref": "#/components/parameters/SortQuery" + }, + { + "$ref": "#/components/parameters/LimitQuery" + }, + { + "$ref": "#/components/parameters/CursorQuery" + } + ], + "responses": { + "200": { + "description": "Rows matching the query.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowListEnvelope" + }, + "example": { + "data": [ + { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "nextCursor": "eyJvZmZzZXQiOjUwfQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createTableRows", + "summary": "Create Rows", + "description": "Insert one or many rows. Send a single-row body (`{ data }`) to insert one row, or a batch body (`{ rows }`) to insert up to 1000 rows in one request. The response shape mirrors the request: a single insert returns `{ data: { row } }`, a batch insert returns `{ data: { rows, insertedCount } }`. Row `data` is keyed by column name.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": {\n \"email\": \"user@example.com\",\n \"name\": \"Jane Doe\"\n }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "Either a single-row payload or a batch payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsBody" + }, + "examples": { + "single": { + "summary": "Insert a single row", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + } + } + }, + "batch": { + "summary": "Insert multiple rows", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rows": [ + { + "email": "a@example.com", + "name": "Ada" + }, + { + "email": "b@example.com", + "name": "Babbage" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The row(s) were inserted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRowsResponse" + }, + "examples": { + "single": { + "summary": "Single insert response", + "value": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "Jane Doe" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + } + } + }, + "batch": { + "summary": "Batch insert response", + "value": { + "data": { + "rows": [ + { + "id": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "data": { + "email": "a@example.com", + "name": "Ada" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + { + "id": "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "data": { + "email": "b@example.com", + "name": "Babbage" + }, + "position": 1, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + } + ], + "insertedCount": 2 + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "updateTableRows", + "summary": "Update Rows by Filter", + "description": "Bulk-update every row matching a filter, applying the same partial `data` patch to each. The filter must contain at least one condition. `updatedRowIds` is always returned (empty when nothing matched).", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"filter\": { \"status\": \"pending\" },\n \"data\": { \"status\": \"active\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, a non-empty filter, the patch data, and an optional row cap.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsByFilterBody" + } + } + } + }, + "responses": { + "200": { + "description": "The matching rows were updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowsEnvelope" + }, + "example": { + "data": { + "updatedCount": 3, + "updatedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85", + "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + ] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRows", + "summary": "Delete Rows", + "description": "Delete rows in bulk, either by a non-empty filter or by an explicit list of row ids. Provide exactly one of `filter` or `rowIds`. For id-based deletes the response also reports `requestedCount` and any `missingRowIds`; these fields are omitted for filter-based deletes.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"rowIds\": [\"row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93\", \"row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85\"]\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and either a non-empty filter or an explicit list of row ids.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsBody" + }, + "examples": { + "byIds": { + "summary": "Delete specific rows by id", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "rowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ] + } + }, + "byFilter": { + "summary": "Delete rows matching a filter", + "value": { + "workspaceId": "YOUR_WORKSPACE_ID", + "filter": { + "status": "archived" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The rows were deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowsEnvelope" + }, + "examples": { + "byIds": { + "summary": "Id-based delete response", + "value": { + "data": { + "deletedCount": 2, + "deletedRowIds": [ + "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93", + "row_2a4c6e8d0b1f4d3e917c5b7d9f1a3c85" + ], + "requestedCount": 2, + "missingRowIds": [] + } + } + }, + "byFilter": { + "summary": "Filter-based delete response", + "value": { + "data": { + "deletedCount": 5, + "deletedRowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/{rowId}": { + "get": { + "operationId": "getTableRow", + "summary": "Get Row", + "description": "Get a single row by id.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The requested row.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "patch": { + "operationId": "updateTableRow", + "summary": "Update Row", + "description": "Partially update a single row by id. The `data` patch is keyed by column name and merges into the existing row.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"name\": \"Updated Name\" }\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace and the partial row data to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RowEnvelope" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteTableRow", + "summary": "Delete Row", + "description": "Delete a single row by id. Returns `deletedCount` and `deletedRowIds`, mirroring the bulk delete shape.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/{rowId}?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + }, + { + "$ref": "#/components/parameters/RowId" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The row was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRowEnvelope" + }, + "example": { + "data": { + "deletedCount": 1, + "deletedRowIds": ["row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07"] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/tables/{tableId}/rows/upsert": { + "post": { + "operationId": "upsertTableRow", + "summary": "Upsert Row", + "description": "Insert a row, or update the existing row that conflicts on a unique column. When `conflictTarget` is omitted the server resolves the conflict against the table's single unique column. The response reports whether the row was inserted or updated.", + "tags": ["Tables"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/tables/{tableId}/rows/upsert\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"data\": { \"email\": \"user@example.com\", \"name\": \"John\" },\n \"conflictTarget\": \"email\"\n }'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/TableId" + } + ], + "requestBody": { + "required": true, + "description": "The workspace, the row data, and an optional unique column to resolve the conflict against.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowBody" + } + } + } + }, + "responses": { + "200": { + "description": "The row was inserted or updated.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRowEnvelope" + }, + "example": { + "data": { + "row": { + "id": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07", + "data": { + "email": "user@example.com", + "name": "John" + }, + "position": 0, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-01-15T10:30:00.000Z" + }, + "operation": "insert" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "TableId": { + "name": "tableId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "description": "The unique identifier of the table." + }, + "RowId": { + "name": "rowId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "description": "The unique identifier of the row." + }, + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "The unique identifier of the workspace that owns the table." + }, + "FilterQuery": { + "name": "filter", + "in": "query", + "required": false, + "description": "JSON-encoded filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition.", + "schema": { + "type": "string" + } + }, + "SortQuery": { + "name": "sort", + "in": "query", + "required": false, + "description": "JSON-encoded sort object mapping column name to direction. Example: {\"created_at\": \"desc\"}.", + "schema": { + "type": "string" + } + }, + "LimitQuery": { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum rows to return (1-1000, default 100).", + "schema": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 1000 + } + }, + "CursorQuery": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` from a previous response to fetch the next page. Omit for the first page.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "Maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitRemaining": { + "description": "Number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer" + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "RetryAfter": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "description": "Canonical v2 error envelope.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable error code.", + "example": "BAD_REQUEST" + }, + "message": { + "type": "string", + "description": "Human-readable error message." + }, + "details": { + "description": "Optional structured error details, such as per-field validation issues." + } + } + } + } + }, + "Column": { + "type": "object", + "description": "A column definition in a table schema.", + "required": ["name", "type"], + "properties": { + "id": { + "type": "string", + "description": "Stable server-assigned column id. May be absent on legacy columns created before id backfill.", + "example": "col_a1b2c3" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "workflowGroupId": { + "type": "string", + "description": "Set when the column is the output of a workflow group." + } + } + }, + "ColumnInput": { + "type": "object", + "description": "Column definition supplied when creating a table or adding a column.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "email" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + } + } + }, + "Table": { + "type": "object", + "description": "A user-defined table with a typed column schema.", + "required": [ + "id", + "name", + "description", + "schema", + "rowCount", + "maxRows", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique table identifier.", + "example": "tbl_92e4c6a8b0d24f1e8a3c5d7b9f0e2a14" + }, + "name": { + "type": "string", + "description": "Table name.", + "example": "contacts" + }, + "description": { + "type": ["string", "null"], + "description": "Optional description of the table. Null when not set.", + "example": "Customer contact records" + }, + "schema": { + "type": "object", + "description": "Table schema definition.", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "description": "Array of column definitions for the table.", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + }, + "rowCount": { + "type": "integer", + "description": "Current number of rows in the table." + }, + "maxRows": { + "type": "integer", + "description": "Maximum rows allowed by the current billing plan." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the table was last modified." + } + } + }, + "RowData": { + "type": "object", + "additionalProperties": true, + "description": "Row cells keyed by column name. Each value is typed per its column definition.", + "example": { + "email": "jane@example.com", + "name": "Jane Doe", + "age": 30 + } + }, + "Row": { + "type": "object", + "description": "A single row in a table.", + "required": ["id", "data", "position", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "string", + "description": "Unique row identifier.", + "example": "row_6b8d0f2a4c3e4e5da28f7c9b1d3f5a07" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "position": { + "type": "integer", + "description": "Row's position/order in the table." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the row was last modified." + } + } + }, + "Filter": { + "type": "object", + "additionalProperties": true, + "minProperties": 1, + "description": "Filter object keyed by column name. Supports equality ({\"status\": \"active\"}), comparison operators ({\"age\": {\"$gt\": 18}}), and $and/$or composition. Must contain at least one condition.", + "example": { + "status": "active" + } + }, + "CreateTableBody": { + "type": "object", + "description": "Payload to create a new table.", + "required": ["workspaceId", "name", "schema"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that will own the table." + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 128, + "description": "Table name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "contacts" + }, + "description": { + "type": "string", + "maxLength": 500, + "description": "Optional description of the table." + }, + "schema": { + "type": "object", + "required": ["columns"], + "description": "The table's column schema.", + "properties": { + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "description": "Column definitions. A table must have between 1 and 50 columns.", + "items": { + "$ref": "#/components/schemas/ColumnInput" + } + } + } + } + } + }, + "AddColumnBody": { + "type": "object", + "description": "Payload to add a column to a table.", + "required": ["workspaceId", "column"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "column": { + "type": "object", + "description": "The column definition to add.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "Column name. Starts with a letter or underscore; contains only alphanumerics and underscores.", + "example": "phone" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "Data type of the column." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "default": false, + "description": "Whether values in this column must be unique across all rows." + }, + "position": { + "type": "integer", + "minimum": 0, + "description": "Zero-based insert position in the column order. Appended at the end when omitted." + } + } + } + } + }, + "UpdateColumnBody": { + "type": "object", + "description": "Payload to update an existing column by name.", + "required": ["workspaceId", "columnName", "updates"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The current name of the column to update.", + "example": "phone" + }, + "updates": { + "type": "object", + "description": "Fields to change. Provide at least one.", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "maxLength": 50, + "description": "New column name.", + "example": "phone_number" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "date", "json"], + "description": "New data type for the column." + }, + "required": { + "type": "boolean", + "description": "Whether the column requires a value on insert." + }, + "unique": { + "type": "boolean", + "description": "Whether values in this column must be unique across all rows." + } + } + } + } + }, + "DeleteColumnBody": { + "type": "object", + "description": "Payload to delete a column by name.", + "required": ["workspaceId", "columnName"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "columnName": { + "type": "string", + "description": "The name of the column to delete.", + "example": "phone_number" + } + } + }, + "CreateRowSingleBody": { + "type": "object", + "description": "Insert a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "afterRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly after this row id. Mutually exclusive with beforeRowId." + }, + "beforeRowId": { + "type": "string", + "minLength": 1, + "description": "Insert directly before this row id. Mutually exclusive with afterRowId." + } + } + }, + "CreateRowBatchBody": { + "type": "object", + "description": "Insert multiple rows in one request.", + "required": ["workspaceId", "rows"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rows": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Rows to insert. Each entry is keyed by column name. Up to 1000 rows per request.", + "items": { + "$ref": "#/components/schemas/RowData" + } + } + } + }, + "CreateRowsBody": { + "description": "Either a single-row payload or a batch payload.", + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRowSingleBody" + }, + { + "$ref": "#/components/schemas/CreateRowBatchBody" + } + ] + }, + "UpdateRowsByFilterBody": { + "type": "object", + "description": "Bulk-update rows matching a filter.", + "required": ["workspaceId", "filter", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to update." + } + } + }, + "DeleteRowsByFilterBody": { + "type": "object", + "description": "Delete rows matching a filter.", + "required": ["workspaceId", "filter"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of matching rows to delete." + } + } + }, + "DeleteRowsByIdsBody": { + "type": "object", + "description": "Delete an explicit list of rows by id.", + "required": ["workspaceId", "rowIds"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "rowIds": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "Row ids to delete. Up to 1000 ids per request.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "description": "Maximum number of rows to delete." + } + } + }, + "DeleteRowsBody": { + "description": "Provide exactly one of `filter` or `rowIds`.", + "oneOf": [ + { + "$ref": "#/components/schemas/DeleteRowsByFilterBody" + }, + { + "$ref": "#/components/schemas/DeleteRowsByIdsBody" + } + ] + }, + "UpdateRowBody": { + "type": "object", + "description": "Partial update for a single row.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + } + } + }, + "UpsertRowBody": { + "type": "object", + "description": "Insert-or-update a row keyed by a unique column.", + "required": ["workspaceId", "data"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "The workspace that owns the table." + }, + "data": { + "$ref": "#/components/schemas/RowData" + }, + "conflictTarget": { + "type": "string", + "minLength": 1, + "description": "Name of the unique column to resolve the conflict against. When omitted, the server uses the table's single unique column." + } + } + }, + "TableEnvelope": { + "type": "object", + "description": "A single table wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["table"], + "properties": { + "table": { + "$ref": "#/components/schemas/Table" + } + } + } + } + }, + "TableListEnvelope": { + "type": "object", + "description": "A page of tables. `nextCursor` is always null because the full bounded workspace set is returned in one page.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Table" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null when there are no more pages." + } + } + }, + "DeleteTableEnvelope": { + "type": "object", + "description": "Confirmation that a table was archived.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The id of the archived table." + } + } + } + } + }, + "ColumnsEnvelope": { + "type": "object", + "description": "The table's full column list after a column mutation.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + } + } + } + } + }, + "RowEnvelope": { + "type": "object", + "description": "A single row wrapped in the v2 data envelope.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + } + } + } + } + }, + "RowListEnvelope": { + "type": "object", + "description": "A cursor-paginated page of rows.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the final page." + } + } + }, + "BatchInsertRowsEnvelope": { + "type": "object", + "description": "Result of a batch row insert.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["rows", "insertedCount"], + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Row" + } + }, + "insertedCount": { + "type": "integer", + "description": "Number of rows inserted." + } + } + } + } + }, + "CreateRowsResponse": { + "description": "A single-row insert returns `{ data: { row } }`; a batch insert returns `{ data: { rows, insertedCount } }`.", + "oneOf": [ + { + "$ref": "#/components/schemas/RowEnvelope" + }, + { + "$ref": "#/components/schemas/BatchInsertRowsEnvelope" + } + ] + }, + "UpdateRowsEnvelope": { + "type": "object", + "description": "Result of a bulk update-by-filter.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["updatedCount", "updatedRowIds"], + "properties": { + "updatedCount": { + "type": "integer", + "description": "Number of rows updated." + }, + "updatedRowIds": { + "type": "array", + "description": "Ids of the updated rows. Empty when nothing matched.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowsEnvelope": { + "type": "object", + "description": "Result of a bulk delete. `requestedCount` and `missingRowIds` are present only for id-based deletes.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Number of rows deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "Ids of the deleted rows.", + "items": { + "type": "string" + } + }, + "requestedCount": { + "type": "integer", + "description": "Number of row ids requested. Present only for id-based deletes." + }, + "missingRowIds": { + "type": "array", + "description": "Requested ids that did not exist. Present only for id-based deletes.", + "items": { + "type": "string" + } + } + } + } + } + }, + "DeleteRowEnvelope": { + "type": "object", + "description": "Result of a single-row delete.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["deletedCount", "deletedRowIds"], + "properties": { + "deletedCount": { + "type": "integer", + "description": "Always 1 when a row was deleted." + }, + "deletedRowIds": { + "type": "array", + "description": "The id of the deleted row.", + "items": { + "type": "string" + } + } + } + } + } + }, + "UpsertRowEnvelope": { + "type": "object", + "description": "Result of an upsert, including whether the row was inserted or updated.", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["row", "operation"], + "properties": { + "row": { + "$ref": "#/components/schemas/Row" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid request. The request body, query parameters, or a JSON-encoded filter/sort failed validation. Inspect `error.details` for field-level issues.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request", + "details": [ + { + "path": "schema.columns", + "message": "Table must have at least one column" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the X-API-Key header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. The API key cannot access the target workspace, or a plan limit (such as the maximum number of tables) has been reached.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The requested table or row was not found. Verify the id is correct and belongs to the specified workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Table not found" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration in the Retry-After header before retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/RetryAfter" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-15T10:31:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json new file mode 100644 index 00000000000..341c14ceb5c --- /dev/null +++ b/apps/docs/openapi-v2-workflows.json @@ -0,0 +1,1024 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Sim API v2 — Workflows", + "description": "Version 2 of the Sim REST API for listing workflows, inspecting workflow detail, and managing deployments (deploy, undeploy, rollback).\n\nThe v2 surface standardizes on a single response family across every endpoint:\n- Single resource: `{ \"data\": T }`\n- List: `{ \"data\": T[], \"nextCursor\": string | null }`\n- Error: `{ \"error\": { \"code\": string, \"message\": string, \"details\"?: unknown } }`\n\nLists use an opaque cursor (Stripe/Slack-style): send `limit` and `cursor`, receive `{ data, nextCursor }`. Cursors are opaque tokens — pass back the `nextCursor` from the previous page verbatim and stop when it is `null`. Total counts are not returned on lists.\n\nRate-limit state is carried in the `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers (not in the body). A throttled request returns `429` with a `Retry-After` header.\n\nAuthenticate every request with an API key in the `X-API-Key` header.", + "version": "2.0.0", + "contact": { + "name": "Sim Support", + "email": "help@sim.ai", + "url": "https://www.sim.ai" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.sim.ai", + "description": "Production" + } + ], + "tags": [ + { + "name": "Workflows", + "description": "List workflows, inspect workflow detail, and manage deployments (deploy, undeploy, rollback) on the v2 API." + } + ], + "security": [ + { + "apiKey": [] + } + ], + "paths": { + "/api/v2/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "List Workflows", + "description": "Retrieve workflows in a workspace using opaque cursor-based pagination. Results are ordered deterministically; follow `nextCursor` to page through the full set, and stop when it is `null`.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Filter results to only include workflows within this folder.", + "schema": { + "type": "string", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + } + }, + { + "name": "deployedOnly", + "in": "query", + "required": false, + "description": "When true, only return workflows that are currently deployed. Useful for listing workflows available for API execution.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of workflows to return per page. Must be between 1 and 100.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor returned from a previous request's `nextCursor` field. Omit for the first page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of workflows.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "Workflows for the current page.", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for fetching the next page. `null` when there are no more results." + } + } + }, + "example": { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + ], + "nextCursor": "eyJzb3J0T3JkZXIiOjAsImNyZWF0ZWRBdCI6IjIwMjYtMDEtMTBUMDk6MDA6MDAuMDAwWiIsImlkIjoiM2IxZjdjOTIifQ==" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Retrieve a single workflow, including its workflow-level variables and trigger input field definitions. Returns 404 when the workflow does not exist or you do not have access to it (existence is not leaked).", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/workflows/{id}\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "The requested workflow.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/WorkflowDetail" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer Support Agent", + "description": "Routes incoming support tickets and drafts responses", + "folderId": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 142, + "lastRunAt": "2026-06-20T14:15:22.000Z", + "variables": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + }, + "inputs": [ + { + "name": "ticketBody", + "type": "string", + "description": "The raw text of the incoming support ticket." + } + ], + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-06-18T16:45:00.000Z" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Deploy the workflow's current draft state. Creates a new deployment version, makes it live for API execution, and activates schedules and triggers. Optionally accepts a `name` and `description` for the new version; the request body may be omitted entirely. Returns 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"name\": \"Release 4\", \"description\": \"Fixes the agent prompt\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version. The request body may be omitted entirely.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Optional label for the new deployment version.", + "example": "Release 4" + }, + "description": { + "type": "string", + "maxLength": 50000, + "nullable": true, + "description": "Optional summary of what changed in this version.", + "example": "Fixes the agent prompt" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow deployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/DeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 4, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Take the workflow offline. API execution stops and schedules, webhooks, and other deployment side effects are removed. Deployment versions are retained, so the workflow can be deployed again later. Returns 400 when the workflow is not currently deployed, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v2/workflows/{id}/deploy\" \\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "responses": { + "200": { + "description": "Workflow undeployed successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/UndeployResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/workflows/{id}/rollback": { + "post": { + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Roll the live deployment back to a previous deployment version. The workflow must currently be deployed. By default the version immediately preceding the currently active one is re-activated; pass `version` to target a specific deployment version instead. The workflow's draft state is not modified. Returns 400 when the workflow is not deployed or there is no version to roll back to, 404 when the workflow does not exist or you do not have access to it, and 423 when the workflow is locked.", + "tags": ["Workflows"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\n \"https://www.sim.ai/api/v2/workflows/{id}/rollback\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"version\": 3}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkflowId" + } + ], + "requestBody": { + "required": false, + "description": "Optional rollback target. The request body may be omitted entirely to roll back to the version immediately preceding the active one.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "The deployment version to re-activate. Defaults to the version immediately preceding the active one.", + "example": 3 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Workflow rolled back successfully.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/RollbackResult" + } + } + }, + "example": { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "version": 3, + "warnings": [] + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceId": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace to list workflows from.", + "schema": { + "type": "string", + "minLength": 1, + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "WorkflowId": { + "name": "id", + "in": "path", + "required": true, + "description": "The unique workflow identifier.", + "schema": { + "type": "string", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + } + } + }, + "headers": { + "RateLimitLimit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 60 + } + }, + "RateLimitRemaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 59 + } + }, + "RateLimitReset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-06-29T21:50:00.000Z" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "description": "Canonical v2 error envelope. Every non-2xx response uses this shape.", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "Stable, machine-readable error code.", + "enum": [ + "BAD_REQUEST", + "UNAUTHORIZED", + "USAGE_LIMIT_EXCEEDED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "LOCKED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ] + }, + "message": { + "type": "string", + "description": "Human-readable description of what went wrong." + }, + "details": { + "description": "Optional structured detail about the error (e.g. field-level validation issues). Shape varies by error code; absent when there is nothing to add." + } + } + } + } + }, + "WorkflowListItem": { + "type": "object", + "description": "Summary representation of a workflow returned by the list endpoint.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "name": { + "type": "string", + "description": "Human-readable workflow name.", + "example": "Customer Support Agent" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description of what the workflow does. `null` when unset.", + "example": "Routes incoming support tickets and drafts responses" + }, + "folderId": { + "type": "string", + "nullable": true, + "description": "The folder this workflow belongs to. `null` when at the workspace root.", + "example": "8a4c2e6b-0d1f-4b3a-9c5e-7f2d8b4a6c91" + }, + "workspaceId": { + "type": "string", + "description": "The workspace this workflow belongs to.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is currently deployed and available for API execution.", + "example": true + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent deployment. `null` when never deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "runCount": { + "type": "integer", + "description": "Total number of times this workflow has been executed.", + "example": 142 + }, + "lastRunAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the most recent execution. `null` when never run.", + "example": "2026-06-20T14:15:22.000Z" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was created.", + "example": "2026-01-10T09:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the workflow was last modified.", + "example": "2026-06-18T16:45:00.000Z" + } + } + }, + "WorkflowInputField": { + "type": "object", + "description": "A single trigger input field extracted from the workflow's input-definition block. Use these to construct the `input` object when executing the workflow.", + "required": ["name", "type"], + "properties": { + "name": { + "type": "string", + "description": "Field name as referenced by the workflow.", + "example": "ticketBody" + }, + "type": { + "type": "string", + "description": "Declared field type (e.g. `string`, `number`, `boolean`, `object`).", + "example": "string" + }, + "description": { + "type": "string", + "description": "Optional human-readable description of the field.", + "example": "The raw text of the incoming support ticket." + } + } + }, + "WorkflowDetail": { + "type": "object", + "description": "Full workflow representation: every list field plus workflow-level variables and trigger input field definitions.", + "required": [ + "id", + "name", + "description", + "folderId", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "variables", + "inputs", + "createdAt", + "updatedAt" + ], + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowListItem" + }, + { + "type": "object", + "required": ["variables", "inputs"], + "properties": { + "variables": { + "type": "object", + "description": "Workflow-scoped variables keyed by variable id. Each value is a structured variable object (`{ id, name, type, value, ... }`); only the inner `value` is user-defined. Empty object when the workflow defines no variables.", + "additionalProperties": true, + "example": { + "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60": { + "id": "8d2c1f0a-3b4e-4c5d-9a6f-1e2d3c4b5a60", + "name": "supportEmail", + "type": "string", + "value": "support@example.com" + } + } + }, + "inputs": { + "type": "array", + "description": "The workflow's trigger input field definitions.", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + } + } + } + } + ] + }, + "DeploymentState": { + "type": "object", + "description": "Base deployment state shared by deploy, undeploy, and rollback results.", + "required": ["id", "isDeployed", "deployedAt", "warnings"], + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow is deployed and available for API execution after the operation." + }, + "deployedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "ISO 8601 timestamp of the active deployment. `null` when the workflow is not deployed.", + "example": "2026-06-12T10:30:00.000Z" + }, + "warnings": { + "type": "array", + "description": "Non-fatal warnings. Present when trigger, schedule, or MCP side-effect sync is still in progress or needs a redeploy. Empty array when there is nothing to report.", + "items": { + "type": "string" + } + } + } + }, + "DeployResult": { + "description": "Deployment state returned after a successful deploy. `isDeployed` is always `true`.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that is now active. May be omitted when the version number is unavailable.", + "example": 4 + } + } + } + ] + }, + "UndeployResult": { + "description": "Deployment state returned after a successful undeploy. `isDeployed` is always `false`, `deployedAt` is always `null`, and no `version` is included.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + } + ] + }, + "RollbackResult": { + "description": "Deployment state returned after a successful rollback. `isDeployed` is always `true` and `version` identifies the re-activated deployment version.", + "allOf": [ + { + "$ref": "#/components/schemas/DeploymentState" + }, + { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "integer", + "description": "The deployment version that was re-activated.", + "example": 3 + } + } + } + ] + } + }, + "responses": { + "BadRequest": { + "description": "The request was malformed or failed validation. Inspect `error.details` for field-level issues. Also returned when an operation is not allowed in the current state (e.g. undeploying a workflow that is not deployed).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "workspaceId is required", + "details": [ + { + "path": ["workspaceId"], + "message": "workspaceId is required" + } + ] + } + } + } + } + }, + "Unauthorized": { + "description": "Invalid or missing API key. Ensure the `X-API-Key` header is set with a valid key.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + } + } + } + } + }, + "Forbidden": { + "description": "Access denied. You do not have permission to access the requested workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Access denied" + } + } + } + } + }, + "NotFound": { + "description": "The workflow does not exist or you do not have access to it. Existence is not leaked, so an access failure is reported as 404.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Workflow not found" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request body exceeds the maximum allowed size.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The workflow is locked and cannot be modified. Wait for the in-progress operation to finish, then retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked and cannot be modified" + } + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded. Wait for the duration specified in the `Retry-After` header before retrying.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying the request.", + "schema": { + "type": "integer", + "example": 30 + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/RateLimitLimit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/RateLimitRemaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/RateLimitReset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-06-29T21:50:00.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected error occurred while processing the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + } + } + } +} diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 9610232d357..f3dbc231e69 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -31,21 +31,13 @@ import { internalErrorResponse, listResponse, } from '@/app/api/v1/admin/responses' -import { - type AdminAuditLog, - createPaginationMeta, - parsePaginationParams, - toAdminAuditLog, -} from '@/app/api/v1/admin/types' +import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types' import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') export const GET = withRouteHandler( withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const parsed = await parseRequest( v1AdminListAuditLogsContract, request, @@ -56,6 +48,7 @@ export const GET = withRouteHandler( try { const query = parsed.data.query + const { limit, offset } = query const conditions = buildFilterConditions({ action: query.action, resourceType: query.resourceType, diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index 69b773accf5..18bc485fbe6 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -29,6 +29,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -152,7 +153,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index 68b79e3a78a..83234df0a7b 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -45,6 +45,7 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -144,7 +145,7 @@ export const PATCH = withRouteHandler( { params: routeParams }, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 5c9525ca7ff..e6c84765379 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -101,7 +101,10 @@ export const POST = withRouteHandler( }) } catch (error) { logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to requeue outbox event' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index f88ac55536c..57ce53c49f5 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -77,7 +77,10 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + return NextResponse.json( + { success: false, error: 'Failed to list outbox events' }, + { status: 500 } + ) } }) ) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index b7f7c162118..1432b46d37b 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -41,6 +41,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { + adminInvalidJsonResponse, adminValidationErrorResponse, badRequestResponse, internalErrorResponse, @@ -181,7 +182,7 @@ export const POST = withRouteHandler( {}, { validationErrorResponse: adminValidationErrorResponse, - invalidJson: 'throw', + invalidJsonResponse: adminInvalidJsonResponse, } ) if (!parsed.success) return parsed.response diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 323d1b82bdd..01eb14996f3 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -25,20 +25,21 @@ type AuthResult = | { success: false; response: NextResponse } /** - * Validates enterprise audit log access for the given user. - * - * Checks: - * 1. User belongs to an organization - * 2. User has admin or owner role - * 3. Organization has an active enterprise subscription - * - * Returns the organization ID and all member user IDs on success, - * or an error response on failure. + * Structured enterprise audit-access result shared by the v1 and v2 surfaces so + * each version can render the failure in its own response envelope. */ -export async function validateEnterpriseAuditAccess( +export type EnterpriseAuditAccessResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; status: number; message: string } + +/** + * Core enterprise audit-access check (no response rendering). See + * {@link validateEnterpriseAuditAccess} for the policy checks performed. + */ +export async function resolveEnterpriseAuditAccess( userId: string, targetOrganizationId?: string -): Promise { +): Promise { const [membership] = await db .select({ organizationId: member.organizationId, role: member.role }) .from(member) @@ -50,31 +51,16 @@ export async function validateEnterpriseAuditAccess( .limit(1) if (!membership) { - return { - success: false, - response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }), - } + return { success: false, status: 403, message: 'Not a member of any organization' } } if (membership.role !== 'admin' && membership.role !== 'owner') { - return { - success: false, - response: NextResponse.json( - { error: 'Organization admin or owner role required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Organization admin or owner role required' } } const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId) if (billingBlocked) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } const [orgSub, orgMembers] = await Promise.all([ @@ -96,13 +82,7 @@ export async function validateEnterpriseAuditAccess( ]) if (orgSub.length === 0) { - return { - success: false, - response: NextResponse.json( - { error: 'Active enterprise subscription required' }, - { status: 403 } - ), - } + return { success: false, status: 403, message: 'Active enterprise subscription required' } } const orgMemberIds = orgMembers.map((m) => m.userId) @@ -115,9 +95,29 @@ export async function validateEnterpriseAuditAccess( return { success: true, - context: { - organizationId: membership.organizationId, - orgMemberIds, - }, + context: { organizationId: membership.organizationId, orgMemberIds }, + } +} + +/** + * Validates enterprise audit log access for the given user. + * + * Checks: + * 1. User belongs to an organization + * 2. User has admin or owner role + * 3. Organization has an active enterprise subscription + * + * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` + * response body. + */ +export async function validateEnterpriseAuditAccess( + userId: string, + targetOrganizationId?: string +): Promise { + const result = await resolveEnterpriseAuditAccess(userId, targetOrganizationId) + if (result.success) return { success: true, context: result.context } + return { + success: false, + response: NextResponse.json({ error: result.message }, { status: result.status }), } } diff --git a/apps/sim/app/api/v1/logs/filters.ts b/apps/sim/app/api/v1/logs/filters.ts index 0e409e4d53f..8e40ca1db51 100644 --- a/apps/sim/app/api/v1/logs/filters.ts +++ b/apps/sim/app/api/v1/logs/filters.ts @@ -1,5 +1,5 @@ import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' export interface LogFilters { workspaceId: string @@ -103,8 +103,14 @@ export function buildLogFilters(filters: LogFilters): SQL { return conditions.length > 0 ? and(...conditions)! : sql`true` } +/** + * Order rows by `(startedAt, id)` so the sort matches the keyset cursor's tuple + * comparison in {@link buildLogFilters}. Without the `id` tie-break, rows that + * share a `startedAt` have an arbitrary order and can be skipped or duplicated + * across pages. + */ export function getOrderBy(order: 'desc' | 'asc' = 'desc') { return order === 'desc' - ? desc(workflowExecutionLogs.startedAt) - : sql`${workflowExecutionLogs.startedAt} ASC` + ? [desc(workflowExecutionLogs.startedAt), desc(workflowExecutionLogs.id)] + : [asc(workflowExecutionLogs.startedAt), asc(workflowExecutionLogs.id)] } diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index bd6a2185dd5..74f992fc207 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -124,7 +124,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const logs = await baseQuery .where(conditions) - .orderBy(orderBy) + .orderBy(...orderBy) .limit(params.limit + 1) const hasMore = logs.length > params.limit diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index c9f757d91df..0f084feec1e 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -162,36 +162,46 @@ export function createRateLimitResponse(result: RateLimitResult): NextResponse { } /** - * Verify that the API key is allowed to access the requested workspace. - * - * Enforces two policies: + * Structured workspace-access failure shared by the v1 and v2 API surfaces so + * each version can render the failure in its own response envelope. + */ +export interface WorkspaceAccessError { + status: number + code: 'FORBIDDEN' + message: string +} + +/** + * Core workspace-scope check (no response rendering). Enforces two policies: * - A workspace-scoped key may only target its own workspace. * - A personal key is rejected when the workspace has disabled personal API * keys (`allowPersonalApiKeys = false`), matching the workflow-execution * surface in `app/api/workflows/middleware.ts`. */ -export async function checkWorkspaceScope( +export async function resolveWorkspaceScope( rateLimit: RateLimitResult, requestedWorkspaceId: string -): Promise { +): Promise { if ( rateLimit.keyType === 'workspace' && rateLimit.workspaceId && rateLimit.workspaceId !== requestedWorkspaceId ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + } } if (rateLimit.keyType === 'personal') { const settings = await getWorkspaceBillingSettings(requestedWorkspaceId) if (!settings?.allowPersonalApiKeys) { - return NextResponse.json( - { error: 'Personal API keys are not allowed for this workspace' }, - { status: 403 } - ) + return { + status: 403, + code: 'FORBIDDEN', + message: 'Personal API keys are not allowed for this workspace', + } } } @@ -214,21 +224,46 @@ export async function resolveWorkspaceRequestActor( } /** - * Validates workspace-scoped API key bounds and the user's workspace permission. - * Returns null on success, NextResponse on failure. + * Core workspace-access check (scope + the user's workspace permission level), + * shared by v1 and v2. Returns a structured failure or null on success. */ -export async function validateWorkspaceAccess( +export async function resolveWorkspaceAccess( rateLimit: RateLimitResult, userId: string, workspaceId: string, level: PermissionType = 'read' -): Promise { - const scopeError = await checkWorkspaceScope(rateLimit, workspaceId) +): Promise { + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permissionSatisfies(permission, level)) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } } return null } + +/** + * v1 wrapper: renders {@link resolveWorkspaceScope} as the v1 `{ error }` body. + */ +export async function checkWorkspaceScope( + rateLimit: RateLimitResult, + requestedWorkspaceId: string +): Promise { + const failure = await resolveWorkspaceScope(rateLimit, requestedWorkspaceId) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} + +/** + * v1 wrapper: renders {@link resolveWorkspaceAccess} as the v1 `{ error }` body. + * Returns null on success, NextResponse on failure. + */ +export async function validateWorkspaceAccess( + rateLimit: RateLimitResult, + userId: string, + workspaceId: string, + level: PermissionType = 'read' +): Promise { + const failure = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, level) + return failure ? NextResponse.json({ error: failure.message }, { status: failure.status }) : null +} diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts new file mode 100644 index 00000000000..d1fca3d0aa0 --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -0,0 +1,76 @@ +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogDetailAPI') + +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs/[id] + * + * Returns a single audit log entry scoped to the authenticated user's + * organization. Org-scoped (not workspace-scoped). Unlike v1, authorization + * (`checkRateLimit` → `validateEnterpriseAuditAccess`) runs BEFORE the untrusted + * param is parsed, fixing the v1 ordering inconsistency. The org-scope predicate + * is folded into the lookup so a non-org log reads as 404 (existence is not + * leaked). + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const parsed = await parseRequest(v2GetAuditLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { organizationId, orgMemberIds } = authResult.context + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) + + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) + + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + + return v2Data(formatAuditLogEntry(log), { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts new file mode 100644 index 00000000000..c785ccaaede --- /dev/null +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -0,0 +1,103 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/audit-logs + * + * Lists audit logs scoped to the authenticated user's organization. Org-scoped + * (not workspace-scoped): `resolveWorkspaceAccess` is intentionally NOT used — + * access is gated by enterprise org admin/owner membership. Auth ordering + * matches v1: `checkRateLimit` → `validateEnterpriseAuditAccess` run before the + * untrusted query is parsed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const authResult = await resolveEnterpriseAuditAccess(userId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + v2ListAuditLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return v2Error('BAD_REQUEST', 'actorId is not a member of your organization') + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + + if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) { + return v2Error('BAD_REQUEST', 'workspaceId does not belong to your organization') + } + + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: params.includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) + + return v2CursorList(data.map(formatAuditLogEntry), nextCursor ?? null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Audit logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts new file mode 100644 index 00000000000..9d2e6d603b9 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -0,0 +1,124 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import type { V2ErrorCode } from '@/app/api/v2/lib/response' +import { + rateLimitHeaders, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * GET /api/v2/files/[fileId] — Download file content (binary). + * + * The response carries no JSON envelope, so rate-limit state is surfaced via + * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. + * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. + */ +export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DownloadFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') + + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + + return new Response(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': fileRecord.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, + 'Content-Length': String(buffer.length), + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. + * + * Delegates to the shared orchestration, which is workspace-scoped and records + * its own audit entry (the request is forwarded so that entry captures client + * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather + * than v1's blanket 500. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds: [fileId], + request, + }) + + if (!result.success) { + const code: V2ErrorCode = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : result.errorCode === 'conflict' + ? 'CONFLICT' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to delete file') + } + + logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) + + return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts new file mode 100644 index 00000000000..dbc6e982068 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.ts @@ -0,0 +1,236 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2File, + v2ListFilesContract, + v2UploadFileContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + FileConflictError, + getWorkspaceFile, + listWorkspaceFiles, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FilesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface FileCursor { + uploadedAt: string + id: string +} + +/** Stable keyset ordering: `uploadedAt` ascending, `id` ascending as the tiebreaker. */ +function compareFiles(a: V2File, b: V2File): number { + if (a.uploadedAt !== b.uploadedAt) return a.uploadedAt < b.uploadedAt ? -1 : 1 + if (a.id !== b.id) return a.id < b.id ? -1 : 1 + return 0 +} + +/** + * GET /api/v2/files — List files in a workspace with cursor pagination. + * + * The shared {@link listWorkspaceFiles} manager returns the full active set + * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in + * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListFilesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const files = await listWorkspaceFiles(workspaceId) + + const items: V2File[] = files + .map((f) => ({ + id: f.id, + name: f.name, + size: f.size, + type: f.type, + key: f.key, + uploadedBy: f.uploadedBy, + uploadedAt: + f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), + })) + .sort(compareFiles) + + const decoded = cursor ? decodeCursor(cursor) : null + const afterCursor = decoded + ? items.filter( + (f) => + f.uploadedAt > decoded.uploadedAt || + (f.uploadedAt === decoded.uploadedAt && f.id > decoded.id) + ) + : items + + const hasMore = afterCursor.length > limit + const page = afterCursor.slice(0, limit) + const last = page.at(-1) + const nextCursor = + hasMore && last ? encodeCursor({ uploadedAt: last.uploadedAt, id: last.id }) : null + + return v2CursorList(page, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/files — Upload a file to a workspace. + * + * Authorization runs fully (rate limit → workspace write access) before the + * multipart body is buffered: the workspace is a contract-validated query param, + * so an unauthorized caller never streams a 100 MB body into memory. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'files') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2UploadFileContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'workspace file upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'workspace upload file', + }) + + const userFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + file.type || 'application/octet-stream' + ) + + logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: file.name, + description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, + request, + }) + + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) + const uploadedAt = + fileRecord?.uploadedAt instanceof Date + ? fileRecord.uploadedAt.toISOString() + : fileRecord?.uploadedAt + ? String(fileRecord.uploadedAt) + : new Date().toISOString() + + const responseFile: V2File = { + id: userFile.id, + name: userFile.name, + size: userFile.size, + type: userFile.type, + key: userFile.key, + uploadedBy: userId, + uploadedAt, + } + + return v2Data(responseFile, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + const message = getErrorMessage(error, 'Failed to upload file') + if (error instanceof FileConflictError || message.includes('already exists')) { + return v2Error('CONFLICT', message) + } + if (message.includes('Storage limit') || message.includes('storage limit')) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + + logger.error('Error uploading file', { error: message }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts new file mode 100644 index 00000000000..235c80707eb --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -0,0 +1,209 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { document, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocument, + v2DeleteKnowledgeDocumentContract, + v2GetKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteDocument } from '@/lib/knowledge/documents/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface DocumentDetailRouteParams { + params: Promise<{ id: string; documentId: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + } + + return v2Data({ document: documentDetail }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: DocumentDetailRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId, documentId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + const doc = docs[0] + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + await deleteDocument(documentId, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: doc.filename, + description: `Deleted document "${doc.filename}" from knowledge base via API`, + metadata: { knowledgeBaseId }, + request, + }) + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts new file mode 100644 index 00000000000..9f2c7b5367a --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -0,0 +1,306 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + type V2KnowledgeDocumentSummary, + v2ListKnowledgeDocumentsContract, + v2UploadKnowledgeDocumentContract, +} from '@/lib/api/contracts/v2/knowledge' +import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { + isPayloadSizeLimitError, + readFileToBufferWithLimit, + readFormDataWithLimit, +} from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + createSingleDocument, + type DocumentData, + getDocuments, + processDocumentsWithQueue, +} from '@/lib/knowledge/documents/service' +import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { validateFileType } from '@/lib/uploads/utils/validation' +import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDocumentsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const MAX_FILE_SIZE = 100 * 1024 * 1024 +const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 + +interface DocumentsRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A + * `404` is always `NOT_FOUND`; a `403` is masked as `NOT_FOUND` on reads and + * surfaced as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ +export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = + parsed.data.query + const { id: knowledgeBaseId } = parsed.data.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + // Opaque cursor encodes the underlying offset (upgradeable to keyset later). + const offset = cursor ? (decodeCursor<{ offset: number }>(cursor)?.offset ?? 0) : 0 + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + const documents: V2KnowledgeDocumentSummary[] = documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })) + + const nextCursor = documentsResult.pagination.hasMore + ? encodeCursor({ offset: offset + limit }) + : null + return v2CursorList(documents, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing documents`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * POST /api/v2/knowledge/[id]/documents — Upload a document to a knowledge base. + * + * Authorization runs fully before the multipart body is buffered: the workspace + * is a contract-validated query param (not a form field as in v1), so an + * unauthorized caller never streams a file into memory. Order: rate limit → + * KB ownership (write) → usage gate → buffered multipart read. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: DocumentsRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id: knowledgeBaseId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + // Fast usage gate before the storage write + indexing (the async backstop + // in processDocumentAsync still covers non-HTTP paths). + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + + let formData: FormData + try { + formData = await readFormDataWithLimit(request, { + maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES, + label: 'knowledge document upload body', + }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + return v2Error('BAD_REQUEST', 'Request body must be valid multipart form data') + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + if (!file) { + return v2Error('BAD_REQUEST', 'file form field is required') + } + + if (file.size > MAX_FILE_SIZE) { + return v2Error( + 'PAYLOAD_TOO_LARGE', + `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message) + } + + const buffer = await readFileToBufferWithLimit(file, { + maxBytes: MAX_FILE_SIZE, + label: 'knowledge document file', + }) + const contentType = file.type || 'application/octet-stream' + + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) + + const newDocument = await createSingleDocument( + { + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + }, + knowledgeBaseId, + requestId, + userId + ) + + const documentData: DocumentData = { + documentId: newDocument.id, + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, + } + + processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + // Processing errors are logged internally by the queue. + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: newDocument.id, + resourceName: file.name, + description: `Uploaded document "${file.name}" to knowledge base via API`, + metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, + request, + }) + + const document: V2KnowledgeDocumentSummary = { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + } + + return v2Data({ document }, { rateLimit, status: 201 }) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return v2Error('PAYLOAD_TOO_LARGE', error.message) + } + + if (error instanceof Error) { + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error uploading document`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts new file mode 100644 index 00000000000..79bb1b4b86c --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -0,0 +1,193 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + v2DeleteKnowledgeBaseContract, + v2GetKnowledgeBaseContract, + v2UpdateKnowledgeBaseContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface KnowledgeRouteParams { + params: Promise<{ id: string }> +} + +/** + * Resolves a knowledge base via the shared v1 ownership invariant + * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and + * renders any failure in the v2 envelope. A `404` (missing KB or workspace + * mismatch) is always `NOT_FOUND`; a `403` (no workspace access) is masked as + * `NOT_FOUND` on reads so cross-workspace KB existence never leaks, and surfaced + * as `FORBIDDEN` on writes. + */ +async function resolveKnowledgeBaseScoped( + id: string, + workspaceId: string, + userId: string, + rateLimit: RateLimitResult, + level: 'read' | 'write' +): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> { + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, level) + if (!(result instanceof NextResponse)) return result + if (result.status === 404) return v2Error('NOT_FOUND', 'Knowledge base not found') + return level === 'read' + ? v2Error('NOT_FOUND', 'Knowledge base not found') + : v2Error('FORBIDDEN', 'Access denied') +} + +/** GET /api/v2/knowledge/[id] — Get knowledge base details. */ +export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + return v2Data({ knowledgeBase: formatKnowledgeBase(result.kb) }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error getting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ +export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const updates: { + name?: string + description?: string + chunkingConfig?: { maxSize: number; minSize: number; overlap: number } + } = {} + if (name !== undefined) updates.name = name + if (description !== undefined) updates.description = description + if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig + + const updatedKb = await updateKnowledgeBase(id, updates, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: updatedKb.name, + description: `Updated knowledge base "${updatedKb.name}" via API`, + metadata: { updatedFields: Object.keys(updates) }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(updatedKb) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error updating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, context: KnowledgeRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + const result = await resolveKnowledgeBaseScoped( + id, + parsed.data.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + await deleteKnowledgeBase(id, requestId) + + recordAudit({ + workspaceId: parsed.data.query.workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: result.kb.name, + description: `Deleted knowledge base "${result.kb.name}" via API`, + request, + }) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts new file mode 100644 index 00000000000..d1fb7d5b10d --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -0,0 +1,140 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2CreateKnowledgeBaseContract, + v2ListKnowledgeBasesContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings' +import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2CursorList, + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/knowledge — List knowledge bases in a workspace. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListKnowledgeBasesContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const items = knowledgeBases.map(formatKnowledgeBase) + + // `getKnowledgeBases` returns the full bounded workspace set → single page. + return v2CursorList(items, null, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Error listing knowledge bases`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/knowledge — Create a new knowledge base. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2CreateKnowledgeBaseContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, description, chunkingConfig } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const kb = await createKnowledgeBase( + { + name, + description, + workspaceId, + userId, + embeddingModel: getConfiguredEmbeddingModel(), + embeddingDimension: EMBEDDING_DIMENSIONS, + chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 }, + }, + requestId + ) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: kb.id, + resourceName: kb.name, + description: `Created knowledge base "${kb.name}" via API`, + metadata: { chunkingConfig }, + request, + }) + + return v2Data({ knowledgeBase: formatKnowledgeBase(kb) }, { rateLimit, status: 201 }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof Error) { + if (error.message.includes('does not have permission')) { + return v2Error('FORBIDDEN', 'Access denied') + } + if ( + error.message.includes('Storage limit exceeded') || + error.message.includes('storage limit') + ) { + return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') + } + if (error.message.includes('already exists')) { + return v2Error('CONFLICT', 'Resource already exists') + } + } + + logger.error(`[${requestId}] Error creating knowledge base`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts new file mode 100644 index 00000000000..8f432bf467e --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -0,0 +1,299 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + type V2KnowledgeSearchResult, + v2SearchKnowledgeContract, +} from '@/lib/api/contracts/v2/knowledge' +import { isZodError, parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter } from '@/lib/knowledge/types' +import { + generateSearchEmbedding, + getDocumentMetadataByIds, + getQueryStrategy, + handleTagAndVectorSearch, + handleTagOnlySearch, + handleVectorOnlySearch, + type SearchResult, +} from '@/app/api/knowledge/search/utils' +import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2KnowledgeSearchAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'knowledge-search') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2SearchKnowledgeContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, topK, query, tagFilters } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's + // usage and frozen status before spending. Tag-only search is free, so skip it. + if (query && query.trim().length > 0) { + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } + + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) + ? parsed.data.body.knowledgeBaseIds + : [parsed.data.body.knowledgeBaseIds] + + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') + } + + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { + return v2Error( + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` + ) + } + + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } + + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) + } + + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } + + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' + ) + } + const queryEmbeddingModel = embeddingModels[0] + + let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null + + if (!hasQuery && hasFilters) { + results = await handleTagOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + }) + } else if (hasQuery && hasFilters) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleTagAndVectorSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + structuredFilters, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else if (hasQuery) { + const strategy = getQueryStrategy(accessibleKbIds.length, topK) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) + results = await handleVectorOnlySearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + } else { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') + } + + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + }) + } + + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) + ) + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map + }) + + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} + + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) + + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, + } + }) + + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + logger.error(`[${requestId}] Knowledge search error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts new file mode 100644 index 00000000000..e6c5e3dc5d2 --- /dev/null +++ b/apps/sim/app/api/v2/lib/response.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server' +import type { ZodError } from 'zod' +import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' +import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middleware' + +/** + * Runtime response helpers for the v2 API surface. Every v2 route renders its + * output through these so the envelope, error shape, and rate-limit headers stay + * identical across the whole surface. v2 routes reuse the v1 auth/rate-limit + * middleware and the platform domain services — these helpers only standardize + * the HTTP envelope. + */ + +export type V2ErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'PAYLOAD_TOO_LARGE' + | 'UNSUPPORTED_MEDIA_TYPE' + | 'USAGE_LIMIT_EXCEEDED' + | 'LOCKED' + | 'RATE_LIMITED' + | 'INTERNAL_ERROR' + +const STATUS_BY_CODE: Record = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + USAGE_LIMIT_EXCEEDED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + PAYLOAD_TOO_LARGE: 413, + UNSUPPORTED_MEDIA_TYPE: 415, + LOCKED: 423, + RATE_LIMITED: 429, + INTERNAL_ERROR: 500, +} + +type RateLimitHeaderSource = Pick + +export function rateLimitHeaders(rateLimit?: RateLimitHeaderSource): Record { + if (!rateLimit) return {} + return { + 'X-RateLimit-Limit': rateLimit.limit.toString(), + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + } +} + +interface V2SuccessOptions { + rateLimit?: RateLimitHeaderSource + status?: number + headers?: Record +} + +function successHeaders(options: V2SuccessOptions): Record { + return { ...rateLimitHeaders(options.rateLimit), ...options.headers } +} + +/** `{ data }` (+ rate-limit headers). */ +export function v2Data(data: T, options: V2SuccessOptions = {}): NextResponse { + return NextResponse.json( + { data }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +/** `{ data, nextCursor }` (+ rate-limit headers). */ +export function v2CursorList( + data: T[], + nextCursor: string | null, + options: V2SuccessOptions = {} +): NextResponse { + return NextResponse.json( + { data, nextCursor }, + { status: options.status ?? 200, headers: successHeaders(options) } + ) +} + +interface V2ErrorOptions { + status?: number + details?: unknown + headers?: Record +} + +/** `{ error: { code, message, details? } }`. */ +export function v2Error( + code: V2ErrorCode, + message: string, + options: V2ErrorOptions = {} +): NextResponse { + const error: { code: V2ErrorCode; message: string; details?: unknown } = { code, message } + if (options.details !== undefined) error.details = options.details + return NextResponse.json( + { error }, + { status: options.status ?? STATUS_BY_CODE[code], headers: options.headers } + ) +} + +/** Render a contract `ZodError` as the v2 error envelope. */ +export function v2ValidationError(error: ZodError): NextResponse { + return v2Error('BAD_REQUEST', getValidationErrorMessage(error, 'Invalid request'), { + details: serializeZodIssues(error), + }) +} + +/** Render a shared {@link WorkspaceAccessError} as the v2 error envelope. */ +export function v2WorkspaceAccessError(failure: WorkspaceAccessError): NextResponse { + return v2Error(failure.code, failure.message, { status: failure.status }) +} + +/** + * Render a v1 rate-limit/auth failure (`checkRateLimit` result) as the v2 error + * envelope: an auth failure becomes 401, a throttle becomes 429 with + * `Retry-After`. + */ +export function v2RateLimitError(rateLimit: RateLimitResult): NextResponse { + const headers = rateLimitHeaders(rateLimit) + if (rateLimit.error) { + return v2Error('UNAUTHORIZED', rateLimit.error, { headers }) + } + const retryAfterSeconds = rateLimit.retryAfterMs + ? Math.ceil(rateLimit.retryAfterMs / 1000) + : Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000) + return v2Error('RATE_LIMITED', 'API rate limit exceeded', { + headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() }, + details: { retryAfter: rateLimit.resetAt.toISOString() }, + }) +} + +/** Opaque base64-JSON keyset cursor codec shared by all v2 cursor lists. */ +export function encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor>(cursor: string): T | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as T + } catch { + return null + } +} diff --git a/apps/sim/app/api/v2/logs/[id]/route.ts b/apps/sim/app/api/v2/logs/[id]/route.ts new file mode 100644 index 00000000000..698e59f10ed --- /dev/null +++ b/apps/sim/app/api/v2/logs/[id]/route.ts @@ -0,0 +1,109 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetLogContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) + + const log = rows[0] + if (!log) return v2Error('NOT_FOUND', 'Log not found') + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') + + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + + const detail: V2LogDetail = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName, + }, + executionData, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Log detail fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts new file mode 100644 index 00000000000..da936577def --- /dev/null +++ b/apps/sim/app/api/v2/logs/executions/[executionId]/route.ts @@ -0,0 +1,74 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2Execution, v2GetExecutionContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ExecutionAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetExecutionContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { executionId } = parsed.data.params + + const rows = await db + .select() + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (rows.length === 0) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const workflowLog = rows[0] + + // Convert an authorization failure into 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow execution not found') + + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) return v2Error('NOT_FOUND', 'Workflow state snapshot not found') + + const execution: V2Execution = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt ? workflowLog.endedAt.toISOString() : null, + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null, + }, + } + + return v2Data(execution, { rateLimit }) + } catch (error) { + logger.error('Error fetching execution data', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts new file mode 100644 index 00000000000..a4cc3372d37 --- /dev/null +++ b/apps/sim/app/api/v2/logs/route.ts @@ -0,0 +1,168 @@ +import { db } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2LogListItem, v2ListLogsContract } from '@/lib/api/contracts/v2/logs' +import { parseRequest } from '@/lib/api/server' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2LogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListLogsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const filters = { + workspaceId: params.workspaceId, + workflowIds: params.workflowIds?.split(',').filter(Boolean), + folderIds: params.folderIds?.split(',').filter(Boolean), + triggers: params.triggers?.split(',').filter(Boolean), + level: params.level, + startDate: params.startDate ? new Date(params.startDate) : undefined, + endDate: params.endDate ? new Date(params.endDate) : undefined, + executionId: params.executionId, + minDurationMs: params.minDurationMs, + maxDurationMs: params.maxDurationMs, + minCost: params.minCost, + maxCost: params.maxCost, + model: params.model, + cursor: params.cursor + ? decodeCursor<{ startedAt: string; id: string }>(params.cursor) || undefined + : undefined, + order: params.order, + } + + const conditions = buildLogFilters(filters) + const orderBy = getOrderBy(params.order) + + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + workspaceId: workflowExecutionLogs.workspaceId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + files: workflowExecutionLogs.files, + executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`, + workflowName: workflow.name, + workflowDescription: workflow.description, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(conditions) + .orderBy(...orderBy) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const lastLog = data[data.length - 1] + nextCursor = encodeCursor({ startedAt: lastLog.startedAt.toISOString(), id: lastLog.id }) + } + + type LogRow = (typeof data)[number] + const buildItem = (log: LogRow): V2LogListItem => { + const item: V2LogListItem = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + files: (log.files as unknown[] | null) ?? null, + } + if (params.details === 'full') { + item.workflow = { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + deleted: !log.workflowName, + } + } + return item + } + + const needsMaterialize = + params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans) + + const formattedLogs = needsMaterialize + ? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => { + const item = buildItem(log) + if (log.executionData) { + const execData = (await materializeExecutionData( + log.executionData as Record | null, + { + workspaceId: log.workspaceId, + workflowId: log.workflowId, + executionId: log.executionId, + } + )) as Record + if (params.includeFinalOutput && execData.finalOutput) { + item.finalOutput = execData.finalOutput + } + if (params.includeTraceSpans && execData.traceSpans) { + item.traceSpans = execData.traceSpans + } + } + return item + }) + : data.map(buildItem) + + return v2CursorList(formattedLogs, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Logs fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts new file mode 100644 index 00000000000..87c46b2cd75 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { + v2DeployWorkflowContract, + v2UndeployWorkflowContract, +} from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDeployAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullDeploy({ + workflowId: id, + userId, + workflowName: workflow.name || undefined, + versionName: body.data.name, + versionDescription: body.data.description ?? undefined, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to deploy workflow') + } + + captureServerEvent( + userId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workspaceId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: result.version, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow deploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-deploy') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId }) + + const result = await performFullUndeploy({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow') + } + + captureServerEvent( + userId, + 'workflow_undeployed', + { workflow_id: id, workspace_id: workspaceId }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: false, + deployedAt: null, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow undeploy error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts new file mode 100644 index 00000000000..634cf9957cf --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' +import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { performActivateVersion } from '@/lib/workflows/orchestration' +import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { checkRateLimit } from '@/app/api/v1/middleware' +import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowRollbackAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 120 + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workflow-rollback') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const rawBody = await parseOptionalJsonBody(request) + if (!rawBody.success) { + return rawBody.response.status === 413 + ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') + : v2Error('BAD_REQUEST', 'Request body must be valid JSON') + } + const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {}) + if (!body.success) return v2ValidationError(body.error) + + const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id) + if (!target.ok) return v2Error('NOT_FOUND', 'Workflow not found') + const { workflow, workspaceId } = target + + if (!workflow.isDeployed) { + return v2Error('BAD_REQUEST', 'Workflow is not deployed') + } + + await assertWorkflowMutable(id) + + let targetVersion = body.data.version + if (targetVersion === undefined) { + const previous = await findPreviousDeploymentVersion(id) + if (!previous.ok) { + const message = + previous.reason === 'no_active_version' + ? 'Workflow has no active deployment to roll back from' + : 'No previous deployment version to roll back to' + return v2Error('BAD_REQUEST', message) + } + targetVersion = previous.version + } + + logger.info( + `[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v2 API`, + { userId } + ) + + const result = await performActivateVersion({ + workflowId: id, + version: targetVersion, + userId, + workflow: workflow as Record, + requestId, + request, + }) + + if (!result.success) { + const code = + result.errorCode === 'not_found' + ? 'NOT_FOUND' + : result.errorCode === 'validation' + ? 'BAD_REQUEST' + : 'INTERNAL_ERROR' + return v2Error(code, result.error || 'Failed to roll back workflow') + } + + captureServerEvent( + userId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: workspaceId, version: targetVersion }, + { groups: { workspace: workspaceId } } + ) + + return v2Data( + { + id, + isDeployed: true, + deployedAt: result.deployedAt?.toISOString() ?? null, + version: targetVersion, + warnings: result.warnings ?? [], + }, + { rateLimit } + ) + } catch (error) { + if (error instanceof WorkflowLockedError) { + return v2Error('LOCKED', error.message) + } + logger.error(`[${requestId}] Workflow rollback error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts new file mode 100644 index 00000000000..a059d669648 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -0,0 +1,81 @@ +import { db } from '@sim/db' +import { workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowDetail, v2GetWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowDetailAPI') + +export const revalidate = 0 + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest(v2GetWorkflowContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') + + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + return v2Data(detail, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflow details fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + } +) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts new file mode 100644 index 00000000000..a35f045bda7 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2WorkflowListItem, v2ListWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + decodeCursor, + encodeCursor, + v2CursorList, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Keyset cursor for the `(sortOrder, createdAt, id)` ordering. */ +interface WorkflowListCursor { + sortOrder: number + createdAt: string + id: string +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflows') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const parsed = await parseRequest( + v2ListWorkflowsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + + const params = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] + + if (params.folderId) { + conditions.push(eq(workflow.folderId, params.folderId)) + } + + if (params.deployedOnly) { + conditions.push(eq(workflow.isDeployed, true)) + } + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + const cursorCondition = or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + } + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(and(...conditions)) + .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | null = null + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = encodeCursor({ + sortOrder: last.sortOrder, + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formatted: V2WorkflowListItem[] = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + folderId: w.folderId, + workspaceId: w.workspaceId ?? params.workspaceId, + isDeployed: w.isDeployed, + deployedAt: w.deployedAt?.toISOString() ?? null, + runCount: w.runCount, + lastRunAt: w.lastRunAt?.toISOString() ?? null, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + })) + + return v2CursorList(formatted, nextCursor, { rateLimit }) + } catch (error) { + logger.error(`[${requestId}] Workflows fetch error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/lib/api/contracts/v1/admin/organizations.ts b/apps/sim/lib/api/contracts/v1/admin/organizations.ts index 1281b5e649d..f64fd8da4b4 100644 --- a/apps/sim/lib/api/contracts/v1/admin/organizations.ts +++ b/apps/sim/lib/api/contracts/v1/admin/organizations.ts @@ -142,7 +142,8 @@ const adminV1RemoveOrganizationMemberResultSchema = z.object({ memberId: z.string(), userId: z.string(), billingActions: z.object({ - usageCaptured: z.boolean(), + /** Dollar amount of departed-member usage captured (0 when none). */ + usageCaptured: z.number(), proRestored: z.boolean(), usageRestored: z.boolean(), skipBillingLogic: z.boolean(), @@ -159,8 +160,10 @@ const adminV1TransferOwnershipResultSchema = z.object({ currentOwnerUserId: z.string(), newOwnerUserId: z.string(), workspacesReassigned: z.number(), - billedAccountReassigned: z.boolean(), - overageMigrated: z.boolean(), + /** Count of workspaces whose billed account was reassigned to the new owner. */ + billedAccountReassigned: z.number(), + /** Decimal-string dollar amount of overage migrated to the new owner ('0' when none). */ + overageMigrated: z.string(), billingBlockInherited: z.boolean(), }) diff --git a/apps/sim/lib/api/contracts/v1/audit-logs.ts b/apps/sim/lib/api/contracts/v1/audit-logs.ts index f82b86e4b6d..4ce86e22e9b 100644 --- a/apps/sim/lib/api/contracts/v1/audit-logs.ts +++ b/apps/sim/lib/api/contracts/v1/audit-logs.ts @@ -1,5 +1,11 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { + adminV1ListResponseSchema, + adminV1PaginationQuerySchema, + adminV1SingleResponseSchema, +} from '@/lib/api/contracts/v1/admin/shared' +import { v1UserLimitsSchema } from '@/lib/api/contracts/v1/shared' const isoDateString = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date format. Use ISO 8601.', @@ -43,25 +49,51 @@ export const v1AdminAuditLogsQuerySchema = z.object({ actorEmail: optionalQueryString, startDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), endDate: z.preprocess((value) => (value === '' ? undefined : value), isoDateString.optional()), + ...adminV1PaginationQuerySchema.shape, }) /** - * Generic wrapper used by v1 admin audit-log responses. The `data` and - * `limits` halves are intentionally `z.unknown()` because this proxy returns - * provider-shaped payloads that vary per route family; tightening here would - * require a discriminated union per route, which is tracked as a follow-up. - * - * boundary-policy: this is the "validates nothing" alias form that the audit - * script's `untyped-response` regex doesn't currently catch. Treat any new - * wrapper of this shape the same way and either annotate at the contract use - * site with `// untyped-response: ` or replace with a concrete schema. + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts`; `ipAddress`/`userAgent` are intentionally + * excluded for privacy. `metadata` is genuinely arbitrary per-action JSON. */ -const apiResponseWithLimitsSchema = z - .object({ - data: z.unknown(), - limits: z.unknown().optional(), - }) - .passthrough() +const v1AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +/** + * Admin audit-log entry. Mirrors `toAdminAuditLog` in `app/api/v1/admin/types.ts`, + * which additionally exposes `ipAddress`/`userAgent`. + */ +const adminV1AuditLogEntrySchema = v1AuditLogEntrySchema.extend({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), +}) + +const v1ListAuditLogsResponseSchema = z.object({ + data: z.array(v1AuditLogEntrySchema), + nextCursor: z.string().optional(), + limits: v1UserLimitsSchema, +}) + +const v1GetAuditLogResponseSchema = z.object({ + data: v1AuditLogEntrySchema, + limits: v1UserLimitsSchema, +}) + +export type V1AuditLogEntry = z.output +export type AdminV1AuditLogEntry = z.output export const v1ListAuditLogsContract = defineRouteContract({ method: 'GET', @@ -69,7 +101,7 @@ export const v1ListAuditLogsContract = defineRouteContract({ query: v1ListAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1ListAuditLogsResponseSchema, }, }) @@ -79,7 +111,7 @@ export const v1GetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: v1GetAuditLogResponseSchema, }, }) @@ -89,7 +121,7 @@ export const v1AdminListAuditLogsContract = defineRouteContract({ query: v1AdminAuditLogsQuerySchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1ListResponseSchema(adminV1AuditLogEntrySchema), }, }) @@ -99,6 +131,6 @@ export const v1AdminGetAuditLogContract = defineRouteContract({ params: v1AuditLogParamsSchema, response: { mode: 'json', - schema: apiResponseWithLimitsSchema, + schema: adminV1SingleResponseSchema(adminV1AuditLogEntrySchema), }, }) diff --git a/apps/sim/lib/api/contracts/v1/shared.ts b/apps/sim/lib/api/contracts/v1/shared.ts new file mode 100644 index 00000000000..9502e57ee5f --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/shared.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +/** + * Rate-limit / usage envelope injected into every Family-A v1 response by + * `createApiResponse` (see `app/api/v1/logs/meta.ts`). Mirrors the `UserLimits` + * interface in that file. Shared here so logs, audit-logs, and workflows + * contracts describe `limits` identically instead of each redefining it. + */ +export const v1UserLimitsSchema = z.object({ + workflowExecutionRateLimit: z.object({ + sync: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + async: z.object({ + requestsPerMinute: z.number(), + maxBurst: z.number(), + remaining: z.number(), + resetAt: z.string(), + }), + }), + usage: z.object({ + currentPeriodCost: z.number(), + limit: z.number(), + plan: z.string(), + isExceeded: z.boolean(), + }), +}) + +export type V1UserLimits = z.output + +/** + * Family-A envelope helper: `{ data, limits }`. Use for the `createApiResponse` + * detail/action surfaces (logs/[id], workflows deploy/rollback/undeploy). List + * endpoints that also return a `nextCursor` should compose the object directly + * (`{ data, nextCursor: z.string().optional(), limits: v1UserLimitsSchema }`). + */ +export const withV1Limits = (dataSchema: T) => + z.object({ + data: dataSchema, + limits: v1UserLimitsSchema, + }) diff --git a/apps/sim/lib/api/contracts/v2/audit-logs.ts b/apps/sim/lib/api/contracts/v2/audit-logs.ts new file mode 100644 index 00000000000..1084d9bbecb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/audit-logs.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1AuditLogParamsSchema, + v1ListAuditLogsQuerySchema, +} from '@/lib/api/contracts/v1/audit-logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 audit-logs contracts. These are org-scoped enterprise endpoints. The + * request schemas are reused verbatim from v1 (the query/param shape is + * unchanged); only the response envelope is upgraded to the canonical v2 + * shapes. The v1 `limits` body is dropped — usage limits live on the dedicated + * usage endpoint, not inlined into every response. + */ + +/** + * Public enterprise audit-log entry. Mirrors `formatAuditLogEntry` in + * `app/api/v1/audit-logs/format.ts` and the v1 `v1AuditLogEntrySchema`; + * `ipAddress`/`userAgent` are intentionally excluded for privacy. `metadata` is + * genuinely arbitrary per-action JSON. + */ +export const v2AuditLogEntrySchema = z.object({ + id: z.string(), + workspaceId: z.string().nullable(), + actorId: z.string().nullable(), + actorName: z.string().nullable(), + actorEmail: z.string().nullable(), + action: z.string(), + resourceType: z.string(), + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + description: z.string().nullable(), + metadata: z.unknown(), + createdAt: z.string(), +}) + +export type V2AuditLogEntry = z.output + +export const v2ListAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs', + query: v1ListAuditLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2AuditLogEntrySchema), + }, +}) + +export const v2GetAuditLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/audit-logs/[id]', + params: v1AuditLogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2AuditLogEntrySchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts new file mode 100644 index 00000000000..040ffa4dc80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 files contracts. v2 drops the v1 `{ success, data, limits }` envelope in + * favor of the canonical v2 shapes (`{ data }` / `{ data, nextCursor }`) and + * adds cursor pagination to the list. The workspace is always carried as a query + * param — including on upload — so the route can authorize before reading the + * multipart body. + */ + +/** A workspace file as exposed by the v2 surface. */ +export const v2FileSchema = z.object({ + id: z.string(), + name: z.string(), + size: z.number().nonnegative(), + type: z.string(), + key: z.string(), + uploadedBy: z.string(), + /** ISO-8601 timestamp. */ + uploadedAt: z.string(), +}) + +export type V2File = z.output + +/** Acknowledgement returned by a successful archive (soft delete). */ +export const v2DeleteFileResultSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) + +export type V2DeleteFileResult = z.output + +export const v2FileParamsSchema = z.object({ + fileId: workspaceFileIdSchema, +}) + +export type V2FileParams = z.output + +/** + * List query: workspace scope plus opaque keyset cursor pagination keyed on + * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the + * response. The cursor is the base64-JSON codec shared across the v2 surface. + */ +export const v2ListFilesQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + limit: z.coerce + .number() + .optional() + .default(100) + .transform((v) => Math.min(Math.max(1, Math.trunc(v)), 1000)), + cursor: z.string().min(1).optional(), +}) + +export type V2ListFilesQuery = z.output + +/** Upload carries the workspace as a query param so auth runs before buffering. */ +export const v2UploadFileQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2UploadFileQuery = z.output + +/** Download/delete both target a single file within a workspace-scoped query. */ +export const v2FileWorkspaceQuerySchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +export type V2FileWorkspaceQuery = z.output + +export const v2ListFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files', + query: v2ListFilesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileSchema), + }, +}) + +export const v2UploadFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files', + query: v2UploadFileQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + +export const v2DownloadFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'binary', + }, +}) + +export const v2DeleteFileContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileResultSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts new file mode 100644 index 00000000000..06f4064d2fb --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -0,0 +1,270 @@ +import { z } from 'zod' +import { knowledgeBaseDataSchema } from '@/lib/api/contracts/knowledge/base' +import { documentDataSchema } from '@/lib/api/contracts/knowledge/documents' +import { + knowledgeBaseParamsSchema, + knowledgeDocumentParamsSchema, + nullableWireDateSchema, +} from '@/lib/api/contracts/knowledge/shared' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1CreateKnowledgeBaseBodySchema, + v1KnowledgeSearchBodySchema, + v1KnowledgeWorkspaceQuerySchema, + v1ListKnowledgeBasesQuerySchema, + v1ListKnowledgeDocumentsQuerySchema, + v1UpdateKnowledgeBaseBodySchema, +} from '@/lib/api/contracts/v1/knowledge' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 knowledge contracts. + * + * Request shapes (params/query/body) are reused verbatim from the v1 public + * contract (`@/lib/api/contracts/v1/knowledge`) — the public request surface is + * unchanged. Only the response envelope is upgraded to the canonical v2 shapes + * (`{ data }` for single/mutation, `{ data, pagination }` for the offset-paginated + * document list), and the success `message` strings v1 inlined are dropped. + * + * The concrete `data` item schemas reuse the first-party knowledge data schemas + * as their source of truth: the knowledge-base item is a `.pick()` of + * {@link knowledgeBaseDataSchema} matching `formatKnowledgeBase`'s projection, + * and the document items reuse the core fields of {@link documentDataSchema}. The + * v2 (and v1-public) document projection renames `uploadedAt` to `createdAt` and + * omits `fileUrl`/tag slots, so that rename is layered on via `.extend()`. + */ + +/** + * Knowledge-base item — the exact subset `formatKnowledgeBase` projects from a + * {@link KnowledgeBaseWithCounts}. `userId`, `workspaceId`, and `deletedAt` are + * intentionally not exposed on the public surface. + */ +export const v2KnowledgeBaseSchema = knowledgeBaseDataSchema.pick({ + id: true, + name: true, + description: true, + tokenCount: true, + embeddingModel: true, + embeddingDimension: true, + chunkingConfig: true, + docCount: true, + connectorTypes: true, + createdAt: true, + updatedAt: true, +}) +export type V2KnowledgeBase = z.output + +/** `{ knowledgeBase }` payload for single-KB reads and mutations. */ +export const v2KnowledgeBaseDataSchema = z.object({ knowledgeBase: v2KnowledgeBaseSchema }) +export type V2KnowledgeBaseData = z.output + +/** Delete acknowledgement — the id of the resource that was deleted. */ +export const v2KnowledgeDeleteDataSchema = z.object({ + id: z.string(), + deleted: z.literal(true), +}) +export type V2KnowledgeDeleteData = z.output + +/** + * Document core fields shared by the list item and the detail payload, reused + * from the first-party {@link documentDataSchema}. + */ +const v2KnowledgeDocumentCoreSchema = documentDataSchema.pick({ + id: true, + knowledgeBaseId: true, + filename: true, + fileSize: true, + mimeType: true, + processingStatus: true, + chunkCount: true, + tokenCount: true, + characterCount: true, + enabled: true, +}) + +/** + * Document list item / upload acknowledgement. `createdAt` is the public rename + * of the underlying `uploadedAt` column. + */ +export const v2KnowledgeDocumentSummarySchema = v2KnowledgeDocumentCoreSchema.extend({ + createdAt: nullableWireDateSchema, +}) +export type V2KnowledgeDocumentSummary = z.output + +/** + * Document detail — the summary plus processing state and connector provenance. + * Every field is always present (nullable), mirroring the v1 detail projection. + */ +export const v2KnowledgeDocumentSchema = v2KnowledgeDocumentSummarySchema.extend({ + processingError: z.string().nullable(), + processingStartedAt: nullableWireDateSchema, + processingCompletedAt: nullableWireDateSchema, + connectorId: z.string().nullable(), + connectorType: z.string().nullable(), + sourceUrl: z.string().nullable(), +}) +export type V2KnowledgeDocument = z.output + +/** `{ document }` payload for the upload acknowledgement (summary shape). */ +export const v2KnowledgeDocumentSummaryDataSchema = z.object({ + document: v2KnowledgeDocumentSummarySchema, +}) +export type V2KnowledgeDocumentSummaryData = z.output + +/** `{ document }` payload for the document detail read. */ +export const v2KnowledgeDocumentDataSchema = z.object({ document: v2KnowledgeDocumentSchema }) +export type V2KnowledgeDocumentData = z.output + +/** + * A single vector/tag search hit. `metadata` is the document's display-named tag + * map; values are user-defined and of mixed type (string/number/boolean/date), + * so they are carried as `unknown` and serialized as-is. + */ +export const v2KnowledgeSearchResultSchema = z.object({ + documentId: z.string(), + documentName: z.string().nullable(), + sourceUrl: z.string().nullable(), + content: z.string(), + chunkIndex: z.number(), + metadata: z.record(z.string(), z.unknown()), + similarity: z.number(), +}) +export type V2KnowledgeSearchResult = z.output + +/** Search response payload — mirrors the v1 `data` object. */ +export const v2KnowledgeSearchDataSchema = z.object({ + results: z.array(v2KnowledgeSearchResultSchema), + query: z.string(), + knowledgeBaseIds: z.array(z.string()), + topK: z.number(), + totalResults: z.number(), +}) +export type V2KnowledgeSearchData = z.output + +/** Upload carries the workspace as a query param so auth runs before the multipart body is buffered. */ +export const v2UploadKnowledgeDocumentQuerySchema = z.object({ workspaceId: workspaceIdSchema }) +export type V2UploadKnowledgeDocumentQuery = z.output + +/** + * KB list. `getKnowledgeBases` returns the full workspace set (a small, bounded + * per-workspace list), so today the cursor list is a single full page + * (`nextCursor` always `null`). The canonical cursor envelope keeps the v2 list + * surface uniform; real pagination can be added later behind the opaque cursor. + */ +export const v2ListKnowledgeBasesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge', + query: v1ListKnowledgeBasesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeBaseSchema), + }, +}) + +export const v2CreateKnowledgeBaseContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge', + body: v1CreateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2GetKnowledgeBaseContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2UpdateKnowledgeBaseContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + body: v1UpdateKnowledgeBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeBaseDataSchema), + }, +}) + +export const v2DeleteKnowledgeBaseContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + params: knowledgeBaseParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) + +export const v2SearchKnowledgeContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/search', + body: v1KnowledgeSearchBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeSearchDataSchema), + }, +}) + +/** + * Document list query: the v1 search/filter/sort/limit shape with `offset` + * swapped for an opaque `cursor`. Total doc count is available as `docCount` on + * the knowledge base. + */ +export const v2ListKnowledgeDocumentsQuerySchema = v1ListKnowledgeDocumentsQuerySchema + .omit({ offset: true }) + .extend({ cursor: z.string().min(1).optional() }) +export type V2ListKnowledgeDocumentsQuery = z.output + +export const v2ListKnowledgeDocumentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2ListKnowledgeDocumentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2KnowledgeDocumentSummarySchema), + }, +}) + +export const v2UploadKnowledgeDocumentContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + params: knowledgeBaseParamsSchema, + query: v2UploadKnowledgeDocumentQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentSummaryDataSchema), + }, +}) + +export const v2GetKnowledgeDocumentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDocumentDataSchema), + }, +}) + +export const v2DeleteKnowledgeDocumentContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + params: knowledgeDocumentParamsSchema, + query: v1KnowledgeWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2KnowledgeDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts new file mode 100644 index 00000000000..774aceb8794 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -0,0 +1,123 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1ExecutionParamsSchema, + v1ListLogsQuerySchema, + v1LogParamsSchema, +} from '@/lib/api/contracts/v1/logs' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 logs contracts. The query schemas are reused verbatim from v1 (the request + * shape is unchanged); only the response envelope is upgraded to the canonical + * v2 shapes with concrete item schemas. + */ + +const v2LogCostSchema = z.object({ total: z.number() }).nullable() + +/** Execution `files` is a per-run jsonb array of attachment metadata. */ +const v2LogFilesSchema = z.array(z.unknown()).nullable() + +const v2LogWorkflowSummarySchema = z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + deleted: z.boolean(), +}) + +export const v2LogListItemSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + deploymentVersionId: z.string().nullable(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + files: v2LogFilesSchema, + /** Present only when `details=full`. */ + workflow: v2LogWorkflowSummarySchema.optional(), + /** Present only when `details=full` and `includeFinalOutput=true`. */ + finalOutput: z.unknown().optional(), + /** Present only when `details=full` and `includeTraceSpans=true`. */ + traceSpans: z.unknown().optional(), +}) + +export type V2LogListItem = z.output + +export const v2LogDetailSchema = z.object({ + id: z.string(), + workflowId: z.string().nullable(), + executionId: z.string(), + level: z.string(), + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + files: v2LogFilesSchema, + workflow: z.object({ + id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + userId: z.string().nullable(), + workspaceId: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + deleted: z.boolean(), + }), + /** Materialized execution trace (block states, trace spans). */ + executionData: z.unknown(), + cost: v2LogCostSchema, + createdAt: z.string(), +}) + +export type V2LogDetail = z.output + +export const v2ExecutionSchema = z.object({ + executionId: z.string(), + workflowId: z.string().nullable(), + /** Workflow state snapshot at execution time. */ + workflowState: z.unknown(), + executionMetadata: z.object({ + trigger: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalDurationMs: z.number().nullable(), + cost: v2LogCostSchema, + }), +}) + +export type V2Execution = z.output + +export const v2ListLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs', + query: v1ListLogsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2LogListItemSchema), + }, +}) + +export const v2GetLogContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/[id]', + params: v1LogParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2LogDetailSchema), + }, +}) + +export const v2GetExecutionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + params: v1ExecutionParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ExecutionSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts new file mode 100644 index 00000000000..d0579054727 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +/** + * Shared building blocks for the v2 API contract surface. + * + * v2 standardizes on a single response family across every endpoint: + * - single resource: `{ data: T }` + * - list: `{ data: T[], nextCursor: string | null }` + * - error: `{ error: { code, message, details? } }` + * + * Every list uses the opaque-cursor envelope (Stripe/Slack-style): `limit` + + * `cursor` in, `{ data, nextCursor }` out. Cursors are opaque so the underlying + * scheme (keyset / offset / full-set) can change without a contract change. + * Total counts are not returned on lists — they're available on the parent + * resource where relevant (e.g. `rowCount` on a table, `docCount` on a KB). + * + * Rate-limit state is carried in `X-RateLimit-*` response headers (not the + * body). Usage limits are available from the dedicated usage endpoint rather + * than being inlined into every response. + */ + +/** Canonical v2 error envelope. */ +export const v2ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.unknown().optional(), + }), +}) + +/** `{ data: T }` */ +export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) + +/** `{ data: T[], nextCursor: string | null }` — the v2 list envelope. */ +export const v2CursorListResponse = (itemSchema: T) => + z.object({ + data: z.array(itemSchema), + nextCursor: z.string().nullable(), + }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts new file mode 100644 index 00000000000..05ca4a36fe8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v1DeployWorkflowDataSchema, + v1ListWorkflowsQuerySchema, + v1RollbackWorkflowDataSchema, +} from '@/lib/api/contracts/v1/workflows' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { workflowIdParamsSchema } from '@/lib/api/contracts/workflows' + +/** + * v2 workflows contracts. Request shapes are reused verbatim from v1 (the list + * query and `[id]` param are unchanged); only the response envelope is upgraded + * to the canonical v2 shapes with concrete item/detail schemas. The + * deploy/rollback/undeploy data payloads reuse the already-concrete v1 schemas, + * re-wrapped in `v2DataResponse` (the v1 `limits` body field is dropped — v2 + * carries rate-limit state in headers and usage on a dedicated endpoint). + */ + +export const v2WorkflowListItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + folderId: z.string().nullable(), + workspaceId: z.string(), + isDeployed: z.boolean(), + deployedAt: z.string().nullable(), + runCount: z.number(), + lastRunAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type V2WorkflowListItem = z.output + +/** A single trigger input field extracted from the workflow's input-definition block. */ +const v2WorkflowInputFieldSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), +}) + +export const v2WorkflowDetailSchema = v2WorkflowListItemSchema.extend({ + /** + * Workflow-scoped variables keyed by variable id. Each value is a structured + * variable object (`{ id, name, type, value, ... }`); only the inner `value` + * is user-defined/free-form. Kept as `unknown` to tolerate legacy/unstamped + * rows — tightening to a concrete object schema later is consumer-safe (the + * wire already carries the full object), so it stays additively evolvable. + */ + variables: z.record(z.string(), z.unknown()), + inputs: z.array(v2WorkflowInputFieldSchema), +}) + +export type V2WorkflowDetail = z.output + +/** + * Undeploy returns the deployment state without a version number. Derived from + * the exported v1 deploy data schema (its private base is not exported) so the + * shape stays in lockstep with v1. + */ +const v2UndeployWorkflowDataSchema = v1DeployWorkflowDataSchema.omit({ version: true }) + +export const v2ListWorkflowsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows', + query: v1ListWorkflowsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2GetWorkflowContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowDetailSchema), + }, +}) + +export const v2DeployWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1DeployWorkflowDataSchema), + }, +}) + +export const v2UndeployWorkflowContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UndeployWorkflowDataSchema), + }, +}) + +export const v2RollbackWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + params: workflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v1RollbackWorkflowDataSchema), + }, +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index ce11157ccb7..d9dc248abaf 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -41,6 +41,12 @@ export interface PerformDeleteWorkspaceFileItemsParams { userId: string fileIds?: string[] folderIds?: string[] + /** + * Optional originating request, forwarded to the audit log so the deletion + * entry captures client IP / user agent. Omitted by in-app callers that have + * no HTTP request in scope. + */ + request?: { headers: { get(name: string): string | null } } } export interface PerformDeleteWorkspaceFileItemsResult { @@ -138,7 +144,7 @@ export interface PerformRestoreWorkspaceFileFolderResult { export async function performDeleteWorkspaceFileItems( params: PerformDeleteWorkspaceFileItemsParams ): Promise { - const { workspaceId, userId, fileIds = [], folderIds = [] } = params + const { workspaceId, userId, fileIds = [], folderIds = [], request } = params if (fileIds.length === 0 && folderIds.length === 0) { return { @@ -173,6 +179,7 @@ export async function performDeleteWorkspaceFileItems( resourceType: AuditResourceType.FILE, description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, metadata: { fileIds }, + request, }) } @@ -191,6 +198,7 @@ export async function performDeleteWorkspaceFileItems( folders: deletedItems.folders, }, }, + request, }) } From 2676f49163c6559bc401789674fa7ee167cc7413 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 11:32:06 -0700 Subject: [PATCH 02/28] feat(cli): sim CLI with AWS-style profiles and a platform key exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `packages/sim-cli` (`@sim/cli`, bin `sim`) and extends the existing CLI key handoff so it can mint the credential the public API actually accepts. ## Key exchange The handoff already existed but only minted *copilot* keys, which do not authenticate `/api/v1` or `/api/v2` — those want a Sim platform key. The approval now carries a `scope`: - `copilot` (the default, so terminals built against the original flow are unaffected) mints as before - `platform` mints a Sim API key: workspace-scoped when the approver is a workspace admin, personal otherwise Scope and workspace are fixed at *approval*, not at poll: the poll is unauthenticated by necessity, so the browser is the only moment a human is present to consent and the only place a permission can be checked. The poll echoes back what was granted rather than what was asked for, so the CLI cannot file a copilot key under a platform profile and fail later with an opaque 401. Picking a workspace and scoping a key to it are kept separate. The terminal has no key yet, so it cannot list workspaces — the browser picker is the only place that choice can be made, and the pick comes back as the profile's default whether or not the key is bound to it. Otherwise a non-admin would pick a workspace by name and then have to go find its id by hand. Personal-key creation moves into `lib/api-key/orchestration` so the settings route and the exchange share one issuer. ## CLI Profiles work like the AWS CLI: `~/.sim/config` for settings (`[profile dev]`), `~/.sim/credentials` for keys at 0600 (`[dev]`), selected via `--profile` / `SIM_PROFILE`. Each setting resolves flag → env → file → default, and `sim whoami` reports the winning source so a surprising value is explainable. CI can skip login entirely with `SIM_API_KEY` + `SIM_WORKSPACE`. Commands cover the v2 surface pulled in earlier: workflows, logs, files, and knowledge, with `--output json` passing the API's own shapes through for `jq`. `sim tables` is deliberately absent — that surface is still in flux. ## Drift fixes The v2 routes were authored a month ago and had fallen behind their services: `checkActorUsageLimits(userId, workspaceId)` → the billing-attribution flow (which also restores correct payer attribution for workspace keys on KB upload and search), `processDocumentsWithQueue` gained a required argument, and the deploy/rollback param objects had stale fields. Caught by a cold type-check — an incremental run had reported these files clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .../app/api/cli/auth/approve/route.test.ts | 126 ++++++++++- apps/sim/app/api/cli/auth/approve/route.ts | 57 ++++- apps/sim/app/api/cli/auth/poll/route.test.ts | 115 +++++++++- apps/sim/app/api/cli/auth/poll/route.ts | 80 ++++++- apps/sim/app/api/users/me/api-keys/route.ts | 72 ++----- .../api/v2/knowledge/[id]/documents/route.ts | 27 ++- apps/sim/app/api/v2/knowledge/search/route.ts | 28 ++- .../app/api/v2/workflows/[id]/deploy/route.ts | 2 - .../api/v2/workflows/[id]/rollback/route.ts | 2 - apps/sim/app/cli/auth/cli-auth-request.ts | 15 +- apps/sim/app/cli/auth/cli-auth-view.tsx | 61 +++++- apps/sim/app/cli/auth/page.tsx | 4 + apps/sim/app/cli/auth/search-params.ts | 17 +- apps/sim/lib/api-key/orchestration/index.ts | 119 +++++++++- apps/sim/lib/api/contracts/cli-auth.ts | 53 +++++ apps/sim/lib/cli-auth/approval-store.test.ts | 76 ++++++- apps/sim/lib/cli-auth/approval-store.ts | 45 +++- bun.lock | 125 ++++++++++- packages/sim-cli/README.md | 142 ++++++++++++ packages/sim-cli/package.json | 44 ++++ packages/sim-cli/src/auth/device-flow.ts | 159 ++++++++++++++ packages/sim-cli/src/commands/auth.ts | 195 +++++++++++++++++ packages/sim-cli/src/commands/configure.ts | 72 +++++++ packages/sim-cli/src/commands/files.ts | 129 +++++++++++ packages/sim-cli/src/commands/knowledge.ts | 165 ++++++++++++++ packages/sim-cli/src/commands/logs.ts | 159 ++++++++++++++ packages/sim-cli/src/commands/workflows.ts | 148 +++++++++++++ packages/sim-cli/src/config/index.ts | 17 ++ packages/sim-cli/src/config/ini.test.ts | 105 +++++++++ packages/sim-cli/src/config/ini.ts | 130 +++++++++++ packages/sim-cli/src/config/paths.ts | 21 ++ packages/sim-cli/src/config/profile.test.ts | 137 ++++++++++++ packages/sim-cli/src/config/profile.ts | 204 ++++++++++++++++++ packages/sim-cli/src/context.ts | 36 ++++ packages/sim-cli/src/http/client.ts | 194 +++++++++++++++++ packages/sim-cli/src/index.ts | 69 ++++++ packages/sim-cli/src/output/render.test.ts | 132 ++++++++++++ packages/sim-cli/src/output/render.ts | 123 +++++++++++ packages/sim-cli/tsconfig.json | 12 ++ packages/sim-cli/vitest.config.ts | 8 + scripts/check-api-validation-contracts.ts | 4 +- 41 files changed, 3310 insertions(+), 119 deletions(-) create mode 100644 packages/sim-cli/README.md create mode 100644 packages/sim-cli/package.json create mode 100644 packages/sim-cli/src/auth/device-flow.ts create mode 100644 packages/sim-cli/src/commands/auth.ts create mode 100644 packages/sim-cli/src/commands/configure.ts create mode 100644 packages/sim-cli/src/commands/files.ts create mode 100644 packages/sim-cli/src/commands/knowledge.ts create mode 100644 packages/sim-cli/src/commands/logs.ts create mode 100644 packages/sim-cli/src/commands/workflows.ts create mode 100644 packages/sim-cli/src/config/index.ts create mode 100644 packages/sim-cli/src/config/ini.test.ts create mode 100644 packages/sim-cli/src/config/ini.ts create mode 100644 packages/sim-cli/src/config/paths.ts create mode 100644 packages/sim-cli/src/config/profile.test.ts create mode 100644 packages/sim-cli/src/config/profile.ts create mode 100644 packages/sim-cli/src/context.ts create mode 100644 packages/sim-cli/src/http/client.ts create mode 100644 packages/sim-cli/src/index.ts create mode 100644 packages/sim-cli/src/output/render.test.ts create mode 100644 packages/sim-cli/src/output/render.ts create mode 100644 packages/sim-cli/tsconfig.json create mode 100644 packages/sim-cli/vitest.config.ts diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index b270c7567cf..ff7902092a5 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -5,11 +5,13 @@ import { createHash } from 'node:crypto' import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockCreateApproval: vi.fn(), - mockEnforceUserRateLimit: vi.fn(), -})) +const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockCreateApproval: vi.fn(), + mockEnforceUserRateLimit: vi.fn(), + mockGetPermissions: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, @@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ enforceUserRateLimit: mockEnforceUserRateLimit, })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mockGetPermissions, +})) + import { POST } from '@/app/api/cli/auth/approve/route' const REQUEST = 'a'.repeat(43) @@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockEnforceUserRateLimit.mockResolvedValue(null) mockCreateApproval.mockResolvedValue(undefined) + mockGetPermissions.mockResolvedValue('admin') }) it('records the approval for the signed-in user', async () => { @@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => { ) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'copilot', + workspaceId: undefined, + workspaceBound: false, + }) + }) + + it('defaults to the copilot scope so pre-scope terminals keep working', async () => { + await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })) + expect(mockCreateApproval).toHaveBeenCalledWith( + 'user-1', + REQUEST, + CHALLENGE, + expect.objectContaining({ scope: 'copilot' }) + ) + }) + + it('records a workspace binding when the approver is a workspace admin', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it("records a non-admin's pick as a default without binding the key to it", async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(200) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('refuses to bind a key to a workspace the approver is not admin of', async () => { + mockGetPermissions.mockResolvedValue('write') + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(403) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace the approver is not a member of', async () => { + mockGetPermissions.mockResolvedValue(null) + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(404) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses bindKeyToWorkspace with no workspaceId', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'platform', + bindKeyToWorkspace: true, + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + + it('refuses a workspace binding on the copilot scope', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'copilot', + workspaceId: 'ws-1', + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() }) it('rejects an unauthenticated caller', async () => { @@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => { await POST( createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' }) ) - expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE) + expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything()) }) it('rejects a malformed challenge', async () => { diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index 8099914be91..3c361a9bf45 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { createApproval } from '@/lib/cli-auth/approval-store' import { enforceUserRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CliAuthApproveAPI') @@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI') * The approving user comes from the session and nothing else — a client-supplied * user id here would let any caller approve a request redeemable for someone * else's key. No key is generated until the CLI polls. + * + * Workspace binding is authorized here rather than at poll time: the poll is + * unauthenticated by necessity, so it has no session to check a permission + * against. Approving is the only moment a human is present. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() @@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(approveCliAuthContract, request, {}) if (!parsed.success) return parsed.response - await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge) - logger.info('Recorded CLI authorization approval', { userId: session.user.id }) + const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body + + if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') { + return NextResponse.json( + { error: 'workspaceId is only valid for the platform scope' }, + { status: 400 } + ) + } + + if (bindKeyToWorkspace && !workspaceId) { + return NextResponse.json( + { error: 'bindKeyToWorkspace requires a workspaceId' }, + { status: 400 } + ) + } + + if (workspaceId) { + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + // Reading the workspace at all requires membership. Without this, the + // terminal could be handed the id of a workspace the approver cannot see — + // harmless for the key, but it would silently become the profile default and + // every later command would 403 with no explanation. + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + // Minting a workspace key is an admin action wherever else it is offered; + // the terminal is not a lower bar. Rejected outright rather than downgraded + // to a personal key, so the CLI never quietly stores a different credential + // than the browser said it would. + if (bindKeyToWorkspace && permission !== 'admin') { + return NextResponse.json( + { error: 'Workspace admin permission is required to issue a workspace API key' }, + { status: 403 } + ) + } + } + + await createApproval(session.user.id, requestId, challenge, { + scope, + workspaceId, + workspaceBound: bindKeyToWorkspace, + }) + logger.info('Recorded CLI authorization approval', { + userId: session.user.id, + scope, + workspaceId: workspaceId ?? null, + workspaceBound: bindKeyToWorkspace, + }) return NextResponse.json({ ok: true }) }) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 89e7422a450..8c762b4845e 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -9,12 +9,16 @@ const { mockCompleteApproval, mockReleaseMint, mockGenerateCopilotApiKey, + mockCreatePersonalApiKey, + mockCreateWorkspaceApiKey, mockEnforceIpRateLimit, } = vi.hoisted(() => ({ mockPollApproval: vi.fn(), mockCompleteApproval: vi.fn(), mockReleaseMint: vi.fn(), mockGenerateCopilotApiKey: vi.fn(), + mockCreatePersonalApiKey: vi.fn(), + mockCreateWorkspaceApiKey: vi.fn(), mockEnforceIpRateLimit: vi.fn(), })) @@ -29,6 +33,11 @@ vi.mock('@/lib/copilot/server/api-keys', () => ({ CopilotApiKeyError: class extends Error {}, })) +vi.mock('@/lib/api-key/orchestration', () => ({ + performCreatePersonalApiKey: mockCreatePersonalApiKey, + performCreateWorkspaceApiKey: mockCreateWorkspaceApiKey, +})) + vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit, })) @@ -42,11 +51,31 @@ function pollRequest(body: Record) { return createMockRequest('POST', body) } +/** What `pollApproval` returns for an approval recorded at the given scope. */ +function approved(overrides: Record = {}) { + return { + status: 'approved', + userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, + ...overrides, + } +} + describe('POST /api/cli/auth/poll', () => { beforeEach(() => { vi.clearAllMocks() mockEnforceIpRateLimit.mockResolvedValue(null) mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' }) + mockCreatePersonalApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-2', name: 'CLI', key: 'sim_personal', createdAt: new Date() }, + }) + mockCreateWorkspaceApiKey.mockResolvedValue({ + success: true, + key: { id: 'key-3', name: 'CLI', key: 'sim_workspace', createdAt: new Date() }, + }) mockCompleteApproval.mockResolvedValue(undefined) mockReleaseMint.mockResolvedValue(undefined) }) @@ -60,20 +89,84 @@ describe('POST /api/cli/auth/poll', () => { }) it('mints, then consumes the approval, once approved', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) + it('mints a personal platform key when the approval carries no workspace', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: null, + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', source: 'cli' }) + ) + expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled() + }) + + it('mints a workspace-scoped key when the approval carries a workspace', async () => { + mockPollApproval.mockResolvedValue( + approved({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-3', apiKey: 'sim_workspace' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(mockCreateWorkspaceApiKey).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', workspaceId: 'ws-1', source: 'cli' }) + ) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + + it('returns the picked workspace with a personal key when the approval is unbound', async () => { + // A non-admin still picked a workspace in the browser; the terminal needs it + // as its default even though the key is not scoped to it. + mockPollApproval.mockResolvedValue(approved({ scope: 'platform', workspaceId: 'ws-1' })) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + await expect(response.json()).resolves.toEqual({ + status: 'complete', + key: { id: 'key-2', apiKey: 'sim_personal' }, + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: false, + }) + expect(mockCreatePersonalApiKey).toHaveBeenCalled() + expect(mockCreateWorkspaceApiKey).not.toHaveBeenCalled() + }) + + it('scope comes from the approval, never from the poll body', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'copilot' })) + const response = await POST( + pollRequest({ request: REQUEST, verifier: VERIFIER, scope: 'platform' }) + ) + await expect(response.json()).resolves.toMatchObject({ scope: 'copilot' }) + expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() + }) + it('releases the reservation (keeps the approval) when minting fails', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockGenerateCopilotApiKey.mockRejectedValue(new Error('mothership down')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(500) @@ -81,14 +174,30 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCompleteApproval).not.toHaveBeenCalled() }) + it('releases the reservation when a platform mint fails', async () => { + mockPollApproval.mockResolvedValue(approved({ scope: 'platform' })) + mockCreatePersonalApiKey.mockResolvedValue({ + success: false, + errorCode: 'conflict', + error: 'A personal API key named "CLI" already exists.', + }) + const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) + expect(response.status).toBe(409) + expect(mockReleaseMint).toHaveBeenCalledWith(REQUEST) + expect(mockCompleteApproval).not.toHaveBeenCalled() + }) + it('still returns the key when post-mint cleanup fails — never releases the lock', async () => { - mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' }) + mockPollApproval.mockResolvedValue(approved()) mockCompleteApproval.mockRejectedValue(new Error('redis blip')) const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER })) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'complete', key: { id: 'key-1', apiKey: 'sk-test' }, + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // A cleanup failure must not release the mint lock — that would allow a re-mint. expect(mockReleaseMint).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index c5a7610f9de..99bda7fa9a3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -2,6 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { + performCreatePersonalApiKey, + performCreateWorkspaceApiKey, +} from '@/lib/api-key/orchestration' +import type { ApprovalGrant } from '@/lib/cli-auth/approval-store' import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' @@ -28,6 +33,54 @@ function cliKeyName(): string { return `CLI (${new Date().toISOString().slice(0, 10)})` } +/** + * Mints from the key space the approval recorded. + * + * A name collision is reported as a conflict rather than retried under a + * generated name: two logins on the same day from the same terminal should + * reuse the existing key, and silently accumulating `CLI (date) (2)` rows + * would hide that. + */ +async function mintForGrant( + grant: ApprovalGrant +): Promise< + { ok: true; key: { id: string; apiKey: string } } | { ok: false; status: number; message: string } +> { + const name = cliKeyName() + + if (grant.scope === 'copilot') { + try { + const key = await generateCopilotApiKey(grant.userId, name) + return { ok: true, key } + } catch (error) { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + return { ok: false, status: status ?? 500, message: 'Failed to generate copilot API key' } + } + } + + // `workspaceId` alone only names the terminal's default workspace; binding the + // key to it is a separate, admin-gated decision made at approval. + const result = + grant.workspaceBound && grant.workspaceId + ? await performCreateWorkspaceApiKey({ + workspaceId: grant.workspaceId, + userId: grant.userId, + name, + source: 'cli', + }) + : await performCreatePersonalApiKey({ userId: grant.userId, name, source: 'cli' }) + + if (!result.success || !result.key) { + return { + ok: false, + status: result.errorCode === 'conflict' ? 409 : 500, + message: result.error ?? 'Failed to generate API key', + } + } + + return { ok: true, key: { id: result.key.id, apiKey: result.key.key } } +} + /** * The CLI's poll endpoint. Unauthenticated by necessity — the CLI has no * session — but the request id is only a rendezvous handle and minting requires @@ -49,17 +102,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ status: 'pending' }) } - let key: Awaited> - try { - key = await generateCopilotApiKey(result.userId, cliKeyName()) - } catch (error) { + const minted = await mintForGrant(result) + if (!minted.ok) { // Mint failed — release the reservation so a later poll can retry. await releaseMint(requestId) - const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined - return NextResponse.json( - { error: 'Failed to generate copilot API key' }, - { status: status ?? 500 } - ) + return NextResponse.json({ error: minted.message }, { status: minted.status }) } // Mint succeeded — the key exists. Consuming the approval is best-effort: a @@ -71,6 +118,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId: result.userId, }) }) - logger.info('Minted CLI key on approved poll', { userId: result.userId }) - return NextResponse.json({ status: 'complete', key }) + logger.info('Minted CLI key on approved poll', { + userId: result.userId, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) + return NextResponse.json({ + status: 'complete', + key: minted.key, + scope: result.scope, + workspaceId: result.workspaceId, + workspaceBound: result.workspaceBound, + }) }) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index cd5f2eb83ca..b6776b51db6 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -1,14 +1,12 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPersonalApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' -import { hashApiKey } from '@/lib/api-key/crypto' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { performCreatePersonalApiKey } from '@/lib/api-key/orchestration' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' @@ -73,70 +71,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { name } = parsed.data.body - const existingKey = await db - .select() - .from(apiKey) - .where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal'))) - .limit(1) - - if (existingKey.length > 0) { - return NextResponse.json( - { - error: `A personal API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } - ) - } - - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), - userId, - workspaceId: null, - name, - key: encryptedKey, - keyHash: hashApiKey(plainKey), - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) - - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.PERSONAL_API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created personal API key: ${name}`, + const result = await performCreatePersonalApiKey({ + userId, + name, + actorName: session.user.name, + actorEmail: session.user.email, request, }) + if (!result.success || !result.key) { + const status = result.errorCode === 'conflict' ? 409 : 500 + return NextResponse.json({ error: result.error }, { status }) + } captureServerEvent(userId, 'api_key_created', { key_name: name, scope: 'personal', }) - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, - }) + return NextResponse.json({ key: result.key }) } catch (error) { logger.error('Failed to create API key', { error }) return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 9f2c7b5367a..054bd84d9aa 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -8,7 +8,11 @@ import { v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' import { parseRequest } from '@/lib/api/server' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError, @@ -174,9 +178,16 @@ export const POST = withRouteHandler( ) if (result instanceof NextResponse) return result - // Fast usage gate before the storage write + indexing (the async backstop - // in processDocumentAsync still covers non-HTTP paths). - const usage = await checkActorUsageLimits(userId, workspaceId) + /** + * Gate before storage and indexing. Workspace keys bill the billed account + * and its immutable payer from one read; personal keys keep their human + * actor. Mirrors the v1 upload path so the two attribute identically. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + const usage = await checkAttributedUsageLimits(billingAttribution) if (usage.isExceeded) { return v2Error( 'USAGE_LIMIT_EXCEEDED', @@ -249,7 +260,13 @@ export const POST = withRouteHandler( mimeType: contentType, } - processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + processDocumentsWithQueue( + [documentData], + knowledgeBaseId, + {}, + requestId, + billingAttribution + ).catch(() => { // Processing errors are logged internally by the queue. }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 8f432bf467e..b390fddcde2 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -6,7 +6,11 @@ import { v2SearchKnowledgeContract, } from '@/lib/api/contracts/v2/knowledge' import { isZodError, parseRequest } from '@/lib/api/server' -import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' @@ -62,10 +66,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's - // usage and frozen status before spending. Tag-only search is free, so skip it. - if (query && query.trim().length > 0) { - const usage = await checkActorUsageLimits(userId, workspaceId) + /** + * A query incurs hosted embedding (+ optional rerank) cost; a tag-only + * search does not, so it is not gated and not attributed. Workspace keys + * resolve their system actor and immutable payer from one workspace read. + */ + const hasBillableQuery = Boolean(query?.trim()) + const billingAttribution = hasBillableQuery + ? rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + : undefined + const billingActorUserId = billingAttribution?.actorUserId ?? userId + + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) if (usage.isExceeded) { return v2Error( 'USAGE_LIMIT_EXCEEDED', @@ -224,12 +239,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (queryEmbeddingIsBYOK !== null) { await recordSearchEmbeddingUsage({ - userId, + userId: billingActorUserId, workspaceId, embeddingModel: queryEmbeddingModel, query: query!, isBYOK: queryEmbeddingIsBYOK, sourceReference: `v2-kb-search:${requestId}`, + billingAttribution, }) } diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 87c46b2cd75..f545789f1e2 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -58,11 +58,9 @@ export const POST = withRouteHandler( const result = await performFullDeploy({ workflowId: id, userId, - workflowName: workflow.name || undefined, versionName: body.data.name, versionDescription: body.data.description ?? undefined, requestId, - request, }) if (!result.success) { diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 634cf9957cf..b2d2d1d2a92 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -77,9 +77,7 @@ export const POST = withRouteHandler( workflowId: id, version: targetVersion, userId, - workflow: workflow as Record, requestId, - request, }) if (!result.success) { diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts index f13d1e0cffa..57a849b97b0 100644 --- a/apps/sim/app/cli/auth/cli-auth-request.ts +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -1,3 +1,5 @@ +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' + /** BASE64URL, 43 chars (request id or SHA-256 challenge), no padding. */ const BASE64URL_43 = /^[A-Za-z0-9\-_]{43}$/ @@ -12,6 +14,10 @@ export interface CliAuthRequest { challenge: string /** Printed by the CLI, rendered for eyeball comparison. Never sent to the API. */ pairing: string + /** Which key space the terminal is asking for. */ + scope: CliAuthScope + /** Workspace the terminal suggests preselecting. A hint only — never authority. */ + suggestedWorkspaceId: string | null } export type CliAuthRequestResolution = @@ -22,6 +28,8 @@ interface RawCliAuthParams { request: string | null challenge: string | null pairing: string | null + scope: CliAuthScope + workspace: string | null } /** @@ -32,6 +40,8 @@ export function resolveCliAuthRequest({ request, challenge, pairing, + scope, + workspace, }: RawCliAuthParams): CliAuthRequestResolution { if (!request || !challenge || !pairing) { return { valid: false, reason: 'This link is missing the parameters the Sim CLI sends.' } @@ -45,5 +55,8 @@ export function resolveCliAuthRequest({ return { valid: false, reason: 'The pairing code is malformed.' } } - return { valid: true, request: { request, challenge, pairing } } + return { + valid: true, + request: { request, challenge, pairing, scope, suggestedWorkspaceId: workspace || null }, + } } diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 96ad0648ffc..d344b216797 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -1,5 +1,7 @@ 'use client' +import { useMemo, useState } from 'react' +import { ChipSelect, type ChipSelectOption, Label } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' @@ -7,6 +9,10 @@ import { AuthFormMessage, AuthHeader, AuthSubmitButton } from '@/app/(auth)/comp import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { cliAuthParsers } from '@/app/cli/auth/search-params' import { useApproveCliAuth } from '@/hooks/queries/cli-auth' +import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace' + +/** Sentinel for the "not bound to a workspace" row; an empty string reads as unselected. */ +const PERSONAL_VALUE = '__personal__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -22,8 +28,20 @@ export function CliAuthView() { const router = useRouter() const [params] = useQueryStates(cliAuthParsers) const approve = useApproveCliAuth() + const [selected, setSelected] = useState(null) const resolution = resolveCliAuthRequest(params) + const isPlatform = resolution.valid && resolution.request.scope === 'platform' + + const workspaces = useWorkspacesWithMetadata(isPlatform) + + const options = useMemo(() => { + const rows: ChipSelectOption[] = (workspaces.data?.workspaces ?? []).map((workspace) => ({ + label: workspace.name, + value: workspace.id, + })) + return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + }, [workspaces.data]) if (!resolution.valid) { return ( @@ -41,6 +59,18 @@ export function CliAuthView() { const { request } = resolution + // The terminal's suggestion, then the user's last active workspace. Derived at + // render rather than synced into state through an effect, so the first paint + // after the list loads already shows the right row. + const workspaceId = + selected ?? request.suggestedWorkspaceId ?? workspaces.data?.lastActiveWorkspaceId ?? null + const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) + + // Only an admin can bind a key to a workspace. Anything less still gets a + // usable credential — a personal key — but the card says which one before the + // click rather than after, so nothing unexpected lands in the config file. + const bindsToWorkspace = chosen?.permissions === 'admin' + return (
+ {isPlatform && ( +
+ + 8} + searchPlaceholder='Search workspaces' + fullWidth + dropdownWidth='trigger' + /> +

+ {bindsToWorkspace + ? `Issues a key that can only reach ${chosen.name}.` + : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'} +

+
+ )} approve.mutate( - { request: request.request, challenge: request.challenge }, + { + request: request.request, + challenge: request.challenge, + scope: request.scope, + // The picked workspace travels either way — it is the terminal's + // default. Only `bindKeyToWorkspace` narrows the key itself. + ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), + bindKeyToWorkspace: isPlatform && bindsToWorkspace, + }, { onSuccess: () => router.push('/cli/auth/done') } ) } diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index d36de9f8083..966b088c038 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -42,7 +42,11 @@ export default async function CliAuthPage({ request: resolution.request.request, challenge: resolution.request.challenge, pairing: resolution.request.pairing, + scope: resolution.request.scope, }) + if (resolution.request.suggestedWorkspaceId) { + query.set('workspace', resolution.request.suggestedWorkspaceId) + } redirect(`/login?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) } diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts index e62b286e594..375a1c77ab3 100644 --- a/apps/sim/app/cli/auth/search-params.ts +++ b/apps/sim/app/cli/auth/search-params.ts @@ -1,16 +1,27 @@ -import { createSearchParamsCache, parseAsString } from 'nuqs/server' +import { createSearchParamsCache, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Key spaces the handoff can mint from. Mirrors `cliAuthScopeSchema`. */ +export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const /** * Co-located, typed URL query params for the CLI key handoff. Read-only for the * life of the page, so there is no `urlKeys` companion. * - * Nullable with no defaults: a missing value is an invalid request, not a state - * to fall back from. `resolveCliAuthRequest` validates them; never trusted as-is. + * `request`/`challenge`/`pairing` are nullable with no defaults: a missing value + * is an invalid request, not a state to fall back from. `resolveCliAuthRequest` + * validates them; never trusted as-is. + * + * `scope` defaults to `copilot` so a terminal built against the original handoff + * — which sent no scope — still lands on the key space it expects. `workspace` + * is only a preselection hint for the picker; the workspace that ends up bound + * to the key is the one the user confirms, and it is re-authorized server-side. */ export const cliAuthParsers = { request: parseAsString, challenge: parseAsString, pairing: parseAsString, + scope: parseAsStringLiteral(CLI_AUTH_SCOPES).withDefault('copilot'), + workspace: parseAsString, } as const /** diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index 934766fa4e4..ad52b304662 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -1,13 +1,25 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createWorkspaceApiKey } from '@/lib/api-key/auth' +import { generateShortId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { createApiKey, createWorkspaceApiKey } from '@/lib/api-key/auth' +import { hashApiKey } from '@/lib/api-key/crypto' import { PlatformEvents } from '@/lib/core/telemetry' const logger = createLogger('ApiKeyOrchestration') export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal' +export interface CreatedApiKey { + id: string + name: string + key: string + createdAt: Date +} + export interface PerformCreateWorkspaceApiKeyParams { workspaceId: string userId: string @@ -21,11 +33,106 @@ export interface PerformCreateWorkspaceApiKeyResult { success: boolean error?: string errorCode?: ApiKeyOrchestrationErrorCode - key?: { - id: string - name: string - key: string - createdAt: Date + key?: CreatedApiKey +} + +export interface PerformCreatePersonalApiKeyParams { + userId: string + name: string + source?: string + actorName?: string | null + actorEmail?: string | null + /** Forwarded to the audit record so the entry carries the caller's IP/UA. */ + request?: Request +} + +export interface PerformCreatePersonalApiKeyResult { + success: boolean + error?: string + errorCode?: ApiKeyOrchestrationErrorCode + key?: CreatedApiKey +} + +/** + * Issues a personal API key for the given user. + * + * The single issuer for every caller — the settings route, which authenticates + * by session, and the CLI key exchange, which authenticates by a redeemed + * approval. Keeping name-collision handling, audit, and telemetry here means the + * two surfaces can never drift. + */ +export async function performCreatePersonalApiKey( + params: PerformCreatePersonalApiKeyParams +): Promise { + try { + const existing = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.userId, params.userId), + eq(apiKey.name, params.name), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + success: false, + errorCode: 'conflict', + error: `A personal API key named "${params.name}" already exists. Please choose a different name.`, + } + } + + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [created] = await db + .insert(apiKey) + .values({ + id: generateShortId(), + userId: params.userId, + workspaceId: null, + name: params.name, + key: encryptedKey, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + }) + + recordAudit({ + workspaceId: null, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.PERSONAL_API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: created.id, + resourceName: params.name, + description: `Created personal API key: ${params.name}`, + metadata: { + keyName: params.name, + keyType: 'personal', + source: params.source ?? 'settings', + }, + request: params.request, + }) + + logger.info('Created personal API key', { userId: params.userId, keyId: created.id }) + + return { success: true, key: { ...created, key: plainKey } } + } catch (error) { + logger.error('Failed to create personal API key', { error }) + return { success: false, errorCode: 'internal', error: toError(error).message } } } diff --git a/apps/sim/lib/api/contracts/cli-auth.ts b/apps/sim/lib/api/contracts/cli-auth.ts index 56b40ce5953..d37d7a597e0 100644 --- a/apps/sim/lib/api/contracts/cli-auth.ts +++ b/apps/sim/lib/api/contracts/cli-auth.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' /** @@ -14,9 +15,41 @@ import { defineRouteContract } from '@/lib/api/contracts/types' /** BASE64URL, 43 chars (a 32-byte token or SHA-256 digest), no padding. */ const base64Url43 = (message: string) => z.string().regex(/^[A-Za-z0-9\-_]{43}$/, message) +/** + * Which key space the exchange mints from. + * + * `copilot` — a Sim Agent key, for the conversational surface. + * `platform` — a Sim API key (`x-api-key`), the credential the public `/api/v1` + * and `/api/v2` endpoints accept. These are separate key spaces: a copilot key + * does not authenticate a platform request, or vice versa. + * + * Defaulted to `copilot` so terminals built against the original exchange keep + * working without sending the field. + */ +export const cliAuthScopeSchema = z.enum(['copilot', 'platform']).default('copilot') +export type CliAuthScope = z.output + export const approveCliAuthBodySchema = z.object({ request: base64Url43('request must be a base64url request id'), challenge: base64Url43('challenge must be a base64url-encoded SHA-256 digest'), + scope: cliAuthScopeSchema, + /** + * Platform scope only: the workspace the user picked in the browser. Returned + * to the terminal so it can store it as the profile's default — the user chose + * it by name, and asking them to go find its id afterwards would be absurd. + * + * Recorded whether or not the key ends up bound to it; see + * {@link bindKeyToWorkspace}. + */ + workspaceId: workspaceIdSchema.optional(), + /** + * Mint a key scoped to {@link workspaceId} rather than a personal key. Only a + * workspace admin may ask for this, and the approve route rejects anything + * less rather than silently downgrading — the browser has already told the + * user which kind of key they are about to get, so a mismatch here means the + * request did not come from that UI. + */ + bindKeyToWorkspace: z.boolean().optional().default(false), }) export type ApproveCliAuthBody = z.input @@ -49,6 +82,26 @@ export const pollCliAuthContract = defineRouteContract({ z.object({ status: z.literal('complete'), key: z.object({ id: z.string(), apiKey: z.string() }), + /** + * Echoes what the approving user actually consented to. The CLI asked + * for a scope in the browser URL, but the approval is what binds it — + * a client that assumed its own request was honored could file a + * copilot key under a platform profile and fail every later call with + * an opaque 401. + */ + scope: z.enum(['copilot', 'platform']), + /** + * The workspace the user picked, for the terminal to store as its + * default. Present for a personal key too — the choice is about which + * workspace the profile targets, not about what the key can reach. + */ + workspaceId: z.string().nullable(), + /** + * Whether the key itself is scoped to {@link workspaceId}. A bound key + * can reach nothing else, so the terminal must not offer to point the + * profile somewhere the credential cannot follow. + */ + workspaceBound: z.boolean(), }), ]), }, diff --git a/apps/sim/lib/cli-auth/approval-store.test.ts b/apps/sim/lib/cli-auth/approval-store.test.ts index 47d3bd06e9b..b9edabfb582 100644 --- a/apps/sim/lib/cli-auth/approval-store.test.ts +++ b/apps/sim/lib/cli-auth/approval-store.test.ts @@ -46,15 +46,53 @@ describe('cli-auth approval store', () => { expect(JSON.parse(value)).toEqual({ challenge: CHALLENGE, userId: 'user-1', + scope: 'copilot', createdAt: expect.any(Number), }) expect([px, ttl]).toEqual(['PX', 120_000]) }) + + it('records the consented scope and workspace', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('omits the workspace fields entirely when no workspace was picked', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { scope: 'platform' }) + const record = JSON.parse(mockSet.mock.calls[0][1]) + expect(record).not.toHaveProperty('workspaceId') + expect(record).not.toHaveProperty('workspaceBound') + }) + + it('records a picked workspace as unbound unless binding was asked for', async () => { + await createApproval('user-1', REQUEST, CHALLENGE, { + scope: 'platform', + workspaceId: 'ws-1', + }) + expect(JSON.parse(mockSet.mock.calls[0][1])).toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) }) describe('pollApproval', () => { - const storedApproval = () => - JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + const storedApproval = (overrides: Record = {}) => + JSON.stringify({ + challenge: CHALLENGE, + userId: 'user-1', + scope: 'copilot', + createdAt: Date.now(), + ...overrides, + }) it('returns pending when no approval exists yet', async () => { mockGet.mockResolvedValue(null) @@ -68,6 +106,9 @@ describe('cli-auth approval store', () => { await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ status: 'approved', userId: 'user-1', + scope: 'copilot', + workspaceId: null, + workspaceBound: false, }) // NX lock on the mint key; TTL matches the approval so they expire together // and a failed cleanup can't leave a re-mintable window. Record not deleted here. @@ -77,6 +118,37 @@ describe('cli-auth approval store', () => { expect(mockDel).not.toHaveBeenCalled() }) + it('returns the recorded scope and workspace binding', async () => { + mockGet.mockResolvedValue( + storedApproval({ scope: 'platform', workspaceId: 'ws-1', workspaceBound: true }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toEqual({ + status: 'approved', + userId: 'user-1', + scope: 'platform', + workspaceId: 'ws-1', + workspaceBound: true, + }) + }) + + it('reports an unbound workspace pick as a default, not a key scope', async () => { + mockGet.mockResolvedValue(storedApproval({ scope: 'platform', workspaceId: 'ws-1' })) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ + workspaceId: 'ws-1', + workspaceBound: false, + }) + }) + + it('treats a record written before scopes existed as a copilot approval', async () => { + mockGet.mockResolvedValue( + JSON.stringify({ challenge: CHALLENGE, userId: 'user-1', createdAt: Date.now() }) + ) + mockSet.mockResolvedValue('OK') + await expect(pollApproval(REQUEST, SECRET)).resolves.toMatchObject({ scope: 'copilot' }) + }) + it('does NOT touch the record when the secret is wrong', async () => { mockGet.mockResolvedValue(storedApproval()) await expect(pollApproval(REQUEST, 'c'.repeat(43))).resolves.toEqual({ status: 'pending' }) diff --git a/apps/sim/lib/cli-auth/approval-store.ts b/apps/sim/lib/cli-auth/approval-store.ts index 607f6b38b73..7b07fe8cd19 100644 --- a/apps/sim/lib/cli-auth/approval-store.ts +++ b/apps/sim/lib/cli-auth/approval-store.ts @@ -1,5 +1,6 @@ import { safeCompare } from '@sim/security/compare' import { sha256Base64Url, sha256Hex } from '@sim/security/hash' +import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' import { getRedisClient } from '@/lib/core/config/redis' /** @@ -29,10 +30,29 @@ interface ApprovalRecord { challenge: string /** Always taken from the approving user's session, never from a request body. */ userId: string + /** + * Which key space to mint from, fixed at approval time. Recording it here + * rather than reading it from the poll body is what makes the browser consent + * binding: the poll carries only a secret, so it cannot widen what the user + * agreed to. Absent on records written before the field existed — those are + * copilot approvals. + */ + scope?: CliAuthScope + /** Platform scope only: the workspace the user picked, for the terminal's default. */ + workspaceId?: string + /** Whether to mint a key scoped to `workspaceId`. Admin-verified at approval. */ + workspaceBound?: boolean createdAt: number } -export type PollResult = { status: 'pending' } | { status: 'approved'; userId: string } +export interface ApprovalGrant { + userId: string + scope: CliAuthScope + workspaceId: string | null + workspaceBound: boolean +} + +export type PollResult = { status: 'pending' } | ({ status: 'approved' } & ApprovalGrant) function requireRedis() { const redis = getRedisClient() @@ -61,10 +81,21 @@ function mintLockKey(requestId: string): string { export async function createApproval( userId: string, requestId: string, - challenge: string + challenge: string, + grant: { scope: CliAuthScope; workspaceId?: string; workspaceBound?: boolean } = { + scope: 'copilot', + } ): Promise { const redis = requireRedis() - const record: ApprovalRecord = { challenge, userId, createdAt: Date.now() } + const record: ApprovalRecord = { + challenge, + userId, + scope: grant.scope, + ...(grant.workspaceId + ? { workspaceId: grant.workspaceId, workspaceBound: grant.workspaceBound === true } + : {}), + createdAt: Date.now(), + } await redis.set(approvalKey(requestId), JSON.stringify(record), 'PX', APPROVAL_TTL_MS) } @@ -97,7 +128,13 @@ export async function pollApproval(requestId: string, pollSecret: string): Promi const reserved = await redis.set(mintLockKey(requestId), '1', 'PX', MINT_LOCK_TTL_MS, 'NX') if (reserved !== 'OK') return { status: 'pending' } - return { status: 'approved', userId: record.userId } + return { + status: 'approved', + userId: record.userId, + scope: record.scope ?? 'copilot', + workspaceId: record.workspaceId ?? null, + workspaceBound: record.workspaceBound === true, + } } /** Consumes the approval after a successful mint — single-use from here on. */ diff --git a/bun.lock b/bun.lock index b1527c615ef..0ad4015d018 100644 --- a/bun.lock +++ b/bun.lock @@ -580,6 +580,23 @@ "vitest": "^4.1.0", }, }, + "packages/sim-cli": { + "name": "@sim/cli", + "version": "0.1.0", + "bin": { + "sim": "dist/index.js", + }, + "dependencies": { + "chalk": "5.6.2", + "commander": "^11.1.0", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^7.0.2", + "vitest": "^3.2.4", + }, + }, "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", @@ -1705,7 +1722,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], @@ -1737,6 +1802,8 @@ "@sim/browser-protocol": ["@sim/browser-protocol@workspace:packages/browser-protocol"], + "@sim/cli": ["@sim/cli@workspace:packages/sim-cli"], + "@sim/db": ["@sim/db@workspace:packages/db"], "@sim/desktop": ["@sim/desktop@workspace:apps/desktop"], @@ -2451,6 +2518,8 @@ "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -2467,7 +2536,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2479,6 +2548,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -2699,6 +2770,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -3403,6 +3476,8 @@ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], @@ -3771,6 +3846,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], @@ -4065,6 +4142,8 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -4257,6 +4336,8 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], @@ -4345,8 +4426,12 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], "tldts-core": ["tldts-core@7.4.3", "", {}, "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw=="], @@ -4483,6 +4568,8 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], @@ -4675,6 +4762,8 @@ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@electric-sql/client/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4911,6 +5000,8 @@ "@sim/browser-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@sim/cli/vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "@sim/terminal-protocol/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -5007,6 +5098,8 @@ "@types/ws/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -5353,6 +5446,8 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "svix/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -5387,6 +5482,10 @@ "unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "vite-node/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -5585,6 +5684,28 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + "@sim/cli/vitest/@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + + "@sim/cli/vitest/@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + + "@sim/cli/vitest/@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + + "@sim/cli/vitest/@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + + "@sim/cli/vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + + "@sim/cli/vitest/@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + + "@sim/cli/vitest/@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + + "@sim/cli/vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "@sim/cli/vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "@sim/cli/vitest/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "@sim/cli/vitest/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "@trigger.dev/core/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md new file mode 100644 index 00000000000..3eab36aa44b --- /dev/null +++ b/packages/sim-cli/README.md @@ -0,0 +1,142 @@ +# Sim CLI + +Talk to the [Sim](https://sim.ai) API from your terminal. + +```bash +npm install -g @sim/cli +sim login +sim workflows list +``` + +## Profiles + +Profiles work like the AWS CLI: one identity and one set of defaults per named +profile, selected with `--profile` or `SIM_PROFILE`. This is what lets you keep +production and a local dev stack side by side without re-authenticating. + +Non-secret settings live in `~/.sim/config`: + +```ini +[default] +endpoint = https://sim.ai +workspace = ws_abc123 +output = table + +[profile dev] +endpoint = http://localhost:3000 +workspace = ws_local +``` + +Keys live in `~/.sim/credentials`, written `0600`: + +```ini +[default] +api_key = sim_… + +[dev] +api_key = sim_… +``` + +The section-naming asymmetry — `[profile dev]` in config, `[dev]` in credentials +— is the AWS convention, kept so existing habits and tooling carry over. + +```bash +sim configure --set-endpoint http://localhost:3000 --profile dev +sim configure --set-workspace ws_local --profile dev +sim profiles # list them; * marks the active one +sim whoami # resolved values, and where each came from +``` + +## Where settings come from + +Each setting resolves independently, first match wins: + +| Rank | Source | +| --- | --- | +| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) | +| 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | +| 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | +| 4 | Built-in default (`https://sim.ai`, `table`) | + +`sim whoami` prints the winning source per setting, which is usually the fastest +way to explain a surprising result. + +For CI, skip `sim login` entirely and set `SIM_API_KEY` and `SIM_WORKSPACE` — +nothing needs to touch the filesystem. `SIM_CONFIG_DIR` relocates both files if +you need to keep them somewhere other than `~/.sim`. + +## Logging in + +`sim login` uses the same browser handoff shape as `gh auth login`: the terminal +prints a pairing code and a URL, you approve in a browser, and the key comes back +over the CLI's own connection. Nothing redeemable crosses the browser leg, and +there is no loopback listener — so it works over SSH and inside containers. + +``` +$ sim login --profile dev --endpoint http://localhost:3000 + +Pairing code: K7M2-P9XT +Confirm this code matches what the browser shows before approving. + +http://localhost:3000/cli/auth?request=…&scope=platform +Waiting for approval… + +✓ Logged in. Key stored in /Users/you/.sim/credentials + Workspace-scoped key, pinned to ws_local. +``` + +The approval page is where you pick the workspace — the terminal has no key yet, +so it cannot list them for you. Whichever you pick becomes the profile's default +`workspace`, so you never have to go look up its id. + +What the key itself can reach depends on your role in that workspace, and the +page says which you are about to get before you approve: + +| Your role | Key issued | Reach | +| --- | --- | --- | +| Workspace admin | Workspace-scoped | That workspace only | +| Anything else | Personal | Every workspace you can access; `--workspace` overrides the default | + +`sim login --workspace ` preselects a workspace in the picker, and an +existing profile's workspace preselects itself on re-login. + +`sim logout` removes the stored key. It does not revoke it — do that in +Settings → API keys. + +## Commands + +```bash +sim workflows list [--folder ] [--deployed] [--limit ] +sim workflows get +sim workflows deploy|undeploy|rollback + +sim logs list [--level error] [--workflow …] [--trigger …] [--start ] +sim logs get +sim logs execution + +sim files list +sim files download [-o ] +sim files delete + +sim knowledge list +sim knowledge get +sim knowledge documents [--search ] +sim knowledge search --kb … +``` + +Every command takes `--output json` for scripting; the JSON is the API's own +response shape, so it pipes cleanly into `jq`. + +```bash +sim logs list --level error --output json | jq -r '.[].executionId' +``` + +## Notes + +- Commands talk to the `/api/v2` surface, which returns `{ data }` and + `{ data, nextCursor }`. List commands auto-page up to `--limit`. +- `sim tables` is not here yet — the tables v2 surface is still changing. + +## License + +Apache-2.0 diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json new file mode 100644 index 00000000000..4cc20b967fc --- /dev/null +++ b/packages/sim-cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@sim/cli", + "version": "0.1.0", + "description": "Sim CLI - talk to the Sim API from your terminal", + "type": "module", + "bin": { + "sim": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "prepublishOnly": "bun run build" + }, + "files": [ + "dist" + ], + "keywords": [ + "sim", + "ai", + "agents", + "cli", + "workflow" + ], + "author": "Sim", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "dependencies": { + "chalk": "5.6.2", + "commander": "^11.1.0" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^7.0.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts new file mode 100644 index 00000000000..01b428b817b --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -0,0 +1,159 @@ +import { createHash, randomBytes, randomInt } from 'node:crypto' +import { SimApiError } from '../http/client.js' + +/** + * The terminal half of the CLI key handoff. + * + * Shaped like OAuth's device authorization grant: the CLI mints a rendezvous id + * and a secret, sends only the secret's SHA-256 challenge through the browser, + * and redeems the key over its own TLS connection. The browser leg therefore + * never carries anything redeemable, and no loopback listener is required — + * which matters because the terminal is often not on the same machine as the + * browser (SSH, containers, remote dev boxes). + */ + +/** No look-alike characters: the human is comparing this across two screens. */ +const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +const POLL_INTERVAL_MS = 2000 +const POLL_TIMEOUT_MS = 15 * 60 * 1000 + +export type CliAuthScope = 'copilot' | 'platform' + +export interface AuthRequest { + /** Semi-public rendezvous handle; travels in the browser URL. */ + request: string + /** Never leaves this process until the poll redeems it. */ + pollSecret: string + /** BASE64URL(SHA256(pollSecret)), registered when the user approves. */ + challenge: string + /** Printed for the user to compare against the browser. Never sent to the API. */ + pairing: string +} + +export interface MintedKey { + id: string + apiKey: string + scope: CliAuthScope + /** The workspace picked in the browser — the profile's default target. */ + workspaceId: string | null + /** Whether the key can *only* reach that workspace. */ + workspaceBound: boolean +} + +/** 32 bytes of entropy, base64url — 43 characters, exactly what the contract accepts. */ +function token(): string { + return randomBytes(32).toString('base64url') +} + +function pairingCode(): string { + const draw = (count: number) => + Array.from({ length: count }, () => PAIRING_ALPHABET[randomInt(PAIRING_ALPHABET.length)]).join( + '' + ) + return `${draw(4)}-${draw(4)}` +} + +export function createAuthRequest(): AuthRequest { + const pollSecret = token() + return { + request: token(), + pollSecret, + challenge: createHash('sha256').update(pollSecret, 'utf8').digest('base64url'), + pairing: pairingCode(), + } +} + +export function buildApprovalUrl( + endpoint: string, + auth: AuthRequest, + scope: CliAuthScope, + workspaceId?: string +): string { + const url = new URL('/cli/auth', endpoint) + url.searchParams.set('request', auth.request) + url.searchParams.set('challenge', auth.challenge) + url.searchParams.set('pairing', auth.pairing) + url.searchParams.set('scope', scope) + if (workspaceId) url.searchParams.set('workspace', workspaceId) + return url.toString() +} + +interface PollResponse { + status: 'pending' | 'complete' + key?: { id: string; apiKey: string } + scope?: CliAuthScope + workspaceId?: string | null + workspaceBound?: boolean +} + +/** + * Polls until the user approves in the browser. + * + * Transport failures are swallowed and retried rather than aborting the login: + * a laptop that slept, a VPN reconnecting, or a deploy rolling the server mid- + * wait are all recoverable, and the approval sits in Redis with its own TTL. A + * non-2xx *response*, by contrast, is the server refusing on purpose and is + * surfaced immediately. + */ +export async function pollForKey( + endpoint: string, + auth: AuthRequest, + signal?: AbortSignal +): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS + + while (Date.now() < deadline) { + if (signal?.aborted) throw new SimApiError('Login cancelled.', 0) + + let response: Response | null = null + try { + response = await fetch(new URL('/api/cli/auth/poll', endpoint), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ request: auth.request, verifier: auth.pollSecret }), + signal, + }) + } catch { + response = null + } + + if (response) { + const raw = await response.text() + + if (!response.ok) { + // 429 is the poll cadence bumping the per-IP bucket, not a refusal — + // back off and keep the login alive instead of making the user restart. + if (response.status !== 429) { + let message = `Login failed with status ${response.status}` + try { + const body = JSON.parse(raw) as { error?: unknown } + if (typeof body.error === 'string') message = body.error + else if (body.error && typeof body.error === 'object') { + const detail = (body.error as { message?: unknown }).message + if (typeof detail === 'string') message = detail + } + } catch {} + throw new SimApiError(message, response.status) + } + } else { + const body = JSON.parse(raw) as PollResponse + if (body.status === 'complete' && body.key) { + return { + id: body.key.id, + apiKey: body.key.apiKey, + // Older servers answer without these; a key from a server that does + // not know about scopes is a copilot key by definition. + scope: body.scope ?? 'copilot', + workspaceId: body.workspaceId ?? null, + workspaceBound: body.workspaceBound === true, + } + } + } + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + + throw new SimApiError('Timed out waiting for browser approval.', 0) +} diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts new file mode 100644 index 00000000000..262e2f51bac --- /dev/null +++ b/packages/sim-cli/src/commands/auth.ts @@ -0,0 +1,195 @@ +import { spawn } from 'node:child_process' +import chalk from 'chalk' +import { Command } from 'commander' +import { + buildApprovalUrl, + type CliAuthScope, + createAuthRequest, + pollForKey, +} from '../auth/device-flow.js' +import { + credentialsPath, + deleteProfile, + listProfiles, + readCredentialsProfile, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index.js' +import { profileFrom } from '../context.js' +import { SimApiError } from '../http/client.js' +import { printRecord } from '../output/render.js' + +/** + * Best-effort browser launch. Failure is not an error: the URL is always printed + * first, so a headless box, an SSH session, or a machine with no handler just + * falls through to the user pasting it somewhere. + */ +function openBrowser(url: string): void { + const command = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + try { + const child = spawn(command, [url], { + stdio: 'ignore', + detached: true, + shell: process.platform === 'win32', + }) + child.on('error', () => {}) + child.unref() + } catch {} +} + +function maskKey(key: string): string { + return key.length <= 10 ? '•'.repeat(key.length) : `${key.slice(0, 6)}…${key.slice(-4)}` +} + +export function loginCommand(): Command { + return new Command('login') + .description('Authorize this terminal and store an API key for the profile') + .option('--scope ', 'Key space to mint from: platform or copilot', 'platform') + .option('--no-browser', 'Print the URL instead of opening a browser') + .action(async (options: { scope: string; browser: boolean }, command: Command) => { + const profile = profileFrom(command) + + if (options.scope !== 'platform' && options.scope !== 'copilot') { + throw new SimApiError(`Unknown scope "${options.scope}". Use platform or copilot.`, 0) + } + const scope = options.scope as CliAuthScope + + const auth = createAuthRequest() + const url = buildApprovalUrl(profile.endpoint, auth, scope, profile.workspaceId ?? undefined) + + console.log( + `Signing in to ${chalk.bold(profile.endpoint)} as profile ${chalk.bold(profile.name)}` + ) + console.log(`\nPairing code: ${chalk.bold(auth.pairing)}`) + console.log(chalk.dim('Confirm this code matches what the browser shows before approving.\n')) + console.log(url) + + if (options.browser) openBrowser(url) + console.log(chalk.dim('\nWaiting for approval…')) + + const key = await pollForKey(profile.endpoint, auth) + + if (key.scope !== scope) { + // The approval, not the request, decides the scope. Storing a copilot + // key where a platform key belongs would fail every later call with an + // unexplained 401, so refuse now with the reason. + throw new SimApiError( + `Server issued a ${key.scope} key but this profile needs a ${scope} key. Update the Sim deployment, or run: sim login --scope ${key.scope}`, + 0 + ) + } + + writeCredentialsProfile(profile.name, key.apiKey) + + // The workspace picked in the browser becomes the profile's default, + // whether or not the key is scoped to it. The user chose it by name — + // making them look up its id afterwards would waste the one moment the + // answer was already on screen. + const settings: Record = { endpoint: profile.endpoint } + if (key.workspaceId) settings.workspace = key.workspaceId + writeConfigProfile(profile.name, settings) + + console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) + if (key.workspaceBound && key.workspaceId) { + console.log(chalk.dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) + } else if (key.workspaceId) { + console.log( + chalk.dim( + ` Personal key, defaulting to ${key.workspaceId}. Override per command with --workspace.` + ) + ) + } else if (!profile.workspaceId) { + console.log( + chalk.dim( + ' Personal key with no default workspace. Set one with: sim configure --set-workspace ' + ) + ) + } + }) +} + +export function logoutCommand(): Command { + return new Command('logout') + .description("Remove the profile's stored API key") + .option('--all', 'Remove the profile entirely, including its settings') + .action((options: { all?: boolean }, command: Command) => { + const profile = profileFrom(command) + + if (options.all) { + const removed = deleteProfile(profile.name) + if (!removed.config && !removed.credentials) { + console.log(chalk.dim(`Nothing stored for profile "${profile.name}".`)) + return + } + console.log(chalk.green(`✓ Removed profile "${profile.name}".`)) + return + } + + if (!readCredentialsProfile(profile.name).api_key) { + console.log(chalk.dim(`No stored key for profile "${profile.name}".`)) + return + } + + writeCredentialsProfile(profile.name, null) + console.log(chalk.green(`✓ Removed the stored key for profile "${profile.name}".`)) + // The key still exists server-side; leaving that unsaid invites the + // assumption that logging out revoked it. + console.log(chalk.dim(' The key itself is still active — revoke it in Settings → API keys.')) + }) +} + +export function whoamiCommand(): Command { + return new Command('whoami') + .description('Show the resolved profile and where each setting came from') + .action((_options: unknown, command: Command) => { + const profile = profileFrom(command) + const { sources } = profile + + const annotate = (value: string, source: string) => + source === 'unset' ? chalk.dim('not set') : `${value} ${chalk.dim(`(${source})`)}` + + printRecord( + profile.output, + [ + ['Profile', profile.name], + ['Endpoint', annotate(profile.endpoint, sources.endpoint)], + [ + 'API key', + profile.apiKey + ? annotate(maskKey(profile.apiKey), sources.apiKey) + : chalk.yellow('not logged in'), + ], + ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], + ['Output', annotate(profile.output, sources.output)], + ], + { + profile: profile.name, + endpoint: profile.endpoint, + workspaceId: profile.workspaceId, + output: profile.output, + authenticated: Boolean(profile.apiKey), + sources, + } + ) + }) +} + +export function profilesCommand(): Command { + return new Command('profiles') + .description('List the profiles defined in the config and credentials files') + .action((_options: unknown, command: Command) => { + const profiles = listProfiles() + if (profiles.length === 0) { + console.log(chalk.dim('No profiles yet. Run: sim login')) + return + } + + const active = profileFrom(command).name + for (const name of profiles) { + const marker = name === active ? chalk.green('*') : ' ' + const hasKey = Boolean(readCredentialsProfile(name).api_key) + console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}`) + } + }) +} diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts new file mode 100644 index 00000000000..af804495396 --- /dev/null +++ b/packages/sim-cli/src/commands/configure.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { + configPath, + OUTPUT_FORMATS, + readConfigProfile, + writeConfigProfile, +} from '../config/index.js' +import { profileFrom } from '../context.js' +import { SimApiError } from '../http/client.js' + +/** + * Non-secret profile settings. Credentials are deliberately not settable here — + * they arrive through `sim login`, which is the only path that mints a key with + * a recorded consent behind it. + */ +export function configureCommand(): Command { + return new Command('configure') + .description("Set a profile's endpoint, default workspace, or output format") + .option('--set-endpoint ', 'Sim deployment to talk to') + .option('--set-workspace ', 'Default workspace for workspace-scoped commands') + .option('--set-output ', `Default output format (${OUTPUT_FORMATS.join(' | ')})`) + .option('--unset ', 'Remove settings (endpoint, workspace, output)') + .action( + ( + options: { + setEndpoint?: string + setWorkspace?: string + setOutput?: string + unset?: string[] + }, + command: Command + ) => { + const profile = profileFrom(command) + const updates: Record = {} + + if (options.setEndpoint) updates.endpoint = options.setEndpoint.replace(/\/+$/, '') + if (options.setWorkspace) updates.workspace = options.setWorkspace + if (options.setOutput) { + if (!(OUTPUT_FORMATS as readonly string[]).includes(options.setOutput)) { + throw new SimApiError( + `Unknown output format "${options.setOutput}". Use one of: ${OUTPUT_FORMATS.join(', ')}`, + 0 + ) + } + updates.output = options.setOutput + } + + for (const key of options.unset ?? []) { + if (!['endpoint', 'workspace', 'output'].includes(key)) { + throw new SimApiError(`Cannot unset "${key}". Use endpoint, workspace, or output.`, 0) + } + updates[key] = null + } + + if (Object.keys(updates).length === 0) { + const current = readConfigProfile(profile.name) + if (Object.keys(current).length === 0) { + console.log(chalk.dim(`No settings stored for profile "${profile.name}".`)) + return + } + for (const [key, value] of Object.entries(current)) { + console.log(`${chalk.dim(`${key}:`)} ${value}`) + } + return + } + + writeConfigProfile(profile.name, updates) + console.log(chalk.green(`✓ Updated profile "${profile.name}" in ${configPath()}`)) + } + ) +} diff --git a/packages/sim-cli/src/commands/files.ts b/packages/sim-cli/src/commands/files.ts new file mode 100644 index 00000000000..3cf5c1df6c0 --- /dev/null +++ b/packages/sim-cli/src/commands/files.ts @@ -0,0 +1,129 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { basename } from 'node:path' +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { SimApiError } from '../http/client.js' +import { bytes, type Column, printList, timestamp } from '../output/render.js' + +interface WorkspaceFile { + id: string + name: string + size: number + type: string + key: string + uploadedBy: string + uploadedAt: string +} + +/** + * Streams a fetch body to disk, honouring backpressure. + * + * Written as an explicit reader loop rather than `Readable.fromWeb`: the DOM + * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares + * are structurally incompatible under this TS config, and bridging them needs a + * cast that would erase exactly the typing this loop keeps honest. + */ +async function streamToFile(body: ReadableStream, file: WriteStream): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the internal buffer is full; waiting for + // `drain` is what stops a large file from being buffered in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve, reject) => { + file.once('error', reject) + file.end(resolve) + }) +} + +const LIST_COLUMNS: Column[] = [ + { header: 'id', value: (file) => file.id }, + { header: 'name', value: (file) => file.name }, + { header: 'size', value: (file) => bytes(file.size) }, + { header: 'type', value: (file) => file.type }, + { header: 'uploaded', value: (file) => timestamp(file.uploadedAt) }, +] + +export function filesCommand(): Command { + const files = new Command('files').alias('file').description('List and download workspace files') + + files + .command('list') + .alias('ls') + .description('List files in a workspace') + .option('--limit ', 'Maximum files to return', '100') + .action(async (options: { limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + '/api/v2/files', + { query: { workspaceId: client.requireWorkspace(), limit: Math.min(limit, 1000) } }, + limit + ) + + printList(profile.output, rows, LIST_COLUMNS) + }) + + files + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + // Streamed rather than routed through the JSON client: the response is + // binary of unbounded size, so buffering it just to write it out would put + // the whole file in memory. + const url = new URL(`${profile.endpoint}/api/v2/files/${fileId}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + // `filename="…"` from the route's content-disposition, when present. + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + await streamToFile(response.body, createWriteStream(target)) + console.log(chalk.green(`✓ Saved ${target}`)) + }) + + files + .command('delete ') + .description('Archive a file') + .action(async (fileId: string, _options: unknown, command: Command) => { + const { client } = clientFrom(command) + await client.getData(`/api/v2/files/${fileId}`, { + method: 'DELETE', + query: { workspaceId: client.requireWorkspace() }, + }) + console.log(chalk.green(`✓ Deleted ${fileId}`)) + }) + + return files +} diff --git a/packages/sim-cli/src/commands/knowledge.ts b/packages/sim-cli/src/commands/knowledge.ts new file mode 100644 index 00000000000..00a8a95ec43 --- /dev/null +++ b/packages/sim-cli/src/commands/knowledge.ts @@ -0,0 +1,165 @@ +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { bytes, type Column, printList, printRecord, text, timestamp } from '../output/render.js' + +interface KnowledgeBase { + id: string + name: string + description: string | null + docCount: number + tokenCount: number + embeddingModel: string + createdAt: string | null + updatedAt: string | null +} + +interface KnowledgeDocument { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: string + chunkCount: number + tokenCount: number + enabled: boolean + createdAt: string | null +} + +interface SearchHit { + documentId: string + documentName: string | null + content: string + chunkIndex: number + similarity: number +} + +const BASE_COLUMNS: Column[] = [ + { header: 'id', value: (kb) => kb.id }, + { header: 'name', value: (kb) => kb.name }, + { header: 'docs', value: (kb) => String(kb.docCount) }, + { header: 'tokens', value: (kb) => String(kb.tokenCount) }, + { header: 'model', value: (kb) => kb.embeddingModel }, +] + +const DOCUMENT_COLUMNS: Column[] = [ + { header: 'id', value: (doc) => doc.id }, + { header: 'filename', value: (doc) => doc.filename }, + { header: 'size', value: (doc) => bytes(doc.fileSize) }, + { header: 'status', value: (doc) => doc.processingStatus }, + { header: 'chunks', value: (doc) => String(doc.chunkCount) }, + { header: 'created', value: (doc) => timestamp(doc.createdAt) }, +] + +/** Search hits are long prose; keep the table readable and single-line. */ +function preview(content: string): string { + const collapsed = content.replace(/\s+/g, ' ').trim() + return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` +} + +export function knowledgeCommand(): Command { + const knowledge = new Command('knowledge') + .alias('kb') + .description('Browse and search knowledge bases') + + knowledge + .command('list') + .alias('ls') + .description('List knowledge bases in a workspace') + .action(async (_options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const page = await client.getPage('/api/v2/knowledge', { + query: { workspaceId: client.requireWorkspace() }, + }) + printList(profile.output, page.data, BASE_COLUMNS) + }) + + knowledge + .command('get ') + .description('Show one knowledge base') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const { knowledgeBase } = await client.getData<{ knowledgeBase: KnowledgeBase }>( + `/api/v2/knowledge/${id}`, + { query: { workspaceId: client.requireWorkspace() } } + ) + + printRecord( + profile.output, + [ + ['ID', knowledgeBase.id], + ['Name', knowledgeBase.name], + ['Description', text(knowledgeBase.description)], + ['Documents', String(knowledgeBase.docCount)], + ['Tokens', String(knowledgeBase.tokenCount)], + ['Embedding model', knowledgeBase.embeddingModel], + ['Updated', timestamp(knowledgeBase.updatedAt)], + ], + knowledgeBase + ) + }) + + knowledge + .command('documents ') + .alias('docs') + .description('List the documents in a knowledge base') + .option('--search ', 'Filter by filename') + .option('--status ', 'Filter by enabled state: all, enabled, or disabled', 'all') + .option('--limit ', 'Maximum documents to return', '50') + .action( + async ( + id: string, + options: { search?: string; status: string; limit: string }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + `/api/v2/knowledge/${id}/documents`, + { + query: { + workspaceId: client.requireWorkspace(), + search: options.search, + enabledFilter: options.status, + limit: Math.min(limit, 100), + }, + }, + limit + ) + + printList(profile.output, rows, DOCUMENT_COLUMNS) + } + ) + + knowledge + .command('search ') + .description('Vector-search one or more knowledge bases') + .requiredOption('--kb ', 'Knowledge base ids to search') + .option('--top-k ', 'Number of hits to return', '10') + .action(async (query: string, options: { kb: string[]; topK: string }, command: Command) => { + const { client, profile } = clientFrom(command) + + const result = await client.getData<{ results: SearchHit[]; totalResults: number }>( + '/api/v2/knowledge/search', + { + method: 'POST', + body: { + workspaceId: client.requireWorkspace(), + knowledgeBaseIds: options.kb, + query, + topK: Number.parseInt(options.topK, 10), + }, + } + ) + + printList(profile.output, result.results, [ + { header: 'score', value: (hit) => hit.similarity.toFixed(3) }, + { header: 'document', value: (hit) => text(hit.documentName ?? hit.documentId) }, + { header: 'chunk', value: (hit) => String(hit.chunkIndex) }, + { header: 'content', value: (hit) => preview(hit.content) }, + ]) + }) + + return knowledge +} diff --git a/packages/sim-cli/src/commands/logs.ts b/packages/sim-cli/src/commands/logs.ts new file mode 100644 index 00000000000..ac20e8e76d2 --- /dev/null +++ b/packages/sim-cli/src/commands/logs.ts @@ -0,0 +1,159 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { type Column, duration, printList, printRecord, text, timestamp } from '../output/render.js' + +interface LogListItem { + id: string + workflowId: string | null + executionId: string + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { total: number } | null + workflow?: { id: string | null; name: string; deleted: boolean } +} + +interface LogDetail extends LogListItem { + executionData: unknown + createdAt: string +} + +function level(value: string): string { + return value === 'error' ? chalk.red(value) : value +} + +function cost(value: { total: number } | null): string { + return value ? `$${value.total.toFixed(4)}` : text(null) +} + +const LIST_COLUMNS: Column[] = [ + { header: 'started', value: (log) => timestamp(log.startedAt) }, + { header: 'level', value: (log) => level(log.level) }, + { header: 'trigger', value: (log) => log.trigger }, + { header: 'workflow', value: (log) => text(log.workflow?.name ?? log.workflowId) }, + { header: 'duration', value: (log) => duration(log.totalDurationMs) }, + { header: 'cost', value: (log) => cost(log.cost) }, + { header: 'execution', value: (log) => log.executionId }, +] + +export function logsCommand(): Command { + const logs = new Command('logs').alias('log').description('Read workflow execution logs') + + logs + .command('list') + .alias('ls') + .description('List execution logs in a workspace') + .option('--workflow ', 'Restrict to these workflow ids') + .option('--trigger ', 'Restrict to these triggers (api, schedule, webhook, manual…)') + .option('--level ', 'Filter by level: info or error') + .option('--execution ', 'Restrict to a single execution id') + .option('--start ', 'Only runs starting at or after this ISO date') + .option('--end ', 'Only runs starting at or before this ISO date') + .option('--order ', 'Sort by start time: desc or asc', 'desc') + .option('--limit ', 'Maximum logs to return', '50') + .action( + async ( + options: { + workflow?: string[] + trigger?: string[] + level?: string + execution?: string + start?: string + end?: string + order: string + limit: string + }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + '/api/v2/logs', + { + query: { + workspaceId: client.requireWorkspace(), + // The route takes these as comma-joined strings, not repeated params. + workflowIds: options.workflow?.join(','), + triggers: options.trigger?.join(','), + level: options.level, + executionId: options.execution, + startDate: options.start, + endDate: options.end, + order: options.order, + details: 'full', + limit: Math.min(limit, 1000), + }, + }, + limit + ) + + printList(profile.output, rows, LIST_COLUMNS) + } + ) + + logs + .command('get ') + .description('Show one log, including its execution trace') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const log = await client.getData(`/api/v2/logs/${id}`) + + printRecord( + profile.output, + [ + ['ID', log.id], + ['Execution', log.executionId], + ['Workflow', text(log.workflow?.name ?? log.workflowId)], + ['Level', level(log.level)], + ['Trigger', log.trigger], + ['Started', timestamp(log.startedAt)], + ['Ended', timestamp(log.endedAt)], + ['Duration', duration(log.totalDurationMs)], + ['Cost', cost(log.cost)], + ], + log + ) + + if (profile.output === 'table') { + console.log(chalk.dim('\nRun with --output json to see the full execution trace.')) + } + }) + + logs + .command('execution ') + .description('Show the workflow state snapshot for an execution') + .action(async (executionId: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const execution = await client.getData<{ + executionId: string + workflowId: string | null + executionMetadata: { + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { total: number } | null + } + }>(`/api/v2/logs/executions/${executionId}`) + + printRecord( + profile.output, + [ + ['Execution', execution.executionId], + ['Workflow', text(execution.workflowId)], + ['Trigger', execution.executionMetadata.trigger], + ['Started', timestamp(execution.executionMetadata.startedAt)], + ['Ended', timestamp(execution.executionMetadata.endedAt)], + ['Duration', duration(execution.executionMetadata.totalDurationMs)], + ['Cost', cost(execution.executionMetadata.cost)], + ], + execution + ) + }) + + return logs +} diff --git a/packages/sim-cli/src/commands/workflows.ts b/packages/sim-cli/src/commands/workflows.ts new file mode 100644 index 00000000000..a2acca4c076 --- /dev/null +++ b/packages/sim-cli/src/commands/workflows.ts @@ -0,0 +1,148 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import { bool, type Column, printList, printRecord, text, timestamp } from '../output/render.js' + +interface WorkflowListItem { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string +} + +interface WorkflowDetail extends WorkflowListItem { + variables: Record + inputs: Array<{ name: string; type: string; description?: string }> +} + +const LIST_COLUMNS: Column[] = [ + { header: 'id', value: (w) => w.id }, + { header: 'name', value: (w) => w.name }, + { header: 'deployed', value: (w) => bool(w.isDeployed) }, + { header: 'runs', value: (w) => String(w.runCount) }, + { header: 'last run', value: (w) => timestamp(w.lastRunAt) }, +] + +export function workflowsCommand(): Command { + const workflows = new Command('workflows') + .alias('workflow') + .description('List and manage workflows') + + workflows + .command('list') + .alias('ls') + .description('List workflows in a workspace') + .option('--folder ', 'Only workflows in this folder') + .option('--deployed', 'Only deployed workflows') + .option('--limit ', 'Maximum workflows to return', '50') + .action( + async (options: { folder?: string; deployed?: boolean; limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const limit = Number.parseInt(options.limit, 10) + + const rows = await client.collect( + '/api/v2/workflows', + { + query: { + workspaceId: client.requireWorkspace(), + folderId: options.folder, + deployedOnly: options.deployed ? 'true' : undefined, + // The route caps a page at 100; `collect` pages past that up to `limit`. + limit: Math.min(limit, 100), + }, + }, + limit + ) + + printList(profile.output, rows, LIST_COLUMNS) + } + ) + + workflows + .command('get ') + .description('Show one workflow, including its trigger inputs') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const workflow = await client.getData(`/api/v2/workflows/${id}`) + + printRecord( + profile.output, + [ + ['ID', workflow.id], + ['Name', workflow.name], + ['Description', text(workflow.description)], + ['Workspace', workflow.workspaceId], + ['Folder', text(workflow.folderId)], + ['Deployed', bool(workflow.isDeployed)], + ['Deployed at', timestamp(workflow.deployedAt)], + ['Runs', String(workflow.runCount)], + ['Last run', timestamp(workflow.lastRunAt)], + [ + 'Inputs', + workflow.inputs.length > 0 + ? workflow.inputs.map((input) => `${input.name}:${input.type}`).join(', ') + : text(null), + ], + ['Updated', timestamp(workflow.updatedAt)], + ], + workflow + ) + }) + + workflows + .command('deploy ') + .description('Deploy a workflow') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = await client.getData>( + `/api/v2/workflows/${id}/deploy`, + { method: 'POST' } + ) + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Deployed ${id}`)) + }) + + workflows + .command('undeploy ') + .description('Take a workflow out of deployment') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = await client.getData>( + `/api/v2/workflows/${id}/deploy`, + { method: 'DELETE' } + ) + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Undeployed ${id}`)) + }) + + workflows + .command('rollback ') + .description('Roll a deployed workflow back to its previous version') + .action(async (id: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = await client.getData>( + `/api/v2/workflows/${id}/rollback`, + { method: 'POST' } + ) + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Rolled back ${id}`)) + }) + + return workflows +} diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts new file mode 100644 index 00000000000..5a11e311370 --- /dev/null +++ b/packages/sim-cli/src/config/index.ts @@ -0,0 +1,17 @@ +export { configDir, configPath, credentialsPath } from './paths.js' +export { + DEFAULT_ENDPOINT, + DEFAULT_PROFILE, + deleteProfile, + listProfiles, + OUTPUT_FORMATS, + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + readConfigProfile, + readCredentialsProfile, + resolveProfile, + type SettingSource, + writeConfigProfile, + writeCredentialsProfile, +} from './profile.js' diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts new file mode 100644 index 00000000000..ba3a93fb84c --- /dev/null +++ b/packages/sim-cli/src/config/ini.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + getSection, + listSections, + parseIni, + removeSection, + serializeIni, + setSectionValues, +} from './ini.js' + +const SAMPLE = `# top-level note +[default] +endpoint = https://sim.ai +workspace = ws_1 + +[profile dev] +# points at the local stack +endpoint = http://localhost:3000 +` + +describe('ini', () => { + it('reads keys out of a section', () => { + expect(getSection(parseIni(SAMPLE), 'default')).toEqual({ + endpoint: 'https://sim.ai', + workspace: 'ws_1', + }) + }) + + it('reads a section whose name contains a space', () => { + expect(getSection(parseIni(SAMPLE), 'profile dev')).toEqual({ + endpoint: 'http://localhost:3000', + }) + }) + + it('returns null for a section that is not there', () => { + expect(getSection(parseIni(SAMPLE), 'profile nope')).toBeNull() + }) + + it('lists sections in file order', () => { + expect(listSections(parseIni(SAMPLE))).toEqual(['default', 'profile dev']) + }) + + it('preserves comments and untouched keys through a write', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile dev', { workspace: 'ws_local' }) + const out = serializeIni(doc) + + expect(out).toContain('# top-level note') + expect(out).toContain('# points at the local stack') + expect(out).toContain('endpoint = http://localhost:3000') + expect(out).toContain('workspace = ws_local') + }) + + it('updates a key in place rather than appending a duplicate', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'default', { endpoint: 'https://staging.sim.ai' }) + const out = serializeIni(doc) + + expect(out).not.toContain('https://sim.ai\n') + expect(out.match(/endpoint = /g)).toHaveLength(2) // one per section, not three + }) + + it('removes a key when the value is null', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'default', { workspace: null }) + expect(getSection(parseIni(serializeIni(doc)), 'default')).toEqual({ + endpoint: 'https://sim.ai', + }) + }) + + it('creates a section that does not exist yet', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile prod', { endpoint: 'https://sim.ai' }) + expect(getSection(parseIni(serializeIni(doc)), 'profile prod')).toEqual({ + endpoint: 'https://sim.ai', + }) + }) + + it('does not accumulate blank lines across repeated writes', () => { + let text = SAMPLE + for (let i = 0; i < 5; i++) { + const doc = parseIni(text) + setSectionValues(doc, 'default', { workspace: `ws_${i}` }) + text = serializeIni(doc) + } + expect(text).not.toContain('\n\n\n') + }) + + it('keeps a comment containing "=" as a comment', () => { + const doc = parseIni('[default]\n# note: a = b\nendpoint = https://sim.ai\n') + expect(getSection(doc, 'default')).toEqual({ endpoint: 'https://sim.ai' }) + expect(serializeIni(doc)).toContain('# note: a = b') + }) + + it('removes a whole section', () => { + const doc = parseIni(SAMPLE) + expect(removeSection(doc, 'profile dev')).toBe(true) + expect(removeSection(doc, 'profile dev')).toBe(false) + expect(listSections(doc)).toEqual(['default']) + }) + + it('round-trips an empty document without emitting a stray newline', () => { + expect(serializeIni(parseIni(''))).toBe('') + }) +}) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts new file mode 100644 index 00000000000..6b220b82267 --- /dev/null +++ b/packages/sim-cli/src/config/ini.ts @@ -0,0 +1,130 @@ +/** + * A minimal INI reader/writer for the AWS-style `~/.sim/config` and + * `~/.sim/credentials` files. + * + * Parsing keeps every line it did not understand — comments, blank lines, + * unrecognized keys — and writing re-emits them in place. These are files people + * hand-edit, so a round trip through `sim login` must not silently delete the + * comment above someone's staging endpoint. + * + * Deliberately not a general INI implementation: no nested sections, no `[a.b]` + * paths, no quoting rules beyond trimming. The format only has to carry a + * handful of flat string settings. + */ + +type Entry = { kind: 'kv'; key: string; value: string } | { kind: 'raw'; text: string } + +interface Section { + name: string + entries: Entry[] +} + +export interface IniDocument { + /** Lines before the first section header. */ + preamble: string[] + sections: Section[] +} + +const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/ +const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ + +export function parseIni(text: string): IniDocument { + const doc: IniDocument = { preamble: [], sections: [] } + let current: Section | null = null + + for (const line of text.split('\n')) { + const sectionMatch = SECTION_PATTERN.exec(line) + if (sectionMatch) { + current = { name: sectionMatch[1].trim(), entries: [] } + doc.sections.push(current) + continue + } + + if (!current) { + doc.preamble.push(line) + continue + } + + const kvMatch = KV_PATTERN.exec(line) + // A `#`/`;` comment can contain `=`, so the comment check must come first. + if (kvMatch && !/^\s*[#;]/.test(line)) { + current.entries.push({ kind: 'kv', key: kvMatch[1], value: kvMatch[2] }) + } else { + current.entries.push({ kind: 'raw', text: line }) + } + } + + return doc +} + +export function serializeIni(doc: IniDocument): string { + const lines: string[] = [...doc.preamble] + + for (const section of doc.sections) { + // Keep exactly one blank line between sections without accumulating them + // across repeated writes. + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + if (lines.length > 0) lines.push('') + lines.push(`[${section.name}]`) + for (const entry of section.entries) { + lines.push(entry.kind === 'kv' ? `${entry.key} = ${entry.value}` : entry.text) + } + } + + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop() + return lines.length > 0 ? `${lines.join('\n')}\n` : '' +} + +export function getSection(doc: IniDocument, name: string): Record | null { + const section = doc.sections.find((s) => s.name === name) + if (!section) return null + + const values: Record = {} + for (const entry of section.entries) { + if (entry.kind === 'kv') values[entry.key] = entry.value + } + return values +} + +export function listSections(doc: IniDocument): string[] { + return doc.sections.map((s) => s.name) +} + +/** + * Upserts values into a section, creating it when absent. A `null` value removes + * the key. Existing keys are updated where they sit so surrounding comments keep + * describing the line they were written above. + */ +export function setSectionValues( + doc: IniDocument, + name: string, + values: Record +): void { + let section = doc.sections.find((s) => s.name === name) + if (!section) { + section = { name, entries: [] } + doc.sections.push(section) + } + + for (const [key, value] of Object.entries(values)) { + const index = section.entries.findIndex((e) => e.kind === 'kv' && e.key === key) + + if (value === null) { + if (index !== -1) section.entries.splice(index, 1) + continue + } + + if (index === -1) { + section.entries.push({ kind: 'kv', key, value }) + } else { + section.entries[index] = { kind: 'kv', key, value } + } + } +} + +export function removeSection(doc: IniDocument, name: string): boolean { + const index = doc.sections.findIndex((s) => s.name === name) + if (index === -1) return false + doc.sections.splice(index, 1) + return true +} diff --git a/packages/sim-cli/src/config/paths.ts b/packages/sim-cli/src/config/paths.ts new file mode 100644 index 00000000000..158a356d57c --- /dev/null +++ b/packages/sim-cli/src/config/paths.ts @@ -0,0 +1,21 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * Where the CLI keeps its state. `SIM_CONFIG_DIR` overrides the location + * wholesale, which is what lets tests and CI point at a scratch directory + * instead of the invoking user's real credentials. + */ +export function configDir(): string { + return process.env.SIM_CONFIG_DIR || join(homedir(), '.sim') +} + +/** Non-secret per-profile settings. Safe to commit to a dotfiles repo. */ +export function configPath(): string { + return process.env.SIM_CONFIG_FILE || join(configDir(), 'config') +} + +/** API keys, written 0600. Kept apart from `config` so the two can be handled differently. */ +export function credentialsPath(): string { + return process.env.SIM_CREDENTIALS_FILE || join(configDir(), 'credentials') +} diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts new file mode 100644 index 00000000000..141166945be --- /dev/null +++ b/packages/sim-cli/src/config/profile.test.ts @@ -0,0 +1,137 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { configPath, credentialsPath } from './paths.js' +import { + deleteProfile, + listProfiles, + resolveProfile, + writeConfigProfile, + writeCredentialsProfile, +} from './profile.js' + +let dir: string +const ENV_KEYS = ['SIM_PROFILE', 'SIM_ENDPOINT', 'SIM_API_KEY', 'SIM_WORKSPACE', 'SIM_OUTPUT'] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-cli-')) + process.env.SIM_CONFIG_DIR = dir + for (const key of ENV_KEYS) delete process.env[key] +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + process.env.SIM_CONFIG_DIR = undefined + for (const key of ENV_KEYS) delete process.env[key] +}) + +describe('profile resolution', () => { + it('falls back to built-in defaults with nothing configured', () => { + const profile = resolveProfile() + expect(profile.name).toBe('default') + expect(profile.endpoint).toBe('https://sim.ai') + expect(profile.apiKey).toBeNull() + expect(profile.output).toBe('table') + expect(profile.sources.apiKey).toBe('unset') + }) + + it('reads settings and credentials for the default profile', () => { + writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_1' }) + writeCredentialsProfile('default', 'sim_key') + + const profile = resolveProfile() + expect(profile.endpoint).toBe('https://a.example') + expect(profile.workspaceId).toBe('ws_1') + expect(profile.apiKey).toBe('sim_key') + expect(profile.sources).toMatchObject({ endpoint: 'config', apiKey: 'credentials' }) + }) + + it('namespaces a non-default profile as [profile x] in config but [x] in credentials', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'sim_dev') + + expect(readFileSync(configPath(), 'utf8')).toContain('[profile dev]') + expect(readFileSync(credentialsPath(), 'utf8')).toContain('[dev]') + expect(readFileSync(credentialsPath(), 'utf8')).not.toContain('[profile dev]') + }) + + it('keeps profiles isolated from one another', () => { + writeConfigProfile('default', { endpoint: 'https://a.example', workspace: 'ws_a' }) + writeCredentialsProfile('default', 'key_a') + writeConfigProfile('dev', { endpoint: 'http://localhost:3000', workspace: 'ws_b' }) + writeCredentialsProfile('dev', 'key_b') + + expect(resolveProfile()).toMatchObject({ workspaceId: 'ws_a', apiKey: 'key_a' }) + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + workspaceId: 'ws_b', + apiKey: 'key_b', + }) + }) + + it('lets a flag beat the environment, and the environment beat the file', () => { + writeConfigProfile('default', { endpoint: 'https://file.example' }) + + expect(resolveProfile().endpoint).toBe('https://file.example') + + process.env.SIM_ENDPOINT = 'https://env.example' + expect(resolveProfile()).toMatchObject({ endpoint: 'https://env.example' }) + expect(resolveProfile().sources.endpoint).toBe('env') + + expect(resolveProfile({ endpoint: 'https://flag.example' })).toMatchObject({ + endpoint: 'https://flag.example', + }) + expect(resolveProfile({ endpoint: 'https://flag.example' }).sources.endpoint).toBe('flag') + }) + + it('selects the profile from SIM_PROFILE when no flag is given', () => { + writeCredentialsProfile('dev', 'key_dev') + process.env.SIM_PROFILE = 'dev' + expect(resolveProfile()).toMatchObject({ name: 'dev', apiKey: 'key_dev' }) + expect(resolveProfile({ profile: 'default' }).name).toBe('default') + }) + + it('strips a trailing slash so paths do not double up', () => { + expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') + }) + + it('ignores an unrecognized output format instead of failing the whole resolve', () => { + process.env.SIM_OUTPUT = 'yaml' + expect(resolveProfile().output).toBe('table') + }) + + it('writes credentials 0600 even when the file already existed world-readable', () => { + writeFileSync(credentialsPath(), '', { mode: 0o644 }) + writeCredentialsProfile('default', 'sim_key') + expect(statSync(credentialsPath()).mode & 0o777).toBe(0o600) + }) + + it('lists profiles from both files without duplicating', () => { + writeConfigProfile('default', { endpoint: 'https://a.example' }) + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('ci', 'key') + + expect(listProfiles()).toEqual(['ci', 'default', 'dev']) + }) + + it('deletes a profile from both files', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + + expect(deleteProfile('dev')).toEqual({ config: true, credentials: true }) + expect(listProfiles()).toEqual([]) + expect(deleteProfile('dev')).toEqual({ config: false, credentials: false }) + }) + + it('clears just the key when the credential is removed', () => { + writeConfigProfile('dev', { endpoint: 'http://localhost:3000' }) + writeCredentialsProfile('dev', 'key') + writeCredentialsProfile('dev', null) + + expect(resolveProfile({ profile: 'dev' })).toMatchObject({ + apiKey: null, + endpoint: 'http://localhost:3000', + }) + }) +}) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts new file mode 100644 index 00000000000..943d414c7c7 --- /dev/null +++ b/packages/sim-cli/src/config/profile.ts @@ -0,0 +1,204 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + getSection, + type IniDocument, + listSections, + parseIni, + removeSection, + serializeIni, + setSectionValues, +} from './ini.js' +import { configPath, credentialsPath } from './paths.js' + +export const DEFAULT_PROFILE = 'default' +export const DEFAULT_ENDPOINT = 'https://sim.ai' +export const OUTPUT_FORMATS = ['table', 'json'] as const +export type OutputFormat = (typeof OUTPUT_FORMATS)[number] + +/** Everything a command needs to make a call, after the resolution chain runs. */ +export interface ResolvedProfile { + name: string + endpoint: string + apiKey: string | null + workspaceId: string | null + output: OutputFormat + /** Where each value came from, for `sim whoami` to explain surprising results. */ + sources: { + endpoint: SettingSource + apiKey: SettingSource + workspaceId: SettingSource + output: SettingSource + } +} + +export type SettingSource = 'flag' | 'env' | 'config' | 'credentials' | 'default' | 'unset' + +export interface ProfileOverrides { + profile?: string + endpoint?: string + apiKey?: string + workspaceId?: string + output?: string +} + +/** + * AWS's asymmetry, reproduced deliberately: the config file namespaces + * non-default profiles as `[profile dev]` while the credentials file uses a bare + * `[dev]`. It is a wart, but matching it means muscle memory and existing + * tooling carry over. + */ +function configSectionName(profile: string): string { + return profile === DEFAULT_PROFILE ? DEFAULT_PROFILE : `profile ${profile}` +} + +function readIni(path: string): IniDocument { + if (!existsSync(path)) return { preamble: [], sections: [] } + return parseIni(readFileSync(path, 'utf8')) +} + +function writeIni(path: string, doc: IniDocument, secret: boolean): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeFileSync(path, serializeIni(doc), { mode: secret ? 0o600 : 0o644 }) + // `writeFileSync`'s mode only applies when it creates the file, so an existing + // credentials file written before this ran (or created by a hand `touch`) + // keeps its old, possibly world-readable, permissions without this. + if (secret) chmodSync(path, 0o600) +} + +export function readConfigProfile(profile: string): Record { + return getSection(readIni(configPath()), configSectionName(profile)) ?? {} +} + +export function readCredentialsProfile(profile: string): Record { + return getSection(readIni(credentialsPath()), profile) ?? {} +} + +/** Every profile named by either file, deduplicated and sorted. */ +export function listProfiles(): string[] { + const names = new Set() + + for (const section of listSections(readIni(configPath()))) { + if (section === DEFAULT_PROFILE) names.add(DEFAULT_PROFILE) + else if (section.startsWith('profile ')) names.add(section.slice('profile '.length).trim()) + } + for (const section of listSections(readIni(credentialsPath()))) { + names.add(section) + } + + return [...names].sort() +} + +export function writeConfigProfile(profile: string, values: Record): void { + const doc = readIni(configPath()) + setSectionValues(doc, configSectionName(profile), values) + writeIni(configPath(), doc, false) +} + +export function writeCredentialsProfile(profile: string, apiKey: string | null): void { + const doc = readIni(credentialsPath()) + setSectionValues(doc, profile, { api_key: apiKey }) + writeIni(credentialsPath(), doc, true) +} + +/** Drops the profile from both files. Returns whether anything was removed. */ +export function deleteProfile(profile: string): { config: boolean; credentials: boolean } { + const configDoc = readIni(configPath()) + const config = removeSection(configDoc, configSectionName(profile)) + if (config) writeIni(configPath(), configDoc, false) + + const credentialsDoc = readIni(credentialsPath()) + const credentials = removeSection(credentialsDoc, profile) + if (credentials) writeIni(credentialsPath(), credentialsDoc, true) + + return { config, credentials } +} + +function normalizeEndpoint(endpoint: string): string { + // A trailing slash here produces `https://sim.ai//api/v2/...`, which some + // proxies 404 rather than normalize. + return endpoint.replace(/\/+$/, '') +} + +function parseOutput(value: string | undefined): OutputFormat | null { + return value && (OUTPUT_FORMATS as readonly string[]).includes(value) + ? (value as OutputFormat) + : null +} + +/** + * Resolves one setting through the precedence chain, reporting where it landed. + * Order is flags → environment → files → built-in default, the same order every + * profile-based CLI uses: the more specific and more ephemeral the source, the + * higher it wins. + */ +function resolve( + candidates: Array<[SettingSource, T | null | undefined]>, + fallback: T | null, + fallbackSource: SettingSource +): { value: T | null; source: SettingSource } { + for (const [source, value] of candidates) { + if (value !== null && value !== undefined && value !== '') return { value, source } + } + return { value: fallback, source: fallbackSource } +} + +export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfile { + const name = overrides.profile || process.env.SIM_PROFILE || DEFAULT_PROFILE + const config = readConfigProfile(name) + const credentials = readCredentialsProfile(name) + + const endpoint = resolve( + [ + ['flag', overrides.endpoint], + ['env', process.env.SIM_ENDPOINT], + ['config', config.endpoint], + ], + DEFAULT_ENDPOINT, + 'default' + ) + + const apiKey = resolve( + [ + ['flag', overrides.apiKey], + ['env', process.env.SIM_API_KEY], + ['credentials', credentials.api_key], + ], + null, + 'unset' + ) + + const workspaceId = resolve( + [ + ['flag', overrides.workspaceId], + ['env', process.env.SIM_WORKSPACE], + ['config', config.workspace], + ], + null, + 'unset' + ) + + const output = resolve( + [ + ['flag', parseOutput(overrides.output)], + ['env', parseOutput(process.env.SIM_OUTPUT)], + ['config', parseOutput(config.output)], + ], + 'table', + 'default' + ) + + return { + name, + endpoint: normalizeEndpoint(endpoint.value as string), + apiKey: apiKey.value, + workspaceId: workspaceId.value, + output: output.value as OutputFormat, + sources: { + endpoint: endpoint.source, + apiKey: apiKey.source, + workspaceId: workspaceId.source, + output: output.source, + }, + } +} diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts new file mode 100644 index 00000000000..9e706baa404 --- /dev/null +++ b/packages/sim-cli/src/context.ts @@ -0,0 +1,36 @@ +import type { Command } from 'commander' +import { type ProfileOverrides, type ResolvedProfile, resolveProfile } from './config/index.js' +import { SimClient } from './http/client.js' + +/** Global flags, shared by every subcommand. */ +export interface GlobalOptions { + profile?: string + endpoint?: string + workspace?: string + output?: string +} + +/** + * Commander stores globals on the root command, not on the leaf that ran, so a + * subcommand handler has to walk up to find them. `optsWithGlobals()` does that + * walk; reading `command.opts()` alone silently drops `--profile`. + */ +export function globalsOf(command: Command): GlobalOptions { + return command.optsWithGlobals() as GlobalOptions +} + +export function profileFrom(command: Command, extra: ProfileOverrides = {}): ResolvedProfile { + const globals = globalsOf(command) + return resolveProfile({ + profile: globals.profile, + endpoint: globals.endpoint, + workspaceId: globals.workspace, + output: globals.output, + ...extra, + }) +} + +export function clientFrom(command: Command): { client: SimClient; profile: ResolvedProfile } { + const profile = profileFrom(command) + return { client: new SimClient(profile), profile } +} diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts new file mode 100644 index 00000000000..72afaca74e6 --- /dev/null +++ b/packages/sim-cli/src/http/client.ts @@ -0,0 +1,194 @@ +import type { ResolvedProfile } from '../config/index.js' + +/** + * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed + * as a clean message and a non-zero exit; anything else escapes as a stack + * trace, which is the signal that the CLI itself is broken rather than the + * request. + */ +export class SimApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string | null = null, + readonly details?: unknown + ) { + super(message) + this.name = 'SimApiError' + } +} + +/** `{ data }` — a single resource. */ +interface V2DataEnvelope { + data: T +} + +/** `{ data, nextCursor }` — one page of a list. */ +export interface V2Page { + data: T[] + nextCursor: string | null +} + +export type QueryValue = string | number | boolean | null | undefined + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + query?: Record + body?: unknown +} + +function buildUrl(endpoint: string, path: string, query?: Record): string { + const url = new URL(`${endpoint}${path}`) + for (const [key, value] of Object.entries(query ?? {})) { + if (value === null || value === undefined || value === '') continue + url.searchParams.set(key, String(value)) + } + return url.toString() +} + +/** + * Pulls a human-readable message out of whatever the server returned. + * + * v2 answers with `{ error: { code, message } }`, but a request can also be + * turned away before it reaches a v2 route — by the v1 auth middleware + * (`{ error }`), or by a proxy that returns HTML. Each of those still has to + * produce a sentence rather than `[object Object]`. + */ +function toApiError(status: number, raw: string): SimApiError { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + const text = raw.trim() + return new SimApiError( + text ? truncate(text, 300) : `Request failed with status ${status}`, + status + ) + } + + const body = parsed as { error?: unknown; message?: unknown } + + if (body.error && typeof body.error === 'object') { + const error = body.error as { code?: unknown; message?: unknown; details?: unknown } + return new SimApiError( + typeof error.message === 'string' ? error.message : `Request failed with status ${status}`, + status, + typeof error.code === 'string' ? error.code : null, + error.details + ) + } + + if (typeof body.error === 'string') return new SimApiError(body.error, status) + if (typeof body.message === 'string') return new SimApiError(body.message, status) + + return new SimApiError(`Request failed with status ${status}`, status) +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}…` +} + +export class SimClient { + constructor(private readonly profile: ResolvedProfile) {} + + private requireAuth(): string { + if (!this.profile.apiKey) { + throw new SimApiError( + `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.name}`, + 0 + ) + } + return this.profile.apiKey + } + + /** + * The workspace every workspace-scoped command defaults to. + * + * Checks the key first even though it does not need one: commands resolve the + * workspace while building their query, so without this a brand-new install + * is told to set a workspace when the actual first step is logging in. + */ + requireWorkspace(explicit?: string): string { + this.requireAuth() + const workspaceId = explicit ?? this.profile.workspaceId + if (!workspaceId) { + throw new SimApiError( + `No workspace set for profile "${this.profile.name}". Pass --workspace, or run: sim configure --profile ${this.profile.name} --set-workspace `, + 0 + ) + } + return workspaceId + } + + async request(path: string, options: RequestOptions = {}): Promise { + const apiKey = this.requireAuth() + + const url = buildUrl(this.profile.endpoint, path, options.query) + const hasBody = options.body !== undefined + + let response: Response + try { + response = await fetch(url, { + method: options.method ?? 'GET', + headers: { + 'x-api-key': apiKey, + accept: 'application/json', + ...(hasBody ? { 'content-type': 'application/json' } : {}), + }, + body: hasBody ? JSON.stringify(options.body) : undefined, + }) + } catch (cause) { + throw new SimApiError( + `Could not reach ${this.profile.endpoint}: ${(cause as Error).message}`, + 0 + ) + } + + const raw = await response.text() + + if (!response.ok) { + const error = toApiError(response.status, raw) + if (response.status === 401) { + error.message = `${error.message} — run: sim login --profile ${this.profile.name}` + } + throw error + } + + if (!raw) return undefined as T + return JSON.parse(raw) as T + } + + /** Unwraps `{ data }`. */ + async getData(path: string, options: RequestOptions = {}): Promise { + const body = await this.request>(path, options) + return body.data + } + + /** One page of `{ data, nextCursor }`. */ + async getPage(path: string, options: RequestOptions = {}): Promise> { + return this.request>(path, options) + } + + /** + * Walks a cursor list until it is exhausted or `max` items are collected. + * + * `max` is required rather than optional: an unbounded auto-pager against a + * workspace with a million logs will happily fill memory and hammer the rate + * limiter, so the caller always states a ceiling. + */ + async collect(path: string, options: RequestOptions, max: number): Promise { + const items: T[] = [] + let cursor: string | null = null + + do { + const page: V2Page = await this.getPage(path, { + ...options, + query: { ...options.query, cursor }, + }) + items.push(...page.data) + cursor = page.nextCursor + } while (cursor && items.length < max) + + return items.slice(0, max) + } +} diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts new file mode 100644 index 00000000000..cfca5271cd2 --- /dev/null +++ b/packages/sim-cli/src/index.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import chalk from 'chalk' +import { Command } from 'commander' +import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' +import { configureCommand } from './commands/configure.js' +import { filesCommand } from './commands/files.js' +import { knowledgeCommand } from './commands/knowledge.js' +import { logsCommand } from './commands/logs.js' +import { workflowsCommand } from './commands/workflows.js' +import { OUTPUT_FORMATS } from './config/index.js' +import { SimApiError } from './http/client.js' + +const program = new Command() + +program + .name('sim') + .description('Talk to the Sim API from your terminal') + .version('0.1.0') + .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') + .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') + .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') + .option('-o, --output ', `Output format: ${OUTPUT_FORMATS.join(' | ')} (env: SIM_OUTPUT)`) + +program.addCommand(loginCommand()) +program.addCommand(logoutCommand()) +program.addCommand(whoamiCommand()) +program.addCommand(profilesCommand()) +program.addCommand(configureCommand()) +program.addCommand(workflowsCommand()) +program.addCommand(logsCommand()) +program.addCommand(filesCommand()) +program.addCommand(knowledgeCommand()) + +program.addHelpText( + 'after', + ` +Profiles work like the AWS CLI: settings live in ~/.sim/config, keys in +~/.sim/credentials (0600). Select one with --profile or SIM_PROFILE. + +Examples: + $ sim login Authorize the default profile + $ sim login --profile dev --endpoint http://localhost:3000 + $ sim workflows list + $ sim logs list --level error --limit 20 + $ sim knowledge search "refund policy" --kb kb_123 + $ sim whoami --profile dev +` +) + +/** + * Anything the CLI can explain prints as one line and exits 1. An unexpected + * error keeps its stack trace — that is a bug in the CLI, and hiding it behind a + * friendly message would make it unreportable. + */ +async function main() { + try { + await program.parseAsync(process.argv) + } catch (error) { + if (error instanceof SimApiError) { + console.error(chalk.red(`Error: ${error.message}`)) + if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + process.exit(1) + } + throw error + } +} + +main() diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts new file mode 100644 index 00000000000..c092febc001 --- /dev/null +++ b/packages/sim-cli/src/output/render.test.ts @@ -0,0 +1,132 @@ +import chalk, { Chalk } from 'chalk' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + bytes, + type Column, + duration, + printList, + printRecord, + text, + visibleWidth, +} from './render.js' + +/** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ +const coloured = new Chalk({ level: 1 }) + +let logged: string[] + +beforeEach(() => { + logged = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line) + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +interface Row { + name: string + status: string +} + +const COLUMNS: Column[] = [ + { header: 'name', value: (row) => row.name }, + { header: 'status', value: (row) => row.status }, +] + +describe('visibleWidth', () => { + it('ignores ANSI colour codes', () => { + expect(visibleWidth(coloured.red('error'))).toBe(5) + expect(visibleWidth(coloured.dim(coloured.green('ok')))).toBe(2) + }) + + it('counts plain text as-is', () => { + expect(visibleWidth('error')).toBe(5) + }) + + it('sees a wrapped string as wider than nothing but no wider than its text', () => { + // The regression this guards: a pattern that misses the ESC byte leaves it + // in the string and inflates the width, drifting every coloured column. + expect(visibleWidth(coloured.red('x'))).toBe(1) + }) +}) + +describe('printList', () => { + it('starts the second column at the same visible offset on every line', () => { + printList( + 'table', + [ + { name: 'alpha', status: coloured.red('error') }, + { name: 'b', status: coloured.green('ok') }, + ], + COLUMNS + ) + + const lines = logged[0].split('\n') + expect(lines).toHaveLength(3) // header + two rows + + // Where the status column begins, measured in visible characters: strip the + // colour, then drop the first word and the padding after it. If padding had + // counted ANSI bytes, the coloured rows would disagree with the header. + const statusOffsets = lines.map((line) => { + const plain = line.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '') + return plain.length - plain.replace(/^\S+\s+/, '').length + }) + + expect(statusOffsets).toEqual([7, 7, 7]) // 'alpha' (5) + 2-space separator + }) + + it('says so instead of printing an empty table', () => { + printList('table', [], COLUMNS) + expect(logged[0]).toContain('No results.') + }) + + it('prints the raw rows for json, not the formatted cells', () => { + printList('json', [{ name: 'alpha', status: 'error' }], COLUMNS) + expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) + }) +}) + +describe('printRecord', () => { + it('prints the raw object for json, ignoring the field list', () => { + printRecord('json', [['Name', 'alpha']], { name: 'alpha', hidden: 1 }) + expect(JSON.parse(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) + }) + + it('prints one aligned line per field for table', () => { + printRecord( + 'table', + [ + ['ID', 'abc'], + ['Name', 'alpha'], + ], + {} + ) + expect(logged).toHaveLength(2) + expect(logged[0]).toContain('abc') + expect(logged[1]).toContain('alpha') + }) +}) + +describe('formatters', () => { + it('renders absent values as a dash rather than "null"', () => { + for (const value of [null, undefined, '']) { + expect(visibleWidth(text(value))).toBe(1) + expect(chalk.reset(text(value))).not.toContain('null') + } + }) + + it('scales bytes to a readable unit', () => { + expect(bytes(512)).toBe('512 B') + expect(bytes(2048)).toBe('2.0 KB') + expect(bytes(0)).toBe('0 B') + }) + + it('scales durations across the ms/s/m boundaries', () => { + expect(duration(999)).toBe('999ms') + expect(duration(1500)).toBe('1.5s') + expect(duration(90_000)).toBe('1m30s') + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts new file mode 100644 index 00000000000..821043bb85a --- /dev/null +++ b/packages/sim-cli/src/output/render.ts @@ -0,0 +1,123 @@ +import chalk from 'chalk' +import type { OutputFormat } from '../config/index.js' + +export interface Column { + header: string + value: (row: T) => string +} + +/** Cell text for values that have no useful rendering, kept visually quiet. */ +const EMPTY = chalk.dim('—') + +export function text(value: unknown): string { + if (value === null || value === undefined || value === '') return EMPTY + return String(value) +} + +/** ISO timestamps are the wire format everywhere; show them without the milliseconds. */ +export function timestamp(value: string | null | undefined): string { + if (!value) return EMPTY + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + return date.toISOString().replace('T', ' ').slice(0, 19) +} + +export function bool(value: boolean | null | undefined): string { + if (value === null || value === undefined) return EMPTY + return value ? chalk.green('yes') : chalk.dim('no') +} + +export function bytes(value: number | null | undefined): string { + if (value === null || value === undefined) return EMPTY + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let size = value + let unit = 0 + while (size >= 1024 && unit < units.length - 1) { + size /= 1024 + unit += 1 + } + return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}` +} + +export function duration(ms: number | null | undefined): string { + if (ms === null || ms === undefined) return EMPTY + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s` +} + +/** + * Matches an ANSI SGR sequence (`ESC [ … m`). + * + * Built from a char code rather than written as a literal so the source carries + * no raw ESC byte — an invisible control character inside a regex literal is the + * kind of thing an editor, a formatter, or a patch tool silently eats, and the + * only symptom would be columns drifting by one space per coloured cell. + */ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +/** + * Visible width of a cell, ignoring ANSI colour codes. + * + * Padding on the raw string would count the escape sequences as characters and + * skew every coloured column, so widths are measured on the stripped text while + * the coloured text is what gets printed. + */ +export function visibleWidth(value: string): number { + return value.replace(ANSI_PATTERN, '').length +} + +function pad(value: string, width: number): string { + return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) +} + +function renderTable(rows: T[], columns: Column[]): string { + if (rows.length === 0) return chalk.dim('No results.') + + const cells = rows.map((row) => columns.map((column) => column.value(row))) + const widths = columns.map((column, index) => + Math.max(visibleWidth(column.header), ...cells.map((line) => visibleWidth(line[index]))) + ) + + const header = columns + .map((column, index) => chalk.dim(pad(column.header.toUpperCase(), widths[index]))) + .join(' ') + .trimEnd() + + const body = cells.map((line) => + line + .map((cell, index) => pad(cell, widths[index])) + .join(' ') + .trimEnd() + ) + + return [header, ...body].join('\n') +} + +/** + * Prints a list in the profile's output format. + * + * The JSON branch prints the raw rows, not the table's formatted cells — piping + * to `jq` should yield the API's own field names and types, so `--output json` + * is a passthrough rather than a second rendering. + */ +export function printList(format: OutputFormat, rows: T[], columns: Column[]): void { + if (format === 'json') { + console.log(JSON.stringify(rows, null, 2)) + return + } + console.log(renderTable(rows, columns)) +} + +/** Prints a single record: JSON as-is, table format as aligned key/value lines. */ +export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { + if (format === 'json') { + console.log(JSON.stringify(raw, null, 2)) + return + } + + const width = Math.max(...fields.map(([label]) => label.length)) + for (const [label, value] of fields) { + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${value}`) + } +} diff --git a/packages/sim-cli/tsconfig.json b/packages/sim-cli/tsconfig.json new file mode 100644 index 00000000000..69711cab009 --- /dev/null +++ b/packages/sim-cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sim/tsconfig/library-build.json", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sim-cli/vitest.config.ts b/packages/sim-cli/vitest.config.ts new file mode 100644 index 00000000000..ceafc241202 --- /dev/null +++ b/packages/sim-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8df1ad2a511..26556d9b980 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 997, - zodRoutes: 997, + totalRoutes: 1013, + zodRoutes: 1013, nonZodRoutes: 0, } as const From b29d694adcb2c7e7b10a10bc108cb406bc245f00 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 12:48:17 -0700 Subject: [PATCH 03/28] feat(cli): generate the CLI's v2 API from the route contracts, add tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same endpoint was being described in three hand-maintained places: the Zod contracts the routes validate against, the OpenAPI documents, and the CLI's own TypeScript interfaces. Two of those are now derived. ## Generation `scripts/generate-v2-cli-api.ts` reads `apps/sim/lib/api/contracts/v2/**` and emits `packages/sim-cli/src/generated/v2-api.ts`: request/response types for all 44 operations plus an operation table (method, path, path params) the client dispatches through, so a route that moves or changes verb moves the CLI with it. The contracts are the right source because the routes validate against them — a shape that disagrees with a contract is a shape the server would reject. Zod 4's `z.toJSONSchema()` handles all 110 schema slots; the JSON-Schema-to-TS emitter is hand-rolled over that known-narrow subset and throws on anything unrecognized rather than degrading to `any`, since silence is how a generated client drifts. `packages/*` must not import `apps/*`, so the generated file is plain type declarations with no imports and the script does the crossing at build time. `check:cli-api` fails CI when the file is stale. The generated directory is excluded from biome: the pre-commit hook runs `check --write`, which would otherwise reformat generated output and fail that check with an unrelated message. ## OpenAPI: checked, not generated The docs specs carry ~1000 hand-written descriptions and ~400 examples that Zod schemas do not encode, so generating them would trade real documentation for mechanical accuracy. `check:openapi-drift` reconciles structure instead — every v2 path and method must exist on both sides — keeping the prose while still failing on divergence. Both currently agree on all 44 operations. ## Tables `sim tables list|get|columns|rows|insert|delete-rows`, built on the generated types. Rows go through the POST query endpoint even unfiltered, since it is the only shape carrying the predicate. Row columns are discovered at runtime and unioned across the page, so a sparse row cannot hide a column. Deletion requires an explicit `--row`/`--filter` selector *and* `--yes`; an argument-less call would otherwise empty the table. Path params are percent-encoded — an id containing `/` or `?` would otherwise retarget the request. The four existing command groups drop their hand-written interfaces for the generated ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .github/workflows/test-build.yml | 12 + biome.json | 1 + package.json | 3 + packages/sim-cli/README.md | 52 +- packages/sim-cli/src/commands/files.ts | 11 +- packages/sim-cli/src/commands/knowledge.ts | 39 +- packages/sim-cli/src/commands/logs.ts | 35 +- packages/sim-cli/src/commands/tables.ts | 262 ++++ packages/sim-cli/src/commands/workflows.ts | 21 +- packages/sim-cli/src/generated/v2-api.ts | 1657 ++++++++++++++++++++ packages/sim-cli/src/http/client.test.ts | 91 ++ packages/sim-cli/src/http/client.ts | 43 + packages/sim-cli/src/index.ts | 2 + scripts/generate-v2-cli-api.ts | 338 ++++ 14 files changed, 2480 insertions(+), 87 deletions(-) create mode 100644 packages/sim-cli/src/commands/tables.ts create mode 100644 packages/sim-cli/src/generated/v2-api.ts create mode 100644 packages/sim-cli/src/http/client.test.ts create mode 100644 scripts/generate-v2-cli-api.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 262386b6922..833f1fcc8c1 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -126,6 +126,18 @@ jobs: - name: Desktop bridge contract audit run: bun run check:desktop-bridge + # The CLI's view of the v2 API is generated from the same Zod contracts + # the routes validate against, so a contract change that skips + # `generate:cli-api` would ship a client describing endpoints the server + # no longer has. + - name: Sim CLI API generation up to date + run: bun run check:cli-api + + # Structure only — the OpenAPI documents keep their hand-written prose, + # but every v2 path/method must still exist on both sides. + - name: OpenAPI matches the v2 contracts + run: bun run check:openapi-drift + # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/biome.json b/biome.json index 9249402d969..31b2c99cacb 100644 --- a/biome.json +++ b/biome.json @@ -32,6 +32,7 @@ "!**/.venv", "!**/uploads", "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs", + "!**/packages/sim-cli/src/generated", "!**/test-results", "!**/playwright-report" ] diff --git a/package.json b/package.json index fec0621c543..d2c3dcb148b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,9 @@ "check:migrations": "bun run scripts/check-migrations-safety.ts", "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", + "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", + "check:openapi-drift": "bun run scripts/generate-v2-cli-api.ts --check-openapi", + "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 3eab36aa44b..25b0fa993d5 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -114,6 +114,13 @@ sim logs list [--level error] [--workflow …] [--trigger …] [--star sim logs get sim logs execution +sim tables list +sim tables get +sim tables columns +sim tables rows [--filter ] [--sort …] [--limit ] +sim tables insert --data +sim tables delete-rows (--row … | --filter ) --yes + sim files list sim files download [-o ] sim files delete @@ -124,6 +131,25 @@ sim knowledge documents [--search ] sim knowledge search --kb … ``` +### Filtering table rows + +`--filter` takes the same predicate tree the API uses — `all` (AND) or `any` +(OR) groups of `{field, op, value}` conditions, nestable. It's JSON because the +grammar is a tree; there's no honest flag encoding for it. + +```bash +sim tables rows tbl_123 \ + --filter '{"all":[{"field":"status","op":"eq","value":"open"}, + {"field":"score","op":"gt","value":10}]}' \ + --sort score:desc --limit 50 +``` + +Row columns are discovered at runtime from the returned data, unioned across the +page so a sparse row doesn't hide a column. + +Deletions require an explicit selector *and* `--yes`; there is no "delete +everything" default. + Every command takes `--output json` for scripting; the JSON is the API's own response shape, so it pipes cleanly into `jq`. @@ -131,11 +157,35 @@ response shape, so it pipes cleanly into `jq`. sim logs list --level error --output json | jq -r '.[].executionId' ``` +## How this stays in sync with the API + +`src/generated/v2-api.ts` is generated from the Zod route contracts in +`apps/sim/lib/api/contracts/v2/**` — the same contracts the routes validate +against, so a shape that disagrees with them is a shape the server would reject. +It holds every response/request type plus the operation table (method, path, +path params) the client dispatches through. + +```bash +bun run generate:cli-api # regenerate after changing a contract +bun run check:cli-api # CI: fails if the generated file is stale +bun run check:openapi-drift # CI: fails if the docs and contracts disagree +``` + +The generated file contains only type declarations and one const — no imports — +so the `packages/*` must not import `apps/*` boundary is preserved; the script +does the crossing at build time. + +The OpenAPI documents under `apps/docs` are deliberately **not** generated. They +carry ~1000 hand-written descriptions and ~400 examples that Zod schemas don't +encode, so regenerating them would trade real documentation for mechanical +accuracy. `check:openapi-drift` reconciles their *structure* against the +contracts instead — every v2 path and method must exist on both sides — so the +prose survives while drift still fails the build. + ## Notes - Commands talk to the `/api/v2` surface, which returns `{ data }` and `{ data, nextCursor }`. List commands auto-page up to `--limit`. -- `sim tables` is not here yet — the tables v2 surface is still changing. ## License diff --git a/packages/sim-cli/src/commands/files.ts b/packages/sim-cli/src/commands/files.ts index 3cf5c1df6c0..3433d8b89c2 100644 --- a/packages/sim-cli/src/commands/files.ts +++ b/packages/sim-cli/src/commands/files.ts @@ -4,18 +4,11 @@ import { basename } from 'node:path' import chalk from 'chalk' import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { ListFilesResponse } from '../generated/v2-api.js' import { SimApiError } from '../http/client.js' import { bytes, type Column, printList, timestamp } from '../output/render.js' -interface WorkspaceFile { - id: string - name: string - size: number - type: string - key: string - uploadedBy: string - uploadedAt: string -} +type WorkspaceFile = ListFilesResponse['data'][number] /** * Streams a fetch body to disk, honouring backpressure. diff --git a/packages/sim-cli/src/commands/knowledge.ts b/packages/sim-cli/src/commands/knowledge.ts index 00a8a95ec43..130a7e02394 100644 --- a/packages/sim-cli/src/commands/knowledge.ts +++ b/packages/sim-cli/src/commands/knowledge.ts @@ -1,38 +1,15 @@ import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { + ListKnowledgeBasesResponse, + ListKnowledgeDocumentsResponse, + SearchKnowledgeResponse, +} from '../generated/v2-api.js' import { bytes, type Column, printList, printRecord, text, timestamp } from '../output/render.js' -interface KnowledgeBase { - id: string - name: string - description: string | null - docCount: number - tokenCount: number - embeddingModel: string - createdAt: string | null - updatedAt: string | null -} - -interface KnowledgeDocument { - id: string - knowledgeBaseId: string - filename: string - fileSize: number - mimeType: string - processingStatus: string - chunkCount: number - tokenCount: number - enabled: boolean - createdAt: string | null -} - -interface SearchHit { - documentId: string - documentName: string | null - content: string - chunkIndex: number - similarity: number -} +type KnowledgeBase = ListKnowledgeBasesResponse['data'][number] +type KnowledgeDocument = ListKnowledgeDocumentsResponse['data'][number] +type SearchHit = SearchKnowledgeResponse['data']['results'][number] const BASE_COLUMNS: Column[] = [ { header: 'id', value: (kb) => kb.id }, diff --git a/packages/sim-cli/src/commands/logs.ts b/packages/sim-cli/src/commands/logs.ts index ac20e8e76d2..47525e925c5 100644 --- a/packages/sim-cli/src/commands/logs.ts +++ b/packages/sim-cli/src/commands/logs.ts @@ -1,25 +1,12 @@ import chalk from 'chalk' import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { GetExecutionResponse, GetLogResponse, ListLogsResponse } from '../generated/v2-api.js' import { type Column, duration, printList, printRecord, text, timestamp } from '../output/render.js' -interface LogListItem { - id: string - workflowId: string | null - executionId: string - level: string - trigger: string - startedAt: string - endedAt: string | null - totalDurationMs: number | null - cost: { total: number } | null - workflow?: { id: string | null; name: string; deleted: boolean } -} - -interface LogDetail extends LogListItem { - executionData: unknown - createdAt: string -} +type LogListItem = ListLogsResponse['data'][number] +type LogDetail = GetLogResponse['data'] +type ExecutionDetail = GetExecutionResponse['data'] function level(value: string): string { return value === 'error' ? chalk.red(value) : value @@ -128,17 +115,9 @@ export function logsCommand(): Command { .description('Show the workflow state snapshot for an execution') .action(async (executionId: string, _options: unknown, command: Command) => { const { client, profile } = clientFrom(command) - const execution = await client.getData<{ - executionId: string - workflowId: string | null - executionMetadata: { - trigger: string - startedAt: string - endedAt: string | null - totalDurationMs: number | null - cost: { total: number } | null - } - }>(`/api/v2/logs/executions/${executionId}`) + const execution = await client.getData( + `/api/v2/logs/executions/${executionId}` + ) printRecord( profile.output, diff --git a/packages/sim-cli/src/commands/tables.ts b/packages/sim-cli/src/commands/tables.ts new file mode 100644 index 00000000000..9362d7ded17 --- /dev/null +++ b/packages/sim-cli/src/commands/tables.ts @@ -0,0 +1,262 @@ +import chalk from 'chalk' +import { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { + CreateTableRowsResponse, + DeleteTableRowsResponse, + GetTableResponse, + ListTablesResponse, + QueryRowsResponse, +} from '../generated/v2-api.js' +import { SimApiError } from '../http/client.js' +import { type Column, printList, printRecord, text, timestamp } from '../output/render.js' + +type Table = ListTablesResponse['data'][number] +type TableColumn = Table['schema']['columns'][number] +type Row = QueryRowsResponse['data'][number] + +const TABLE_COLUMNS: Column[] = [ + { header: 'id', value: (t) => t.id }, + { header: 'name', value: (t) => t.name }, + { header: 'rows', value: (t) => `${t.rowCount}${t.maxRows ? ` / ${t.maxRows}` : ''}` }, + { header: 'columns', value: (t) => String(t.schema.columns.length) }, + { header: 'updated', value: (t) => timestamp(t.updatedAt) }, +] + +const COLUMN_COLUMNS: Column[] = [ + { header: 'name', value: (c) => c.name }, + { header: 'type', value: (c) => c.type }, + { header: 'required', value: (c) => (c.required ? 'yes' : '') }, + { header: 'unique', value: (c) => (c.unique ? 'yes' : '') }, + { header: 'options', value: (c) => (c.options ?? []).map((o) => o.name).join(', ') }, +] + +/** + * Parses a `--filter` / `--data` argument. + * + * The predicate grammar is a nested object (`{all|any: [{field, op, value}]}`), + * which has no honest flag encoding — so it is passed as JSON and the parse + * error names the flag rather than surfacing a bare `SyntaxError`. + */ +function parseJsonArg(value: string, flag: string): unknown { + try { + return JSON.parse(value) + } catch (error) { + throw new SimApiError(`${flag} must be valid JSON: ${(error as Error).message}`, 0) + } +} + +/** `name:desc` / `name` → the wire sort spec. */ +function parseSort(specs: string[]): Array<{ field: string; direction: 'asc' | 'desc' }> { + return specs.map((spec) => { + const [field, direction = 'asc'] = spec.split(':') + if (direction !== 'asc' && direction !== 'desc') { + throw new SimApiError(`Sort direction must be asc or desc, got "${direction}"`, 0) + } + if (!field) throw new SimApiError(`Invalid --sort value "${spec}"`, 0) + return { field, direction } + }) +} + +/** + * Row `data` is name-keyed and user-defined, so the columns are only known at + * runtime. Union the keys across the page rather than trusting the first row — + * a sparse row would otherwise hide every column it happens to omit. + */ +function rowColumns(rows: Row[]): Column[] { + const keys: string[] = [] + const seen = new Set() + for (const row of rows) { + for (const key of Object.keys(row.data)) { + if (!seen.has(key)) { + seen.add(key) + keys.push(key) + } + } + } + + return [ + { header: 'id', value: (row) => row.id }, + ...keys.map((key) => ({ + header: key, + value: (row: Row) => { + const value = row.data[key] + if (value === null || value === undefined) return text(null) + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }, + })), + ] +} + +export function tablesCommand(): Command { + const tables = new Command('tables').alias('table').description('Browse and edit tables') + + tables + .command('list') + .alias('ls') + .description('List tables in a workspace') + .action(async (_options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('listTables', { + query: { workspaceId: client.requireWorkspace() }, + })) as ListTablesResponse + printList(profile.output, result.data, TABLE_COLUMNS) + }) + + tables + .command('get ') + .description('Show a table and its schema') + .action(async (tableId: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('getTable', { + pathParams: { tableId }, + query: { workspaceId: client.requireWorkspace() }, + })) as GetTableResponse + const { table } = result.data + + printRecord( + profile.output, + [ + ['ID', table.id], + ['Name', table.name], + ['Description', text(table.description)], + ['Rows', `${table.rowCount}${table.maxRows ? ` / ${table.maxRows}` : ''}`], + ['Columns', table.schema.columns.map((c) => `${c.name}:${c.type}`).join(', ')], + ['Updated', timestamp(table.updatedAt)], + ], + table + ) + }) + + tables + .command('columns ') + .description("Show a table's columns") + .action(async (tableId: string, _options: unknown, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('getTable', { + pathParams: { tableId }, + query: { workspaceId: client.requireWorkspace() }, + })) as GetTableResponse + printList(profile.output, result.data.table.schema.columns, COLUMN_COLUMNS) + }) + + tables + .command('rows ') + .description('List rows, optionally filtered with the predicate grammar') + .option( + '--filter ', + 'Predicate tree, e.g. \'{"all":[{"field":"status","op":"eq","value":"open"}]}\'' + ) + .option('--sort ', 'Sort spec, e.g. --sort created_at:desc') + .option('--limit ', 'Maximum rows to return', '100') + .action( + async ( + tableId: string, + options: { filter?: string; sort?: string[]; limit: string }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const limit = Number.parseInt(options.limit, 10) + + const rows: Row[] = [] + let cursor: string | null = null + + // Always the POST query endpoint, even unfiltered: it is the only shape + // that carries the predicate, so one path covers both cases instead of + // two that could format rows differently. + do { + const page = (await client.call('queryRows', { + pathParams: { tableId }, + body: { + workspaceId, + ...(options.filter ? { predicate: parseJsonArg(options.filter, '--filter') } : {}), + ...(options.sort ? { sort: parseSort(options.sort) } : {}), + limit: Math.min(limit, 1000), + ...(cursor ? { cursor } : {}), + }, + })) as QueryRowsResponse + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + printList(profile.output, rows.slice(0, limit), rowColumns(rows)) + } + ) + + tables + .command('insert ') + .description('Insert a row') + .requiredOption('--data ', 'Row data, e.g. \'{"name":"Ada","score":9}\'') + .action(async (tableId: string, options: { data: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const result = (await client.call('createTableRows', { + pathParams: { tableId }, + body: { + workspaceId: client.requireWorkspace(), + data: parseJsonArg(options.data, '--data'), + }, + })) as CreateTableRowsResponse + + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + const inserted = 'row' in result.data ? 1 : result.data.rows.length + console.log(chalk.green(`✓ Inserted ${inserted} row${inserted === 1 ? '' : 's'}`)) + }) + + tables + .command('delete-rows ') + .description('Delete rows by id or filter') + .option('--row ', 'Row ids to delete') + .option('--filter ', 'Predicate tree selecting the rows to delete') + .option('-y, --yes', 'Skip the confirmation') + .action( + async ( + tableId: string, + options: { row?: string[]; filter?: string; yes?: boolean }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + + if (!options.row && !options.filter) { + // Without this, an argument-less call would delete the whole table. + throw new SimApiError( + 'Pass --row or --filter to choose what to delete.', + 0 + ) + } + + if (!options.yes) { + const target = options.row + ? `${options.row.length} row${options.row.length === 1 ? '' : 's'}` + : 'every row matching the filter' + throw new SimApiError( + `This deletes ${target} from ${tableId} and cannot be undone. Re-run with --yes to confirm.`, + 0 + ) + } + + const result = (await client.call('deleteTableRows', { + pathParams: { tableId }, + body: { + workspaceId: client.requireWorkspace(), + ...(options.row ? { rowIds: options.row } : {}), + ...(options.filter ? { filter: parseJsonArg(options.filter, '--filter') } : {}), + }, + })) as DeleteTableRowsResponse + + if (profile.output === 'json') { + console.log(JSON.stringify(result, null, 2)) + return + } + console.log(chalk.green(`✓ Deleted ${result.data.deletedCount} row(s)`)) + if (result.data.missingRowIds?.length) { + console.log(chalk.dim(` Not found: ${result.data.missingRowIds.join(', ')}`)) + } + } + ) + + return tables +} diff --git a/packages/sim-cli/src/commands/workflows.ts b/packages/sim-cli/src/commands/workflows.ts index a2acca4c076..fcedfd790d0 100644 --- a/packages/sim-cli/src/commands/workflows.ts +++ b/packages/sim-cli/src/commands/workflows.ts @@ -1,26 +1,11 @@ import chalk from 'chalk' import { Command } from 'commander' import { clientFrom } from '../context.js' +import type { GetWorkflowResponse, ListWorkflowsResponse } from '../generated/v2-api.js' import { bool, type Column, printList, printRecord, text, timestamp } from '../output/render.js' -interface WorkflowListItem { - id: string - name: string - description: string | null - folderId: string | null - workspaceId: string - isDeployed: boolean - deployedAt: string | null - runCount: number - lastRunAt: string | null - createdAt: string - updatedAt: string -} - -interface WorkflowDetail extends WorkflowListItem { - variables: Record - inputs: Array<{ name: string; type: string; description?: string }> -} +type WorkflowListItem = ListWorkflowsResponse['data'][number] +type WorkflowDetail = GetWorkflowResponse['data'] const LIST_COLUMNS: Column[] = [ { header: 'id', value: (w) => w.id }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts new file mode 100644 index 00000000000..f6f7238c14b --- /dev/null +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -0,0 +1,1657 @@ +/** + * GENERATED FILE — DO NOT EDIT. + * + * Emitted from the Zod route contracts in + * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`. + * Regenerate with `bun run generate:cli-api`; CI fails when this file is + * stale, so edit the contract rather than this file. + * + * Contains only type declarations and one const table — no imports, so the + * `packages/* must not import apps/*` boundary is preserved. + */ + +/** `POST /api/v2/tables/[tableId]/columns` */ +export type AddTableColumnParams = { + tableId: string +} + +export type AddTableColumnBody = { + workspaceId: string + column: { + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + position?: number + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + } +} + +export type AddTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } +} + +/** `POST /api/v2/knowledge` */ +export type CreateKnowledgeBaseBody = { + workspaceId: string + name: string + description?: string + chunkingConfig?: { + maxSize?: number + minSize?: number + overlap?: number + } +} + +export type CreateKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/tables` */ +export type CreateTableBody = { + name: string + description?: string + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + workspaceId: string + folderId?: string | null +} + +export type CreateTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + rowCount: number + maxRows: number + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/tables/[tableId]/rows` */ +export type CreateTableRowsParams = { + tableId: string +} + +export type CreateTableRowsBody = + | { + workspaceId: string + rows: Array + } + | { + workspaceId: string + data: unknown + afterRowId?: string + beforeRowId?: string + } + +export type CreateTableRowsResponse = + | { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } + } + | { + data: { + rows: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + insertedCount: number + } + } + +/** `DELETE /api/v2/files/[fileId]` */ +export type DeleteFileParams = { + fileId: string +} + +export type DeleteFileQuery = { + workspaceId: string +} + +export type DeleteFileResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/knowledge/[id]` */ +export type DeleteKnowledgeBaseParams = { + id: string +} + +export type DeleteKnowledgeBaseQuery = { + workspaceId: string +} + +export type DeleteKnowledgeBaseResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/knowledge/[id]/documents/[documentId]` */ +export type DeleteKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type DeleteKnowledgeDocumentQuery = { + workspaceId: string +} + +export type DeleteKnowledgeDocumentResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/tables/[tableId]` */ +export type DeleteTableParams = { + tableId: string +} + +export type DeleteTableQuery = { + workspaceId: string +} + +export type DeleteTableResponse = { + data: { + id: string + } +} + +/** `DELETE /api/v2/tables/[tableId]/columns` */ +export type DeleteTableColumnParams = { + tableId: string +} + +export type DeleteTableColumnBody = { + workspaceId: string + columnName: string +} + +export type DeleteTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } +} + +/** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ +export type DeleteTableRowParams = { + tableId: string + rowId: string +} + +export type DeleteTableRowQuery = { + workspaceId: string +} + +export type DeleteTableRowResponse = { + data: { + deletedCount: number + deletedRowIds: Array + } +} + +/** `DELETE /api/v2/tables/[tableId]/rows` */ +export type DeleteTableRowsParams = { + tableId: string +} + +export type DeleteTableRowsBody = { + workspaceId: string + filter?: unknown + limit?: number + rowIds?: Array +} + +export type DeleteTableRowsResponse = { + data: { + deletedCount: number + deletedRowIds: Array + requestedCount?: number + missingRowIds?: Array + } +} + +/** `POST /api/v2/workflows/[id]/deploy` */ +export type DeployWorkflowParams = { + id: string +} + +export type DeployWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + version?: number + } +} + +/** `GET /api/v2/files/[fileId]` */ +export type DownloadFileParams = { + fileId: string +} + +export type DownloadFileQuery = { + workspaceId: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadFileResponse = never + +/** `GET /api/v2/workflows/[id]/export` */ +export type ExportWorkflowParams = { + id: string +} + +export type ExportWorkflowResponse = { + data: { + version: '1.0' + exportedAt: string + workflow: { + id: string + name: string + description: string | null + workspaceId: string | null + folderId: string | null + } + state: { + blocks: Record< + string, + { + id: string + type: string + name: string + position: { + x: number + y: number + } + subBlocks: Record< + string, + { + id: string + type: string + value: unknown + } + > + outputs: Record + enabled: boolean + horizontalHandles?: boolean + height?: number + advancedMode?: boolean + triggerMode?: boolean + data?: { + parentId?: string + extent?: 'parent' + width?: number + height?: number + collection?: unknown + count?: number + loopType?: 'for' | 'forEach' | 'while' | 'doWhile' + whileCondition?: string + doWhileCondition?: string + parallelType?: 'collection' | 'count' + batchSize?: number + type?: string + canonicalModes?: Record + } + locked?: boolean + } + > + edges: Array<{ + id: string + source: string + target: string + sourceHandle: unknown + targetHandle: unknown + type?: string + animated?: boolean + style?: Record + data?: Record + label?: string + labelStyle?: Record + labelShowBg?: boolean + labelBgStyle?: Record + labelBgPadding?: unknown[] + labelBgBorderRadius?: number + markerStart?: string + markerEnd?: string + }> + loops?: Record< + string, + { + id: string + nodes: Array + iterations: number + loopType: 'for' | 'forEach' | 'while' | 'doWhile' + forEachItems?: Array | Record | string + whileCondition?: string + doWhileCondition?: string + enabled?: boolean + locked?: boolean + } + > + parallels?: Record< + string, + { + id: string + nodes: Array + distribution?: Array | Record | string + count?: number + parallelType?: 'count' | 'collection' + batchSize?: number + enabled?: boolean + locked?: boolean + } + > + variables?: Record< + string, + { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown + } + > + metadata?: { + name?: string + description?: string + sortOrder?: number + exportedAt?: string + } + } + } +} + +/** `GET /api/v2/audit-logs/[id]` */ +export type GetAuditLogParams = { + id: string +} + +export type GetAuditLogResponse = { + data: { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string + } +} + +/** `GET /api/v2/logs/executions/[executionId]` */ +export type GetExecutionParams = { + executionId: string +} + +export type GetExecutionResponse = { + data: { + executionId: string + workflowId: string | null + workflowState: unknown + executionMetadata: { + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { + total: number + } | null + } + } +} + +/** `GET /api/v2/knowledge/[id]` */ +export type GetKnowledgeBaseParams = { + id: string +} + +export type GetKnowledgeBaseQuery = { + workspaceId: string +} + +export type GetKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/knowledge/[id]/documents/[documentId]` */ +export type GetKnowledgeDocumentParams = { + id: string + documentId: string +} + +export type GetKnowledgeDocumentQuery = { + workspaceId: string +} + +export type GetKnowledgeDocumentResponse = { + data: { + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + processingError: string | null + processingStartedAt: string | null + processingCompletedAt: string | null + connectorId: string | null + connectorType: string | null + sourceUrl: string | null + } + } +} + +/** `GET /api/v2/logs/[id]` */ +export type GetLogParams = { + id: string +} + +export type GetLogResponse = { + data: { + id: string + workflowId: string | null + executionId: string + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + files: Array | null + workflow: { + id: string | null + name: string + description: string | null + folderId: string | null + userId: string | null + workspaceId: string | null + createdAt: string | null + updatedAt: string | null + deleted: boolean + } + executionData: unknown + cost: { + total: number + } | null + createdAt: string + } +} + +/** `GET /api/v2/tables/[tableId]` */ +export type GetTableParams = { + tableId: string +} + +export type GetTableQuery = { + workspaceId: string +} + +export type GetTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + rowCount: number + maxRows: number + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ +export type GetTableRowParams = { + tableId: string + rowId: string +} + +export type GetTableRowQuery = { + workspaceId: string +} + +export type GetTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/billing/usage` */ +export type GetUsageSummaryQuery = { + workspaceId?: string +} + +export type GetUsageSummaryResponse = { + data: { + period: { + start: string + end: string + } + totalCredits: number + bySourceCredits: Record + limitCredits: number + plan: string + } +} + +/** `GET /api/v2/workflows/[id]` */ +export type GetWorkflowParams = { + id: string +} + +export type GetWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + variables: Record + inputs: Array<{ + name: string + type: string + description?: string + }> + } +} + +/** `POST /api/v2/workflows/import` */ +export type ImportWorkflowBody = { + workspaceId: string + folderId?: string + name?: string + description?: string + workflow: string | Record +} + +export type ImportWorkflowResponse = { + data: { + id: string + name: string + description: string | null + workspaceId: string + folderId: string | null + createdAt: string + updatedAt: string + } +} + +/** `GET /api/v2/audit-logs` */ +export type ListAuditLogsQuery = { + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + actorId?: string + startDate?: string + endDate?: string + includeDeparted?: 'true' | 'false' + limit?: number + cursor?: string +} + +export type ListAuditLogsResponse = { + data: Array<{ + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/files` */ +export type ListFilesQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +export type ListFilesResponse = { + data: Array<{ + id: string + name: string + size: number + type: string + key: string + uploadedBy: string + uploadedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +export type ListKnowledgeBasesQuery = { + workspaceId: string +} + +export type ListKnowledgeBasesResponse = { + data: Array<{ + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge/[id]/documents` */ +export type ListKnowledgeDocumentsParams = { + id: string +} + +export type ListKnowledgeDocumentsQuery = { + workspaceId: string + limit?: number + search?: string + enabledFilter?: 'all' | 'enabled' | 'disabled' + sortBy?: + | 'filename' + | 'fileSize' + | 'tokenCount' + | 'chunkCount' + | 'uploadedAt' + | 'processingStatus' + | 'enabled' + sortOrder?: 'asc' | 'desc' + cursor?: string +} + +export type ListKnowledgeDocumentsResponse = { + data: Array<{ + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + }> + nextCursor: string | null +} + +/** `GET /api/v2/logs` */ +export type ListLogsQuery = { + workspaceId: string + workflowIds?: string + folderIds?: string + triggers?: string + level?: 'info' | 'error' + startDate?: string + endDate?: string + executionId?: string + minDurationMs?: number + maxDurationMs?: number + minCost?: number + maxCost?: number + model?: string + details?: 'basic' | 'full' + includeTraceSpans?: boolean + includeFinalOutput?: boolean + limit?: number + cursor?: string + order?: 'desc' | 'asc' +} + +export type ListLogsResponse = { + data: Array<{ + id: string + workflowId: string | null + executionId: string + deploymentVersionId: string | null + level: string + trigger: string + startedAt: string + endedAt: string | null + totalDurationMs: number | null + cost: { + total: number + } | null + files: Array | null + workflow?: { + id: string | null + name: string + description: string | null + deleted: boolean + } + finalOutput?: unknown + traceSpans?: unknown + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/rows` */ +export type ListTableRowsParams = { + tableId: string +} + +export type ListTableRowsQuery = { + workspaceId: string + limit?: number + cursor?: string +} + +export type ListTableRowsResponse = { + data: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables` */ +export type ListTablesQuery = { + workspaceId: string +} + +export type ListTablesResponse = { + data: Array<{ + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } + rowCount: number + maxRows: number + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/billing/usage/logs` */ +export type ListUsageLogsQuery = { + source?: + | 'workflow' + | 'wand' + | 'copilot' + | 'workspace-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + workspaceId?: string + period?: '1d' | '7d' | '30d' | 'all' | 'custom' + startDate?: string + endDate?: string + limit?: number + cursor?: string +} + +export type ListUsageLogsResponse = { + data: Array<{ + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'copilot' + | 'workspace-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + workflowName: string | null + creditCost: number + }> + nextCursor: string | null +} + +/** `GET /api/v2/workflows` */ +export type ListWorkflowsQuery = { + workspaceId: string + folderId?: string + deployedOnly?: boolean + limit?: number + cursor?: string +} + +export type ListWorkflowsResponse = { + data: Array<{ + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `POST /api/v2/tables/[tableId]/query` */ +export type QueryRowsParams = { + tableId: string +} + +export type QueryRowsBody = { + workspaceId: string + predicate?: unknown + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> + limit?: number + cursor?: string +} + +export type QueryRowsResponse = { + data: Array<{ + id: string + data: Record + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `POST /api/v2/workflows/[id]/rollback` */ +export type RollbackWorkflowParams = { + id: string +} + +export type RollbackWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + version: number + } +} + +/** `POST /api/v2/knowledge/search` */ +export type SearchKnowledgeBody = { + workspaceId: string + knowledgeBaseIds: string | Array + query?: string + topK?: number + tagFilters?: Array<{ + tagName: string + fieldType?: 'text' | 'number' | 'date' | 'boolean' + operator?: string + value: string | number | boolean + valueTo?: string | number + }> +} + +export type SearchKnowledgeResponse = { + data: { + results: Array<{ + documentId: string + documentName: string | null + sourceUrl: string | null + content: string + chunkIndex: number + metadata: Record + similarity: number + }> + query: string + knowledgeBaseIds: Array + topK: number + totalResults: number + } +} + +/** `DELETE /api/v2/workflows/[id]/deploy` */ +export type UndeployWorkflowParams = { + id: string +} + +export type UndeployWorkflowResponse = { + data: { + id: string + isDeployed: boolean + deployedAt: string | null + warnings: Array + activeDeployment: { + deploymentVersionId: string + version: number + deployedAt: string + } | null + latestDeploymentAttempt: { + id: string + deploymentVersionId: string + version: number + action: 'deploy' | 'activate' + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + readiness: { + webhooks: 'pending' | 'ready' | 'not_applicable' + schedules: 'pending' | 'ready' | 'not_applicable' + mcp: 'pending' | 'ready' | 'not_applicable' + } + requestedAt: string + activatedAt?: string | null + error?: { + code: string + message: string + retryable: boolean + } | null + } | null + } +} + +/** `PUT /api/v2/knowledge/[id]` */ +export type UpdateKnowledgeBaseParams = { + id: string +} + +export type UpdateKnowledgeBaseBody = { + workspaceId: string + name?: string + description?: string + chunkingConfig?: { + maxSize: number + minSize: number + overlap: number + } +} + +export type UpdateKnowledgeBaseResponse = { + data: { + knowledgeBase: { + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number + minSize: number + overlap: number + strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' + strategyOptions?: { + pattern?: string + separators?: Array + recipe?: 'plain' | 'markdown' | 'code' + strictBoundaries?: boolean + } + } + docCount?: number + connectorTypes?: Array + createdAt: string + updatedAt: string + } + } +} + +/** `PUT /api/v2/tables/[tableId]/rows` */ +export type UpdateRowsByFilterParams = { + tableId: string +} + +export type UpdateRowsByFilterBody = { + workspaceId: string + filter: unknown + data: unknown + limit?: number +} + +export type UpdateRowsByFilterResponse = { + data: { + updatedCount: number + updatedRowIds: Array + } +} + +/** `PATCH /api/v2/tables/[tableId]/columns` */ +export type UpdateTableColumnParams = { + tableId: string +} + +export type UpdateTableColumnBody = { + workspaceId: string + columnName: string + updates: { + name?: string + type?: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + } +} + +export type UpdateTableColumnResponse = { + data: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + }> + } +} + +/** `PATCH /api/v2/tables/[tableId]/rows/[rowId]` */ +export type UpdateTableRowParams = { + tableId: string + rowId: string +} + +export type UpdateTableRowBody = { + workspaceId: string + data: unknown +} + +export type UpdateTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/files` */ +export type UploadFileQuery = { + workspaceId: string +} + +export type UploadFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + uploadedBy: string + uploadedAt: string + } +} + +/** `POST /api/v2/knowledge/[id]/documents` */ +export type UploadKnowledgeDocumentParams = { + id: string +} + +export type UploadKnowledgeDocumentQuery = { + workspaceId: string +} + +export type UploadKnowledgeDocumentResponse = { + data: { + document: { + id: string + knowledgeBaseId: string + filename: string + fileSize: number + mimeType: string + processingStatus: 'pending' | 'processing' | 'completed' | 'failed' + chunkCount: number + tokenCount: number + characterCount: number + enabled: boolean + createdAt: string | null + } + } +} + +/** `POST /api/v2/tables/[tableId]/rows/upsert` */ +export type UpsertTableRowParams = { + tableId: string +} + +export type UpsertTableRowBody = { + workspaceId: string + data: unknown + conflictTarget?: string +} + +export type UpsertTableRowResponse = { + data: { + row: { + id: string + data: Record + createdAt: string + updatedAt: string + } + operation: 'insert' | 'update' + } +} + +/** Every v2 operation, keyed by name. */ +export const V2_OPERATIONS = { + addTableColumn: { + method: 'POST', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + createKnowledgeBase: { + method: 'POST', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + }, + createTable: { + method: 'POST', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + }, + createTableRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deleteFile: { + method: 'DELETE', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'json', + }, + deleteKnowledgeBase: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + deleteKnowledgeDocument: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + }, + deleteTable: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deleteTableColumn: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deleteTableRow: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + }, + deleteTableRows: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + deployWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/deploy', + pathParams: ['id'] as const, + responseMode: 'json', + }, + downloadFile: { + method: 'GET', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'binary', + }, + exportWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]/export', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getAuditLog: { + method: 'GET', + path: '/api/v2/audit-logs/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getExecution: { + method: 'GET', + path: '/api/v2/logs/executions/[executionId]', + pathParams: ['executionId'] as const, + responseMode: 'json', + }, + getKnowledgeBase: { + method: 'GET', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getKnowledgeDocument: { + method: 'GET', + path: '/api/v2/knowledge/[id]/documents/[documentId]', + pathParams: ['id', 'documentId'] as const, + responseMode: 'json', + }, + getLog: { + method: 'GET', + path: '/api/v2/logs/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + getTable: { + method: 'GET', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + getTableRow: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + }, + getUsageSummary: { + method: 'GET', + path: '/api/v2/billing/usage', + pathParams: [] as const, + responseMode: 'json', + }, + getWorkflow: { + method: 'GET', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + importWorkflow: { + method: 'POST', + path: '/api/v2/workflows/import', + pathParams: [] as const, + responseMode: 'json', + }, + listAuditLogs: { + method: 'GET', + path: '/api/v2/audit-logs', + pathParams: [] as const, + responseMode: 'json', + }, + listFiles: { + method: 'GET', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + }, + listKnowledgeBases: { + method: 'GET', + path: '/api/v2/knowledge', + pathParams: [] as const, + responseMode: 'json', + }, + listKnowledgeDocuments: { + method: 'GET', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + }, + listLogs: { + method: 'GET', + path: '/api/v2/logs', + pathParams: [] as const, + responseMode: 'json', + }, + listTableRows: { + method: 'GET', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + listTables: { + method: 'GET', + path: '/api/v2/tables', + pathParams: [] as const, + responseMode: 'json', + }, + listUsageLogs: { + method: 'GET', + path: '/api/v2/billing/usage/logs', + pathParams: [] as const, + responseMode: 'json', + }, + listWorkflows: { + method: 'GET', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + }, + queryRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/query', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + rollbackWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/rollback', + pathParams: ['id'] as const, + responseMode: 'json', + }, + searchKnowledge: { + method: 'POST', + path: '/api/v2/knowledge/search', + pathParams: [] as const, + responseMode: 'json', + }, + undeployWorkflow: { + method: 'DELETE', + path: '/api/v2/workflows/[id]/deploy', + pathParams: ['id'] as const, + responseMode: 'json', + }, + updateKnowledgeBase: { + method: 'PUT', + path: '/api/v2/knowledge/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + }, + updateRowsByFilter: { + method: 'PUT', + path: '/api/v2/tables/[tableId]/rows', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + updateTableColumn: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/columns', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, + updateTableRow: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/rows/[rowId]', + pathParams: ['tableId', 'rowId'] as const, + responseMode: 'json', + }, + uploadFile: { + method: 'POST', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + }, + uploadKnowledgeDocument: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents', + pathParams: ['id'] as const, + responseMode: 'json', + }, + upsertTableRow: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/upsert', + pathParams: ['tableId'] as const, + responseMode: 'json', + }, +} as const + +export type V2OperationName = keyof typeof V2_OPERATIONS diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts new file mode 100644 index 00000000000..8542593159b --- /dev/null +++ b/packages/sim-cli/src/http/client.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { resolvePath, SimApiError } from './client.js' + +describe('resolvePath', () => { + it('substitutes a path parameter', () => { + expect(resolvePath('/api/v2/tables/[tableId]/rows', { tableId: 'tbl_1' })).toBe( + '/api/v2/tables/tbl_1/rows' + ) + }) + + it('substitutes several parameters', () => { + expect( + resolvePath('/api/v2/knowledge/[id]/documents/[documentId]', { id: 'kb', documentId: 'doc' }) + ).toBe('/api/v2/knowledge/kb/documents/doc') + }) + + it('percent-encodes values so an id cannot retarget the request', () => { + // An unencoded `/` or `?` here would silently address a different endpoint. + expect(resolvePath('/api/v2/tables/[tableId]', { tableId: 'a/b?c=d' })).toBe( + '/api/v2/tables/a%2Fb%3Fc%3Dd' + ) + }) + + it('throws rather than sending a URL with a literal [param] in it', () => { + expect(() => resolvePath('/api/v2/tables/[tableId]', {})).toThrow(SimApiError) + expect(() => resolvePath('/api/v2/tables/[tableId]', {})).toThrow('tableId') + }) + + it('leaves a parameterless path alone', () => { + expect(resolvePath('/api/v2/tables')).toBe('/api/v2/tables') + }) +}) + +describe('generated operation table', () => { + const names = Object.keys(V2_OPERATIONS) as V2OperationName[] + + it('covers the operations the commands rely on', () => { + // Named explicitly: if a contract is renamed, the generator happily emits + // the new name and only this test catches that a command lost its endpoint. + for (const required of [ + 'listTables', + 'getTable', + 'queryRows', + 'createTableRows', + 'deleteTableRows', + 'listWorkflows', + 'getWorkflow', + 'deployWorkflow', + 'undeployWorkflow', + 'rollbackWorkflow', + 'listLogs', + 'getLog', + 'getExecution', + 'listFiles', + 'deleteFile', + 'listKnowledgeBases', + 'getKnowledgeBase', + 'listKnowledgeDocuments', + 'searchKnowledge', + ] satisfies V2OperationName[]) { + expect(names).toContain(required) + } + }) + + it('declares every path parameter its path contains', () => { + for (const name of names) { + const spec = V2_OPERATIONS[name] + const inPath = [...spec.path.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) + expect(spec.pathParams, `${name} path params`).toEqual(inPath) + } + }) + + it('only targets the public v2 surface with real HTTP verbs', () => { + for (const name of names) { + const spec = V2_OPERATIONS[name] + expect(spec.path, name).toMatch(/^\/api\/v2\//) + expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], name).toContain(spec.method) + } + }) + + it('has no two operations sharing a method and path', () => { + const seen = new Map() + for (const name of names) { + const spec = V2_OPERATIONS[name] + const key = `${spec.method} ${spec.path}` + expect(seen.get(key), `${key} claimed by both ${seen.get(key)} and ${name}`).toBeUndefined() + seen.set(key, name) + } + }) +}) diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 72afaca74e6..22110a846db 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,4 +1,5 @@ import type { ResolvedProfile } from '../config/index.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' /** * A failure the CLI can explain. Anything thrown as a `SimApiError` is printed @@ -191,4 +192,46 @@ export class SimClient { return items.slice(0, max) } + + /** + * Calls a generated operation by name. + * + * Method and path come from `V2_OPERATIONS`, so a route that moves or changes + * verb in a contract moves here on the next `generate:cli-api` rather than + * failing at runtime against a URL the CLI still remembers. + */ + async call( + operation: K, + options: OperationOptions = {} + ): Promise { + const spec = V2_OPERATIONS[operation] + return this.request(resolvePath(spec.path, options.pathParams), { + method: spec.method as RequestOptions['method'], + query: options.query, + body: options.body, + }) + } +} + +export interface OperationOptions { + pathParams?: Record + query?: Record + body?: unknown +} + +/** + * Substitutes `[id]`-style path segments. + * + * Values are percent-encoded: table and workspace ids are opaque, and a `/` or + * `?` inside one would otherwise silently retarget the request at a different + * endpoint. + */ +export function resolvePath(template: string, params: Record = {}): string { + return template.replace(/\[([^\]]+)\]/g, (_match, key: string) => { + const value = params[key] + if (value === undefined) { + throw new SimApiError(`Missing path parameter "${key}" for ${template}`, 0) + } + return encodeURIComponent(value) + }) } diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index cfca5271cd2..6b4d8e20149 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -7,6 +7,7 @@ import { configureCommand } from './commands/configure.js' import { filesCommand } from './commands/files.js' import { knowledgeCommand } from './commands/knowledge.js' import { logsCommand } from './commands/logs.js' +import { tablesCommand } from './commands/tables.js' import { workflowsCommand } from './commands/workflows.js' import { OUTPUT_FORMATS } from './config/index.js' import { SimApiError } from './http/client.js' @@ -29,6 +30,7 @@ program.addCommand(profilesCommand()) program.addCommand(configureCommand()) program.addCommand(workflowsCommand()) program.addCommand(logsCommand()) +program.addCommand(tablesCommand()) program.addCommand(filesCommand()) program.addCommand(knowledgeCommand()) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts new file mode 100644 index 00000000000..991a39b52ad --- /dev/null +++ b/scripts/generate-v2-cli-api.ts @@ -0,0 +1,338 @@ +#!/usr/bin/env bun +/** + * Generates the Sim CLI's view of the public v2 API from the Zod route + * contracts, so the terminal and the server cannot describe the same endpoint + * differently. + * + * The contracts under `apps/sim/lib/api/contracts/v2/**` are the single source + * of truth: the routes validate against them, so a shape that disagrees with a + * contract is a shape the server would reject. Everything downstream is derived + * rather than restated. + * + * The CLI cannot import the contracts directly — `packages/*` must never depend + * on `apps/*` (scripts/check-monorepo-boundaries.ts). This script bridges that + * at build time instead: it reads the contracts here and emits a file of plain + * type declarations with no imports at all, so nothing about the package + * boundary changes. + * + * Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They + * carry ~1000 hand-written descriptions and ~400 examples that Zod schemas do + * not encode, and regenerating them would trade real documentation for + * mechanical accuracy. `--check-openapi` reconciles their *structure* against + * the contracts instead, so the prose survives while drift still fails CI. + * + * Usage: + * bun run scripts/generate-v2-cli-api.ts # write the generated file + * bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale + * bun run scripts/generate-v2-cli-api.ts --check-openapi + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { z } from 'zod' + +const ROOT = path.resolve(import.meta.dir, '..') +const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') +const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') +const DOCS_DIR = path.join(ROOT, 'apps/docs') + +/** Contract modules to read, in emit order. */ +const DOMAINS = [ + 'workflows', + 'logs', + 'tables', + 'files', + 'knowledge', + 'audit-logs', + 'billing', +] as const + +interface RouteContract { + method: string + path: string + params?: z.ZodType + query?: z.ZodType + body?: z.ZodType + headers?: z.ZodType + response: { mode: string; schema?: z.ZodType } +} + +interface Operation { + /** `listTables` — derived from the export name. */ + name: string + domain: string + contract: RouteContract +} + +function isRouteContract(value: unknown): value is RouteContract { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.method === 'string' && + typeof candidate.path === 'string' && + typeof candidate.response === 'object' + ) +} + +/** `v2ListTablesContract` → `listTables`. */ +function operationName(exportName: string): string { + const stripped = exportName.replace(/^v2/, '').replace(/Contract$/, '') + return stripped.charAt(0).toLowerCase() + stripped.slice(1) +} + +function pascal(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1) +} + +async function collectOperations(): Promise { + const operations: Operation[] = [] + + for (const domain of DOMAINS) { + const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) + for (const [exportName, value] of Object.entries(mod)) { + if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue + operations.push({ name: operationName(exportName), domain, contract: value }) + } + } + + // Import order is stable, but sort anyway so a reordered export list does not + // show up as a spurious diff in the generated file. + return operations.sort((a, b) => a.name.localeCompare(b.name)) +} + +type JsonSchema = Record + +/** + * Emits a TypeScript type for the subset of JSON Schema that `z.toJSONSchema` + * produces from these contracts. + * + * Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is + * a known, narrow subset (no `$ref`, no `patternProperties`, no draft-04 + * quirks), and the output is committed and read by humans, so controlling the + * formatting is worth more here than covering spec corners that never appear. + * An unhandled construct throws rather than degrading to `any` — silence is how + * a generated client drifts from its server. + */ +function toTypeScript(schema: JsonSchema, indent = 0): string { + const pad = ' '.repeat(indent + 1) + const closePad = ' '.repeat(indent) + + if (schema.const !== undefined) return JSON.stringify(schema.const) + if (schema.enum) return schema.enum.map((v: unknown) => JSON.stringify(v)).join(' | ') + + const variants = schema.anyOf ?? schema.oneOf + if (variants) { + return variants.map((v: JsonSchema) => toTypeScript(v, indent)).join(' | ') + } + + if (schema.allOf) { + return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent)).join(' & ') + } + + switch (schema.type) { + case 'string': + return 'string' + case 'number': + case 'integer': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + case 'array': + return schema.items ? `Array<${toTypeScript(schema.items, indent)}>` : 'unknown[]' + case 'object': { + const properties: Record = schema.properties ?? {} + const required: string[] = schema.required ?? [] + const keys = Object.keys(properties) + + if (keys.length === 0) { + // A bare object with only `additionalProperties` is a record. + const value = + schema.additionalProperties && typeof schema.additionalProperties === 'object' + ? toTypeScript(schema.additionalProperties, indent) + : 'unknown' + return `Record` + } + + const lines = keys.map((key) => { + const optional = required.includes(key) ? '' : '?' + const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key) + return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1)}` + }) + return `{\n${lines.join('\n')}\n${closePad}}` + } + } + + // `z.unknown()` / `z.any()` render as an empty schema. + if (Object.keys(schema).filter((k) => k !== '$schema').length === 0) return 'unknown' + + throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`) +} + +function schemaToType(schema: z.ZodType, io: 'input' | 'output'): string { + const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema + return toTypeScript(json) +} + +/** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */ +function pathParams(routePath: string): string[] { + return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) +} + +function render(operations: Operation[]): string { + const out: string[] = [] + + out.push('/**') + out.push(' * GENERATED FILE — DO NOT EDIT.') + out.push(' *') + out.push(' * Emitted from the Zod route contracts in') + out.push(' * `apps/sim/lib/api/contracts/v2/**` by `scripts/generate-v2-cli-api.ts`.') + out.push(' * Regenerate with `bun run generate:cli-api`; CI fails when this file is') + out.push(' * stale, so edit the contract rather than this file.') + out.push(' *') + out.push(' * Contains only type declarations and one const table — no imports, so the') + out.push(' * `packages/* must not import apps/*` boundary is preserved.') + out.push(' */') + out.push('') + + for (const op of operations) { + const Name = pascal(op.name) + const { contract } = op + + out.push(`/** \`${contract.method} ${contract.path}\` */`) + + for (const slot of ['params', 'query', 'body', 'headers'] as const) { + const schema = contract[slot] + if (!schema) continue + out.push(`export type ${Name}${pascal(slot)} = ${schemaToType(schema, 'input')}`) + out.push('') + } + + if (contract.response.mode === 'json' && contract.response.schema) { + out.push(`export type ${Name}Response = ${schemaToType(contract.response.schema, 'output')}`) + } else { + out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`) + out.push(`export type ${Name}Response = never`) + } + out.push('') + } + + out.push('/** Every v2 operation, keyed by name. */') + out.push('export const V2_OPERATIONS = {') + for (const op of operations) { + const params = pathParams(op.contract.path) + out.push(` ${op.name}: {`) + out.push(` method: '${op.contract.method}',`) + out.push(` path: '${op.contract.path}',`) + out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`) + out.push(` responseMode: '${op.contract.response.mode}',`) + out.push(' },') + } + out.push('} as const') + out.push('') + out.push('export type V2OperationName = keyof typeof V2_OPERATIONS') + out.push('') + + return out.join('\n') +} + +/** + * Reconciles the hand-written OpenAPI documents against the contracts. + * + * Structure only — every contract path/method must be documented, and every + * documented v2 path/method must exist as a contract. Descriptions and examples + * are the docs' own, and are deliberately not compared. + */ +function checkOpenApi(operations: Operation[]): string[] { + const problems: string[] = [] + + const documented = new Set() + for (const file of [ + 'openapi-core.json', + 'openapi-v2-workflows.json', + 'openapi-v2-logs.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', + ]) { + let spec: JsonSchema + try { + spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) + } catch { + problems.push(`missing or unparseable spec: ${file}`) + continue + } + for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { + for (const method of Object.keys(methods as object)) { + if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue + documented.add(`${method.toUpperCase()} ${specPath}`) + } + } + } + + for (const op of operations) { + // Contracts use Next.js `[id]`; OpenAPI uses `{id}`. + const openApiPath = op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}') + const key = `${op.contract.method} ${openApiPath}` + if (!documented.has(key)) { + problems.push(`contract not documented in OpenAPI: ${key} (${op.name})`) + } + documented.delete(key) + } + + for (const stale of documented) { + if (stale.includes('/api/v2/')) { + problems.push(`documented in OpenAPI but no contract: ${stale}`) + } + } + + return problems +} + +async function main() { + const args = new Set(process.argv.slice(2)) + const operations = await collectOperations() + + if (args.has('--check-openapi')) { + const problems = checkOpenApi(operations) + if (problems.length > 0) { + console.error('OpenAPI drift against the v2 contracts:\n') + for (const problem of problems) console.error(` - ${problem}`) + console.error( + '\nUpdate apps/docs/openapi-v2-*.json to match the contracts (the contracts are authoritative).' + ) + process.exit(1) + } + console.log(`OpenAPI matches all ${operations.length} v2 contracts.`) + return + } + + const generated = render(operations) + + if (args.has('--check')) { + let current = '' + try { + current = readFileSync(OUTPUT, 'utf8') + } catch { + console.error(`${path.relative(ROOT, OUTPUT)} is missing. Run: bun run generate:cli-api`) + process.exit(1) + } + if (current !== generated) { + console.error( + `${path.relative(ROOT, OUTPUT)} is stale. Run: bun run generate:cli-api\n\n` + + 'The v2 contracts changed without the CLI being regenerated.' + ) + process.exit(1) + } + console.log(`${path.relative(ROOT, OUTPUT)} is up to date (${operations.length} operations).`) + return + } + + writeFileSync(OUTPUT, generated) + console.log( + `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${DOMAINS.length} contract modules.` + ) +} + +main() From e8534dcce218722a735362af94bd642feeba86e5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 12:49:53 -0700 Subject: [PATCH 04/28] fix(cli): make the generated v2 API a fixed point of the formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-commit hook rewrote the generated file immediately after it was committed, so `check:cli-api` then failed in CI reporting contract drift that had not happened — the only difference was quote style. The biome.json exclusion added alongside it does not help: lint-staged runs `biome check --write` on explicit paths, which bypasses `files.includes`. It implied protection it never provided, so it is removed. The generator now pipes its output through `biome format --stdin-file-path` instead, making the emitted file conformant by construction. The hook has nothing left to change, and the check compares like with like. A formatter failure throws rather than emitting unformatted output, since falling back silently would reopen the same loop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- biome.json | 1 - scripts/generate-v2-cli-api.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index 31b2c99cacb..9249402d969 100644 --- a/biome.json +++ b/biome.json @@ -32,7 +32,6 @@ "!**/.venv", "!**/uploads", "!**/apps/sim/lib/execution/sandbox/bundles/*.cjs", - "!**/packages/sim-cli/src/generated", "!**/test-results", "!**/playwright-report" ] diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 991a39b52ad..17aa0db715f 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -27,6 +27,7 @@ * bun run scripts/generate-v2-cli-api.ts --check-openapi */ +import { spawnSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { z } from 'zod' @@ -290,6 +291,35 @@ function checkOpenApi(operations: Operation[]): string[] { return problems } +/** + * Runs the emitted source through Biome so the generated file is a fixed point + * of the repo's formatter. + * + * Without this the file is rewritten on the way into a commit: lint-staged runs + * `biome check --write` on explicit paths, which bypasses the `files.includes` + * exclusion in biome.json. The result was a generated file that no longer + * matched its generator, so `--check` failed in CI complaining about contract + * drift that had not happened. Formatting here means the hook has nothing left + * to change. + */ +function format(source: string): string { + const result = spawnSync( + path.join(ROOT, 'node_modules/.bin/biome'), + ['format', `--stdin-file-path=${OUTPUT}`], + { input: source, encoding: 'utf8' } + ) + + if (result.status !== 0 || !result.stdout) { + // Fail loudly: silently emitting unformatted output would reintroduce the + // exact hook-rewrites-generated-file loop this exists to close. + throw new Error( + `biome failed to format the generated output (status ${result.status}): ${result.stderr ?? ''}` + ) + } + + return result.stdout +} + async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() @@ -308,7 +338,7 @@ async function main() { return } - const generated = render(operations) + const generated = format(render(operations)) if (args.has('--check')) { let current = '' From 6af4fdb8aae42f5900a5404c92878fccf9492559 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 15:56:15 -0700 Subject: [PATCH 05/28] fix(cli-auth): wait for the workspace list before allowing approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker fell back to "No workspace (personal key)" while the workspace query was in flight, and Connect stayed live through that window. A fast click approved a personal key with no default workspace — when the same click a moment later would have issued a workspace-scoped key. The fallback read as an answer rather than a pending state, so the card could promise one outcome and deliver another. Connect is now disabled until the list resolves, the trigger shows a loading label (a placeholder would not show, since the fallback always counts as a selection), and the explanatory line no longer asserts the personal-key outcome before it is known. Failure is treated as degraded rather than fatal: the picker disables but Connect stays enabled and the copy says a personal key will be issued, so a transient list failure cannot strand a waiting terminal. Tests cover the pending, loaded, admin-binding, and error states; the two loading assertions fail against the previous implementation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/cli/auth/cli-auth-view.test.tsx | 138 +++++++++++++++++++ apps/sim/app/cli/auth/cli-auth-view.tsx | 29 +++- 2 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/cli/auth/cli-auth-view.test.tsx diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx new file mode 100644 index 00000000000..01d908daf05 --- /dev/null +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockApprove, mockUseWorkspaces, mockPush } = vi.hoisted(() => ({ + mockApprove: vi.fn(), + mockUseWorkspaces: vi.fn(), + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [ + { + request: 'a'.repeat(43), + challenge: 'b'.repeat(43), + pairing: 'ABCD-2345', + scope: 'platform', + workspace: null, + }, + ], +})) + +vi.mock('@/hooks/queries/cli-auth', () => ({ + useApproveCliAuth: () => ({ + mutate: mockApprove, + isPending: false, + isSuccess: false, + isError: false, + error: null, + }), +})) + +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesWithMetadata: mockUseWorkspaces, +})) + +import { CliAuthView } from '@/app/cli/auth/cli-auth-view' + +let container: HTMLDivElement +let root: Root + +function render() { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render() + }) +} + +/** The primary CTA is the only button whose label mentions connecting. */ +function connectButton(): HTMLButtonElement { + const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[] + const button = buttons.find((b) => /connect/i.test(b.textContent ?? '')) + if (!button) throw new Error('Connect button not found') + return button +} + +const LOADED = { + isPending: false, + isError: false, + data: { + workspaces: [ + { id: 'ws_admin', name: 'Acme', permissions: 'admin' }, + { id: 'ws_member', name: 'Other', permissions: 'write' }, + ], + lastActiveWorkspaceId: 'ws_admin', + }, +} + +describe('CliAuthView workspace loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('blocks Connect until the workspace list resolves', () => { + // The regression: while pending, the picker falls back to the personal + // option, so an early click approved a personal key when the same click a + // moment later would have bound the key to the user's workspace. + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(connectButton().disabled).toBe(true) + expect(container.textContent).toContain('Loading workspaces') + expect(container.textContent).not.toContain('No workspace (personal key)') + }) + + it('does not present the personal-key wording as the answer while loading', () => { + mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) + render() + + expect(container.textContent).toContain('Checking which workspaces') + expect(container.textContent).not.toContain('Issues a personal key') + }) + + it('enables Connect and preselects the last active workspace once loaded', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('only reach Acme') + }) + + it('binds the key to the workspace when the approver is an admin', () => { + mockUseWorkspaces.mockReturnValue(LOADED) + render() + act(() => { + connectButton().click() + }) + + expect(mockApprove).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'platform', + workspaceId: 'ws_admin', + bindKeyToWorkspace: true, + }), + expect.anything() + ) + }) + + it('still lets the user connect when the workspace list fails', () => { + // A personal key is degraded but usable; blocking entirely would strand a + // terminal on a transient list failure. + mockUseWorkspaces.mockReturnValue({ isPending: false, isError: true, data: undefined }) + render() + + expect(connectButton().disabled).toBe(false) + expect(container.textContent).toContain('Could not load your workspaces') + }) +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index d344b216797..74f53ec5019 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -59,6 +59,18 @@ export function CliAuthView() { const { request } = resolution + /** + * Approval must wait for the workspace list. + * + * Until it arrives there is no selection to show, and the fallback would read + * as "No workspace (personal key)" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click approve a personal key + * with no default workspace, when a moment later the same click would have + * bound the key to the user's workspace. Blocking is the only way the card + * can promise what it is about to do. + */ + const loadingWorkspaces = isPlatform && workspaces.isPending + // The terminal's suggestion, then the user's last active workspace. Derived at // render rather than synced into state through an effect, so the first paint // after the list loads already shows the right row. @@ -91,7 +103,11 @@ export function CliAuthView() { options={options} value={workspaceId ?? PERSONAL_VALUE} onChange={setSelected} - disabled={workspaces.isLoading} + disabled={loadingWorkspaces || workspaces.isError} + // A placeholder only shows when nothing is selected, and the + // fallback value always counts as a selection — so the loading + // state has to override the rendered label outright. + displayLabel={loadingWorkspaces ? 'Loading workspaces…' : undefined} placeholder='Select a workspace' searchable={options.length > 8} searchPlaceholder='Search workspaces' @@ -99,15 +115,20 @@ export function CliAuthView() { dropdownWidth='trigger' />

- {bindsToWorkspace - ? `Issues a key that can only reach ${chosen.name}.` - : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'} + {loadingWorkspaces + ? 'Checking which workspaces you can issue a key for…' + : workspaces.isError + ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' + : bindsToWorkspace + ? `Issues a key that can only reach ${chosen.name}.` + : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'}

)} approve.mutate( From 5d3785a350d2ca89f6f9cf477f0fd7de1a15f21e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 16:24:45 -0700 Subject: [PATCH 06/28] fix(cli-auth): name minted keys by timestamp, not date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second login on the same day failed with `A workspace API key named "CLI (2026-07-30)" already exists` — after the user had already approved in the browser, so the whole handoff was wasted and there was no way to complete it without renaming the existing key. Key names are unique per owner, so the name has to be unique per login. Now `CLI (2026-07-30 15:42:07Z)`: second precision, UTC so it is unambiguous in a shared workspace key list and sorts chronologically. The comment claiming a same-day collision was desirable (so logins would reuse one key) was wrong — nothing reuses the key, the mint just fails. A collision at second precision now means something genuinely unexpected, so it is still surfaced rather than retried under a suffixed name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/api/cli/auth/poll/route.test.ts | 7 ++++++- apps/sim/app/api/cli/auth/poll/route.ts | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 8c762b4845e..81709bd411b 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -99,7 +99,12 @@ describe('POST /api/cli/auth/poll', () => { workspaceId: null, workspaceBound: false, }) - expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /)) + // Second precision, not day: a date-only name made the second login of the + // day fail after the user had already approved in the browser. + expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith( + 'user-1', + expect.stringMatching(/^CLI \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z\)$/) + ) expect(mockCompleteApproval).toHaveBeenCalledWith(REQUEST) expect(mockReleaseMint).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index 99bda7fa9a3..c3e6e8c00c3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -28,18 +28,25 @@ const POLL_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -/** Keys are named for the day they were issued, matching what the CLI prints. */ +/** + * Names a minted key for the instant it was issued, e.g. `CLI (2026-07-30 + * 15:42:07Z)`. + * + * Second precision, not day: key names are unique per owner, so a date-only + * name made the second login of the day fail outright with "a key named … + * already exists" — after the user had already approved in the browser. UTC so + * the name is unambiguous in a shared workspace list and sorts chronologically. + */ function cliKeyName(): string { - return `CLI (${new Date().toISOString().slice(0, 10)})` + return `CLI (${new Date().toISOString().slice(0, 19).replace('T', ' ')}Z)` } /** * Mints from the key space the approval recorded. * - * A name collision is reported as a conflict rather than retried under a - * generated name: two logins on the same day from the same terminal should - * reuse the existing key, and silently accumulating `CLI (date) (2)` rows - * would hide that. + * A name collision is still surfaced rather than retried under a suffixed name: + * with second precision it means something genuinely unexpected, and silently + * accumulating near-identical rows would hide it. */ async function mintForGrant( grant: ApprovalGrant From 936efcf656bcb580a1351db11e72e572d1c4f46f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 12:44:07 -0700 Subject: [PATCH 07/28] feat(cli): CLI contract for the v2 surface, incl. execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `packages/sim-cli/src/contract` — the declarative definition of how the terminal maps onto the API — and folds in the v2 execution endpoints that just landed on improvement/v2-endpoints. ## The contract Read it as a diff against what is already derivable, not a listing. Method, path, path params, field types, enum values, defaults and required-ness all come from the generated operation table (which comes from the Zod contracts), and the command name derives from ` [sub-resource] `. 23 of 47 operations therefore need no entry at all. The 24 that do carry only what a schema cannot express: - names, where REST overloads one path — `DELETE /rows` vs `DELETE /rows/[rowId]` becomes `batch-delete` vs `delete`, and `DELETE /deploy` becomes `undeploy` - flags, where a field's type misdescribes its meaning — `workflowIds` is `z.string()` that the route splits on commas; no generator can infer that - columns, which are editorial - confirm, for the 8 destructive operations ## Execution `executeWorkflow` / `getWorkflowExecution` / `cancelWorkflowExecution` derive badly (`/execute` and `/cancel` are verbs the deriver reads as nouns), so all three are named explicitly: `workflows run`, `workflows executions get|cancel`. `stream` is marked `omit`: it switches the response to SSE, which the JSON client would try to parse. Advertising a flag that breaks the response is worse than not offering it — a `--follow` command that renders the stream is separate and hand-written, like `files download`. ## Also - Drops `check:openapi-drift`. The branch landed `check:openapi`, which does the same path/method reconciliation plus a recursive field diff and validates doc examples against the real Zod schemas — mine was a strict subset. - Surfaces the new v2 rollout gate in the CLI: it answers 404 for callers outside the cohort, indistinguishable from a missing resource, so a 404 now carries that as a possibility rather than a diagnosis. - `executor/utils/errors.ts` widens instead of casting through `unknown`, which is both more honest (the value is an Error) and keeps the double-cast ratchet at 8. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .github/workflows/test-build.yml | 5 - apps/sim/executor/utils/errors.ts | 5 +- package.json | 1 - packages/sim-cli/src/contract/commands.ts | 176 ++++++++++++++++++++++ packages/sim-cli/src/contract/types.ts | 85 +++++++++++ packages/sim-cli/src/generated/v2-api.ts | 137 +++++++++++++++++ packages/sim-cli/src/http/client.ts | 7 + scripts/generate-v2-cli-api.ts | 77 +--------- 8 files changed, 413 insertions(+), 80 deletions(-) create mode 100644 packages/sim-cli/src/contract/commands.ts create mode 100644 packages/sim-cli/src/contract/types.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e7464a6363e..5179544b447 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -136,11 +136,6 @@ jobs: - name: Sim CLI API generation up to date run: bun run check:cli-api - # Structure only — the OpenAPI documents keep their hand-written prose, - # but every v2 path/method must still exist on both sides. - - name: OpenAPI matches the v2 contracts - run: bun run check:openapi-drift - # Complements the bridge audit above, which compares against a snapshot # this same PR is allowed to regenerate. This one derives every fact from # the source both sides execute, so it has no such blind spot. diff --git a/apps/sim/executor/utils/errors.ts b/apps/sim/executor/utils/errors.ts index 4b317377308..e40ff4ad8bf 100644 --- a/apps/sim/executor/utils/errors.ts +++ b/apps/sim/executor/utils/errors.ts @@ -155,7 +155,10 @@ function readAttachedBlockContext(error: unknown): { blockType?: string } { if (!(error instanceof Error)) return {} - const attached = error as unknown as AttachedBlockContext + // Widen rather than erase: the value is an Error, it just may carry extra + // fields attached at throw time. Casting through `unknown` would discard + // that, and trips the double-cast ratchet for no benefit. + const attached = error as Error & Partial return { blockId: typeof attached.blockId === 'string' ? attached.blockId : undefined, blockName: typeof attached.blockName === 'string' ? attached.blockName : undefined, diff --git a/package.json b/package.json index 7f6341f0aeb..52a2618034a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check", "check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts", "check:cli-api": "bun run scripts/generate-v2-cli-api.ts --check", - "check:openapi-drift": "bun run scripts/generate-v2-cli-api.ts --check-openapi", "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", "desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts new file mode 100644 index 00000000000..8f000240b36 --- /dev/null +++ b/packages/sim-cli/src/contract/commands.ts @@ -0,0 +1,176 @@ +import type { CliContract } from './types.js' + +/** + * The CLI contract for the v2 surface. + * + * Read this as a diff against what is already derivable — an operation absent + * from this table still gets a command, built entirely from the generated + * operation table. Only the entries below needed a human. + * + * Derived by default: + * listTables → sim tables list + * getKnowledgeDocument → sim knowledge documents get + * upsertTableRow → sim tables upsert + */ +export const CLI_CONTRACT: CliContract = { + // ─── Name collisions: REST overloads one path for single and bulk ───────── + // The derived name is identical for both, so the bulk form is renamed. AWS's + // `batch-` prefix rather than a `--all` flag: the plural is a different and + // more dangerous operation, and it should be a different word. + deleteTableRows: { + command: 'tables rows batch-delete', + describe: 'Delete rows matching a filter, or an explicit list of ids', + flags: { rowIds: { name: 'row', list: true }, filter: { json: true } }, + confirm: 'This deletes every matching row and cannot be undone.', + }, + updateRowsByFilter: { + command: 'tables rows batch-update', + describe: 'Update every row matching a filter', + flags: { filter: { json: true }, data: { json: true } }, + confirm: 'This updates every matching row and cannot be undone.', + }, + // `DELETE /workflows/[id]/deploy` is an undeploy, not a delete. + undeployWorkflow: { + command: 'workflows undeploy', + describe: 'Take a workflow out of deployment', + }, + + // ─── Destructive single-resource operations ─────────────────────────────── + deleteTable: { confirm: 'This deletes the table and all of its rows.' }, + deleteTableRow: { confirm: 'This deletes the row.' }, + deleteTableColumn: { confirm: 'This deletes the column and its values in every row.' }, + deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, + deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, + deleteFile: { confirm: 'This archives the file.' }, + + // ─── Fields whose type misdescribes their meaning ───────────────────────── + // `z.string()` that the route splits on commas. No generator can infer this. + listLogs: { + flags: { + workflowIds: { name: 'workflow', list: true }, + folderIds: { name: 'folder', list: true }, + triggers: { name: 'trigger', list: true }, + }, + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'execution', path: 'executionId' }, + ], + }, + searchKnowledge: { + // Accepts a string or an array on the wire; the CLI always sends the array. + flags: { knowledgeBaseIds: { name: 'kb', list: true }, tagFilters: { json: true } }, + columns: [ + { header: 'score', path: 'similarity' }, + { header: 'document', path: 'documentName' }, + { header: 'chunk', path: 'chunkIndex' }, + { header: 'content' }, + ], + }, + + // ─── Friendlier flag names ──────────────────────────────────────────────── + upsertTableRow: { + describe: 'Insert a row, or update the one that conflicts on a unique column', + flags: { + data: { json: true }, + conflictTarget: { name: 'on', describe: 'Unique column to resolve the conflict against' }, + }, + columns: [{ header: 'id' }, { header: 'operation' }], + }, + queryRows: { + command: 'tables rows query', + flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + }, + + // ─── Output columns for list commands ───────────────────────────────────── + listTables: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'rows', path: 'rowCount' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkflows: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'deployed', path: 'isDeployed', format: 'bool' }, + { header: 'runs', path: 'runCount' }, + { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, + ], + }, + listFiles: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + listKnowledgeBases: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'docs', path: 'docCount' }, + { header: 'tokens', path: 'tokenCount' }, + { header: 'model', path: 'embeddingModel' }, + ], + }, + listKnowledgeDocuments: { + columns: [ + { header: 'id' }, + { header: 'filename' }, + { header: 'size', path: 'fileSize', format: 'bytes' }, + { header: 'status', path: 'processingStatus' }, + { header: 'chunks', path: 'chunkCount' }, + ], + }, + listAuditLogs: { + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'actor', path: 'actorEmail' }, + { header: 'action' }, + { header: 'resource', path: 'resourceName' }, + ], + }, + + // ─── Execution ──────────────────────────────────────────────────────────── + // The derived names land badly here: `/execute` and `/cancel` are verbs in + // the path, but neither is in the action list, so POST would derive + // `workflows execute create` and `workflows cancel create`. + executeWorkflow: { + command: 'workflows run', + describe: 'Run a deployed workflow and wait for the result', + flags: { + input: { json: true, describe: 'Trigger input as JSON' }, + selectedOutputs: { name: 'output', list: true }, + // SSE, not JSON — the generic client cannot consume it. A `sim workflows + // run --follow` that renders the stream is a separate, hand-written + // command; advertising a flag that breaks the response is worse than + // not offering it yet. + stream: { omit: true }, + }, + }, + getWorkflowExecution: { + command: 'workflows executions get', + describe: 'Show the status of one execution', + }, + cancelWorkflowExecution: { + command: 'workflows executions cancel', + describe: 'Cancel a running execution', + // Not `confirm`-gated: cancelling is recoverable (re-run it), and the + // whole point is to stop something that is already going wrong. + }, + + // ─── Not a terminal-shaped operation ────────────────────────────────────── + // Multipart upload; `sim files upload ` needs its own file-reading + // command rather than a generated flag surface. + uploadFile: { hidden: true }, + uploadKnowledgeDocument: { hidden: true }, +} diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts new file mode 100644 index 00000000000..f255ecc88e5 --- /dev/null +++ b/packages/sim-cli/src/contract/types.ts @@ -0,0 +1,85 @@ +import type { V2OperationName } from '../generated/v2-api.js' + +/** + * The CLI contract: how the terminal surface maps onto the v2 API. + * + * Most of a command is derivable and is NOT stated here. Method, path, path + * params, field types, enum values, defaults, and required-ness all come from + * the generated operation table, which comes from the Zod route contracts. The + * command name itself derives from ` ` for 41 of + * the 44 operations. + * + * This file carries only what a schema cannot say: + * + * - `command` — when the derived name collides or reads badly. REST overloads + * one path for single and bulk (`DELETE /rows` vs `DELETE /rows/[rowId]`), so + * those need a human to pick `delete` vs `batch-delete`. + * - `flags` — when a field's *type* misdescribes its *meaning*. `workflowIds` + * is `z.string()` that the route splits on commas; nothing in the schema says + * "list". Also friendlier aliases (`conflictTarget` → `--on`). + * - `columns` — which of a response's fields belong in a table. Editorial. + * - `confirm` — which operations are destructive enough to demand `--yes`. + * + * An operation with nothing unusual needs no entry at all. + */ + +/** How one request field is exposed as a flag. */ +export interface FlagSpec { + /** Flag name, kebab-case, without `--`. Defaults to the kebab-cased field name. */ + name?: string + /** Short alias, e.g. `w` for `--workspace`. */ + short?: string + /** + * Accept a repeated flag and send it comma-joined. For fields the schema + * types as `string` but the route splits — invisible to any type-driven + * generator, so it has to be stated. + */ + list?: boolean + /** Take a JSON string. Implied for object/array/unknown fields. */ + json?: boolean + /** Overrides the help text otherwise taken from the OpenAPI description. */ + describe?: string + /** + * Never expose this field as a flag, and never send it. + * + * For request fields the terminal cannot honor — `stream: true` switches the + * response to SSE, which the JSON client would try to `JSON.parse`. Offering + * the flag would advertise a mode that breaks; a bespoke streaming command + * owns that instead. + */ + omit?: boolean +} + +/** A column in table-mode output. */ +export interface ColumnSpec { + /** Header, and the default path into the row when `value` is omitted. */ + header: string + /** Dot path into the row. Defaults to `header`. */ + path?: string + /** Rendering hint; `auto` inspects the value. */ + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' +} + +export interface CommandSpec { + /** + * Command path, space-separated. Omit to accept the derived + * ` [sub-resource] ` name. + */ + command?: string + /** One-line help. Falls back to the OpenAPI summary for the operation. */ + describe?: string + /** Per-field flag overrides, keyed by the contract's field name. */ + flags?: Record + /** Columns for table output. Omit on non-list commands to print a record. */ + columns?: ColumnSpec[] + /** + * Require `--yes`. The message should say what is about to be destroyed — + * the point is that the caller can tell whether they meant it. + */ + confirm?: string + /** Keep the operation out of the CLI surface entirely. */ + hidden?: boolean +} + +/** The contract: operation name → how it appears in the terminal. */ +export type CliContract = Partial> diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index f6f7238c14b..f1bcb5ddd52 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -50,6 +50,29 @@ export type AddTableColumnResponse = { } } +/** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ +export type CancelWorkflowExecutionParams = { + id: string + executionId: string +} + +export type CancelWorkflowExecutionResponse = { + data: { + success: boolean + executionId: string + redisAvailable: boolean + durablyRecorded: boolean + locallyAborted: boolean + pausedCancelled: boolean + reason?: + | 'recorded' + | 'redis_unavailable' + | 'redis_write_failed' + | 'paused_event_publish_failed' + | 'paused_database_cancel_failed' + } +} + /** `POST /api/v2/knowledge` */ export type CreateKnowledgeBaseBody = { workspaceId: string @@ -364,6 +387,49 @@ export type DownloadFileQuery = { /** Non-JSON response (`binary`). */ export type DownloadFileResponse = never +/** `POST /api/v2/workflows/[id]/execute` */ +export type ExecuteWorkflowParams = { + id: string +} + +export type ExecuteWorkflowBody = { + input?: Record + async?: boolean + stream?: boolean + selectedOutputs?: Array + includeThinking?: boolean + includeToolCalls?: boolean + includeFileBase64?: boolean + base64MaxBytes?: number +} + +export type ExecuteWorkflowResponse = { + data: { + executionId: string + workflowId: string + status: 'completed' | 'failed' | 'paused' | 'cancelled' + output: unknown + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + startedAt?: string + endedAt?: string + durationMs?: number + } +} + /** `GET /api/v2/workflows/[id]/export` */ export type ExportWorkflowParams = { id: string @@ -743,6 +809,59 @@ export type GetWorkflowResponse = { } } +/** `GET /api/v2/workflows/[id]/executions/[executionId]` */ +export type GetWorkflowExecutionParams = { + id: string + executionId: string +} + +export type GetWorkflowExecutionQuery = { + includeOutput?: 'true' | 'false' + selectedOutputs?: string +} + +export type GetWorkflowExecutionResponse = { + data: { + executionId: string + workflowId: string + status: 'queued' | 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string | null + startedAt: string | null + endedAt: string | null + durationMs: number | null + paused: { + pausedAt: string + resumeAt: string | null + pauseKind: 'time' | 'human' | null + blockedOnBlockId: string | null + automaticResumeWaitingReason: string | null + pausedExecutionId: string + pausePointCount: number + resumedCount: number + } | null + cost: { + total: number + } | null + error: { + message: string + code: + | 'TIMEOUT' + | 'CANCELLED' + | 'USAGE_LIMIT_EXCEEDED' + | 'INVALID_INPUT' + | 'BLOCK_EXECUTION_FAILED' + | 'CHILD_WORKFLOW_FAILED' + | 'OUTPUT_TOO_LARGE' + | 'EXECUTION_FAILED' + blockId?: string + blockName?: string + blockType?: string + } | null + output: unknown | null + blockOutputs: Record | null + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -1394,6 +1513,12 @@ export const V2_OPERATIONS = { pathParams: ['tableId'] as const, responseMode: 'json', }, + cancelWorkflowExecution: { + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', @@ -1466,6 +1591,12 @@ export const V2_OPERATIONS = { pathParams: ['fileId'] as const, responseMode: 'binary', }, + executeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/execute', + pathParams: ['id'] as const, + responseMode: 'json', + }, exportWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]/export', @@ -1526,6 +1657,12 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', }, + getWorkflowExecution: { + method: 'GET', + path: '/api/v2/workflows/[id]/executions/[executionId]', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 22110a846db..0c806c31db8 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -152,6 +152,13 @@ export class SimClient { if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.name}` } + if (response.status === 404) { + // The v2 surface is behind a rollout flag that answers 404 when the + // caller is not in the cohort — deliberately indistinguishable from a + // missing resource, so the CLI cannot tell which happened. Offered as a + // possibility rather than a diagnosis; a plain bad id 404s identically. + error.message = `${error.message}\n If every command returns this, the v2 API may not be enabled for your account yet.` + } throw error } diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 17aa0db715f..b0dbc74624b 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -16,15 +16,14 @@ * boundary changes. * * Deliberately NOT generated: the OpenAPI documents under `apps/docs`. They - * carry ~1000 hand-written descriptions and ~400 examples that Zod schemas do - * not encode, and regenerating them would trade real documentation for - * mechanical accuracy. `--check-openapi` reconciles their *structure* against - * the contracts instead, so the prose survives while drift still fails CI. + * carry hand-written descriptions, examples, and error responses that Zod + * schemas do not encode. `scripts/check-openapi-specs.ts` reconciles those + * against the same contracts instead, field by field, so the prose survives + * while drift still fails CI. * * Usage: * bun run scripts/generate-v2-cli-api.ts # write the generated file * bun run scripts/generate-v2-cli-api.ts --check # fail if it is stale - * bun run scripts/generate-v2-cli-api.ts --check-openapi */ import { spawnSync } from 'node:child_process' @@ -35,7 +34,6 @@ import { z } from 'zod' const ROOT = path.resolve(import.meta.dir, '..') const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') -const DOCS_DIR = path.join(ROOT, 'apps/docs') /** Contract modules to read, in emit order. */ const DOMAINS = [ @@ -238,59 +236,6 @@ function render(operations: Operation[]): string { return out.join('\n') } -/** - * Reconciles the hand-written OpenAPI documents against the contracts. - * - * Structure only — every contract path/method must be documented, and every - * documented v2 path/method must exist as a contract. Descriptions and examples - * are the docs' own, and are deliberately not compared. - */ -function checkOpenApi(operations: Operation[]): string[] { - const problems: string[] = [] - - const documented = new Set() - for (const file of [ - 'openapi-core.json', - 'openapi-v2-workflows.json', - 'openapi-v2-logs.json', - 'openapi-v2-tables.json', - 'openapi-v2-knowledge.json', - 'openapi-v2-files-audit.json', - ]) { - let spec: JsonSchema - try { - spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) - } catch { - problems.push(`missing or unparseable spec: ${file}`) - continue - } - for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { - for (const method of Object.keys(methods as object)) { - if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue - documented.add(`${method.toUpperCase()} ${specPath}`) - } - } - } - - for (const op of operations) { - // Contracts use Next.js `[id]`; OpenAPI uses `{id}`. - const openApiPath = op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}') - const key = `${op.contract.method} ${openApiPath}` - if (!documented.has(key)) { - problems.push(`contract not documented in OpenAPI: ${key} (${op.name})`) - } - documented.delete(key) - } - - for (const stale of documented) { - if (stale.includes('/api/v2/')) { - problems.push(`documented in OpenAPI but no contract: ${stale}`) - } - } - - return problems -} - /** * Runs the emitted source through Biome so the generated file is a fixed point * of the repo's formatter. @@ -324,20 +269,6 @@ async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() - if (args.has('--check-openapi')) { - const problems = checkOpenApi(operations) - if (problems.length > 0) { - console.error('OpenAPI drift against the v2 contracts:\n') - for (const problem of problems) console.error(` - ${problem}`) - console.error( - '\nUpdate apps/docs/openapi-v2-*.json to match the contracts (the contracts are authoritative).' - ) - process.exit(1) - } - console.log(`OpenAPI matches all ${operations.length} v2 contracts.`) - return - } - const generated = format(render(operations)) if (args.has('--check')) { From 1a3d00424e0384009ddd818c2d625c8517235354 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:23:49 -0700 Subject: [PATCH 08/28] feat(cli): yaml and text output formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--output` now takes table | json | yaml | text, settable per-command, via SIM_OUTPUT, or persisted per profile as before. `yaml` joins `json` in rendering the API's raw values rather than the table's formatted cells, so a duration stays `1500` instead of becoming `"1.5s"` — switching format changes the encoding, never the data. Line folding is disabled: valid YAML, but it breaks line-oriented greps and is miserable to read. `text` is tab-separated with no header and no colour — the shape `cut -f2` and `while IFS=$'\t' read` expect, so shell plumbing works on a box with no JSON tool. It uses the rendered cells rather than raw values, since it is a human-ish format for pipelines rather than something to parse. An absent value collapses to an empty field instead of the table's em-dash: `cut` returning a literal `—` would read as a value to every downstream emptiness test. A bad `--output` is now an error (commander `.choices`) rather than a silent fall back to `table`. The environment variable and the config file stay tolerant — those are ambient and set once, so a bad value should not break every command, but a flag just typed should not be quietly disregarded. Uses js-yaml 4.3.0, already a direct dependency of apps/sim, rather than adding a second YAML library to the monorepo. Also drops a stale README reference to check:openapi-drift, which the v2-endpoints merge superseded with the deeper check:openapi. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- bun.lock | 2 + packages/sim-cli/README.md | 49 +++++++++++---- packages/sim-cli/package.json | 4 +- packages/sim-cli/src/config/profile.test.ts | 12 +++- packages/sim-cli/src/config/profile.ts | 10 ++- packages/sim-cli/src/index.ts | 13 +++- packages/sim-cli/src/output/render.test.ts | 59 +++++++++++++++++ packages/sim-cli/src/output/render.ts | 70 ++++++++++++++++++--- 8 files changed, 194 insertions(+), 25 deletions(-) diff --git a/bun.lock b/bun.lock index 0ad4015d018..4322788f585 100644 --- a/bun.lock +++ b/bun.lock @@ -589,9 +589,11 @@ "dependencies": { "chalk": "5.6.2", "commander": "^11.1.0", + "js-yaml": "4.3.0", }, "devDependencies": { "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", "typescript": "^7.0.2", "vitest": "^3.2.4", diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 25b0fa993d5..25ed1d82f53 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -58,6 +58,8 @@ Each setting resolves independently, first match wins: | 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | | 4 | Built-in default (`https://sim.ai`, `table`) | +Formats are listed under [Output formats](#output-formats). + `sim whoami` prints the winning source per setting, which is usually the fastest way to explain a surprising result. @@ -150,13 +152,38 @@ page so a sparse row doesn't hide a column. Deletions require an explicit selector *and* `--yes`; there is no "delete everything" default. -Every command takes `--output json` for scripting; the JSON is the API's own -response shape, so it pipes cleanly into `jq`. +### Output formats + +`--output` / `-o`, or `SIM_OUTPUT`, or `output =` in the profile: + +| Format | For | +| --- | --- | +| `table` | reading (default) | +| `json` | piping into `jq` | +| `yaml` | piping into anything that reads YAML | +| `text` | shell loops — tab-separated, no header, no colour | + +`json` and `yaml` emit the API's **raw** values, not the table's formatting — a +duration stays `1500`, not `"1.5s"` — so switching format never changes the data. +`text` uses the rendered cells, since it is meant for shell plumbing rather than +parsing. ```bash -sim logs list --level error --output json | jq -r '.[].executionId' +sim logs list --level error -o json | jq -r '.[].executionId' +sim logs list --level error -o yaml > logs.yaml + +sim files list -o text | while IFS=$'\t' read -r id name size type uploaded; do + echo "$id $name" +done ``` +An absent value is an em-dash in `table` and an **empty field** in `text`, so +emptiness tests downstream behave. + +A bad `--output` is an error; a bad `SIM_OUTPUT` or `output =` is ignored and +falls back to `table` — ambient settings should not brick every command, but a +flag you just typed should not be silently disregarded. + ## How this stays in sync with the API `src/generated/v2-api.ts` is generated from the Zod route contracts in @@ -166,9 +193,9 @@ It holds every response/request type plus the operation table (method, path, path params) the client dispatches through. ```bash -bun run generate:cli-api # regenerate after changing a contract -bun run check:cli-api # CI: fails if the generated file is stale -bun run check:openapi-drift # CI: fails if the docs and contracts disagree +bun run generate:cli-api # regenerate after changing a contract +bun run check:cli-api # CI: fails if the generated file is stale +bun run check:openapi # CI: fails if the docs and contracts disagree ``` The generated file contains only type declarations and one const — no imports — @@ -176,11 +203,11 @@ so the `packages/*` must not import `apps/*` boundary is preserved; the script does the crossing at build time. The OpenAPI documents under `apps/docs` are deliberately **not** generated. They -carry ~1000 hand-written descriptions and ~400 examples that Zod schemas don't -encode, so regenerating them would trade real documentation for mechanical -accuracy. `check:openapi-drift` reconciles their *structure* against the -contracts instead — every v2 path and method must exist on both sides — so the -prose survives while drift still fails the build. +carry hand-written descriptions, examples, and error responses that Zod schemas +don't encode, so regenerating them would trade real documentation for mechanical +accuracy. `check:openapi` reconciles them against the same contracts instead — +field by field, and it parses every documented example with the real Zod schema — +so the prose survives while drift still fails the build. ## Notes diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 4cc20b967fc..15f721ae031 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -33,10 +33,12 @@ }, "dependencies": { "chalk": "5.6.2", - "commander": "^11.1.0" + "commander": "^11.1.0", + "js-yaml": "4.3.0" }, "devDependencies": { "@sim/tsconfig": "workspace:*", + "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", "typescript": "^7.0.2", "vitest": "^3.2.4" diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 141166945be..fb18c536ab6 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -6,6 +6,7 @@ import { configPath, credentialsPath } from './paths.js' import { deleteProfile, listProfiles, + OUTPUT_FORMATS, resolveProfile, writeConfigProfile, writeCredentialsProfile, @@ -96,10 +97,19 @@ describe('profile resolution', () => { }) it('ignores an unrecognized output format instead of failing the whole resolve', () => { - process.env.SIM_OUTPUT = 'yaml' + // Ambient sources tolerate garbage so one bad value cannot brick every + // command; the `--output` flag is strict instead (commander `.choices`). + process.env.SIM_OUTPUT = 'xml' expect(resolveProfile().output).toBe('table') }) + it('accepts every documented output format from the environment', () => { + for (const format of OUTPUT_FORMATS) { + process.env.SIM_OUTPUT = format + expect(resolveProfile().output).toBe(format) + } + }) + it('writes credentials 0600 even when the file already existed world-readable', () => { writeFileSync(credentialsPath(), '', { mode: 0o644 }) writeCredentialsProfile('default', 'sim_key') diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 943d414c7c7..d2e5e85c683 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -13,7 +13,15 @@ import { configPath, credentialsPath } from './paths.js' export const DEFAULT_PROFILE = 'default' export const DEFAULT_ENDPOINT = 'https://sim.ai' -export const OUTPUT_FORMATS = ['table', 'json'] as const + +/** + * Output formats, in the order `--help` lists them. + * + * `table` is for reading, `json`/`yaml` for piping into a parser, and `text` is + * the one for shell loops: tab-separated, no header, no colour, so `cut`/`awk`/ + * `while read` work without a JSON tool on the box. + */ +export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] /** Everything a command needs to make a call, after the resolution chain runs. */ diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 6b4d8e20149..10a1fb7c191 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import chalk from 'chalk' -import { Command } from 'commander' +import { Command, Option } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' import { filesCommand } from './commands/files.js' @@ -21,7 +21,16 @@ program .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') - .option('-o, --output ', `Output format: ${OUTPUT_FORMATS.join(' | ')} (env: SIM_OUTPUT)`) + // `.choices` so a typo'd format is an error, not a silent fall back to + // `table`. Deliberately stricter than SIM_OUTPUT and the config file, which + // tolerate an unknown value: those are ambient and set once, and a bad one + // should not make every command fail — but a flag is an instruction just + // typed, so honouring something else is a lie. + .addOption( + new Option('-o, --output ', 'Output format (env: SIM_OUTPUT)').choices([ + ...OUTPUT_FORMATS, + ]) + ) program.addCommand(loginCommand()) program.addCommand(logoutCommand()) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index c092febc001..0212bfbcd6d 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -1,4 +1,5 @@ import chalk, { Chalk } from 'chalk' +import { load } from 'js-yaml' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bytes, @@ -87,6 +88,54 @@ describe('printList', () => { printList('json', [{ name: 'alpha', status: 'error' }], COLUMNS) expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) }) + + it('prints the raw rows for yaml too', () => { + printList('yaml', [{ name: 'alpha', status: 'error' }], COLUMNS) + expect(load(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) + }) + + it('keeps machine formats identical in content — only the encoding differs', () => { + const rows = [{ name: 'alpha', status: 'error' }] + printList('json', rows, COLUMNS) + printList('yaml', rows, COLUMNS) + expect(load(logged[1])).toEqual(JSON.parse(logged[0])) + }) + + it('does not fold long yaml values across lines', () => { + // Folding is valid YAML but breaks line-oriented greps and is miserable to read. + const long = 'x'.repeat(300) + printList('yaml', [{ name: long, status: 'ok' }], COLUMNS) + expect(logged[0]).toContain(long) + }) + + it('emits tab-separated cells with no header for text', () => { + printList( + 'text', + [ + { name: 'alpha', status: 'error' }, + { name: 'b', status: 'ok' }, + ], + COLUMNS + ) + expect(logged).toEqual(['alpha\terror', 'b\tok']) + }) + + it('strips colour from text output so cut and awk see plain fields', () => { + printList('text', [{ name: 'alpha', status: coloured.red('error') }], COLUMNS) + expect(logged[0]).toBe('alpha\terror') + }) + + it('renders an absent value as an empty text field, not a dash', () => { + // `cut -f2` returning a literal em-dash would read as a value to every + // downstream emptiness test. + printList('text', [{ name: 'alpha', status: text(null) }], COLUMNS) + expect(logged[0]).toBe('alpha\t') + }) + + it('prints nothing at all for an empty text list', () => { + printList('text', [], COLUMNS) + expect(logged).toEqual([]) + }) }) describe('printRecord', () => { @@ -95,6 +144,16 @@ describe('printRecord', () => { expect(JSON.parse(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) }) + it('prints the raw object for yaml, ignoring the field list', () => { + printRecord('yaml', [['Name', 'alpha']], { name: 'alpha', hidden: 1 }) + expect(load(logged[0])).toEqual({ name: 'alpha', hidden: 1 }) + }) + + it('prints label-tab-value for text', () => { + printRecord('text', [['ID', 'abc']], {}) + expect(logged[0]).toBe('ID\tabc') + }) + it('prints one aligned line per field for table', () => { printRecord( 'table', diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 821043bb85a..0803973467a 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -1,4 +1,5 @@ import chalk from 'chalk' +import { dump } from 'js-yaml' import type { OutputFormat } from '../config/index.js' export interface Column { @@ -6,8 +7,11 @@ export interface Column { value: (row: T) => string } +/** The glyph standing in for "no value", before colour is applied. */ +const EMPTY_GLYPH = '—' + /** Cell text for values that have no useful rendering, kept visually quiet. */ -const EMPTY = chalk.dim('—') +const EMPTY = chalk.dim(EMPTY_GLYPH) export function text(value: unknown): string { if (value === null || value === undefined || value === '') return EMPTY @@ -67,6 +71,18 @@ export function visibleWidth(value: string): number { return value.replace(ANSI_PATTERN, '').length } +/** + * Plain text for a rendered cell. + * + * The empty placeholder collapses to an actual empty field: `cut -f3` returning + * a literal `—` for a null would be worse than useless, since every downstream + * emptiness test would read it as a value. + */ +function stripAnsi(value: string): string { + const plain = value.replace(ANSI_PATTERN, '') + return plain === EMPTY_GLYPH ? '' : plain +} + function pad(value: string, width: number): string { return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) } @@ -94,25 +110,61 @@ function renderTable(rows: T[], columns: Column[]): string { return [header, ...body].join('\n') } +/** + * Renders the machine-readable formats from the RAW value. + * + * Deliberately not the table's formatted cells: `--output json` piped into `jq` + * must yield the API's own field names and types, so a `1500` stays a number + * rather than becoming the `"1.5s"` the table would show. `yaml` follows the + * same rule, so switching format never changes the data. + * + * Returns null when the format wants the human rendering instead. + */ +function renderMachine(format: OutputFormat, raw: unknown): string | null { + if (format === 'json') return JSON.stringify(raw, null, 2) + // `lineWidth: 0` disables YAML's line folding — a wrapped value is technically + // valid but is miserable to eyeball and breaks naive line-oriented greps. + if (format === 'yaml') return dump(raw, { lineWidth: 0, noRefs: true }).trimEnd() + return null +} + /** * Prints a list in the profile's output format. * - * The JSON branch prints the raw rows, not the table's formatted cells — piping - * to `jq` should yield the API's own field names and types, so `--output json` - * is a passthrough rather than a second rendering. + * `text` emits the table's cells tab-separated with no header and no colour — + * the shape `cut -f2` and `while read` expect. It uses the formatted cells + * rather than the raw values on purpose: it is a human-ish format for shell + * plumbing, and a raw ISO timestamp or byte count is worse in that context. */ export function printList(format: OutputFormat, rows: T[], columns: Column[]): void { - if (format === 'json') { - console.log(JSON.stringify(rows, null, 2)) + const machine = renderMachine(format, rows) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const row of rows) { + console.log(columns.map((column) => stripAnsi(column.value(row))).join('\t')) + } return } + console.log(renderTable(rows, columns)) } -/** Prints a single record: JSON as-is, table format as aligned key/value lines. */ +/** Prints a single record: machine formats from the raw value, otherwise aligned lines. */ export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { - if (format === 'json') { - console.log(JSON.stringify(raw, null, 2)) + const machine = renderMachine(format, raw) + if (machine !== null) { + console.log(machine) + return + } + + if (format === 'text') { + for (const [label, value] of fields) { + console.log(`${label}\t${stripAnsi(value)}`) + } return } From e34372b4b13f0b04f437f459745909819aacd7e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:30:40 -0700 Subject: [PATCH 09/28] refactor(cli): output format is a profile setting, not a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops `-o, --output`. Format is set once per profile with `sim configure --set-output `, or overridden ambiently with SIM_OUTPUT for a one-off (`SIM_OUTPUT=json sim logs list | jq`) and for CI, which already runs file-less on env alone. Both remaining sources are ambient — set once, then read by every later command — so an unrecognized value falls back to `table` rather than breaking the CLI. There is no longer a strict tier, because there is no longer anything typed per-invocation to be strict about. Frees `-o` for `sim files download -o `, which previously had to share the short flag with a global that meant something else entirely. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/README.md | 21 +++++++++++++-------- packages/sim-cli/src/config/profile.test.ts | 17 +++++++++++++++-- packages/sim-cli/src/config/profile.ts | 7 +++++-- packages/sim-cli/src/context.ts | 2 -- packages/sim-cli/src/index.ts | 14 ++------------ 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 25ed1d82f53..bdfe45410da 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -53,7 +53,7 @@ Each setting resolves independently, first match wins: | Rank | Source | | --- | --- | -| 1 | Command-line flag (`--endpoint`, `--workspace`, `--output`) | +| 1 | Command-line flag (`--endpoint`, `--workspace`) | | 2 | Environment (`SIM_ENDPOINT`, `SIM_API_KEY`, `SIM_WORKSPACE`, `SIM_OUTPUT`) | | 3 | `~/.sim/config` / `~/.sim/credentials` for the selected profile | | 4 | Built-in default (`https://sim.ai`, `table`) | @@ -154,7 +154,9 @@ everything" default. ### Output formats -`--output` / `-o`, or `SIM_OUTPUT`, or `output =` in the profile: +Output format is a **profile setting**, not a per-command flag — there is no +`--output`. Set it once with `sim configure --set-output `, or override +ambiently with `SIM_OUTPUT` for a one-off or for CI: | Format | For | | --- | --- | @@ -169,10 +171,13 @@ duration stays `1500`, not `"1.5s"` — so switching format never changes the da parsing. ```bash -sim logs list --level error -o json | jq -r '.[].executionId' -sim logs list --level error -o yaml > logs.yaml +sim configure --set-output json # for this profile, from now on +sim configure --set-output text --profile scripts # a profile dedicated to scripting -sim files list -o text | while IFS=$'\t' read -r id name size type uploaded; do +SIM_OUTPUT=json sim logs list --level error | jq -r '.[].executionId' +SIM_OUTPUT=yaml sim logs list --level error > logs.yaml + +SIM_OUTPUT=text sim files list | while IFS=$'\t' read -r id name size type uploaded; do echo "$id $name" done ``` @@ -180,9 +185,9 @@ done An absent value is an em-dash in `table` and an **empty field** in `text`, so emptiness tests downstream behave. -A bad `--output` is an error; a bad `SIM_OUTPUT` or `output =` is ignored and -falls back to `table` — ambient settings should not brick every command, but a -flag you just typed should not be silently disregarded. +A bad `SIM_OUTPUT` or `output =` is ignored and falls back to `table`. Both are +ambient — set once, then read by every later command — so one bad value should +not break the CLI outright. ## How this stays in sync with the API diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index fb18c536ab6..48661750b7c 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -97,10 +97,23 @@ describe('profile resolution', () => { }) it('ignores an unrecognized output format instead of failing the whole resolve', () => { - // Ambient sources tolerate garbage so one bad value cannot brick every - // command; the `--output` flag is strict instead (commander `.choices`). + // Both output sources are ambient — set once, then every later command reads + // them — so a bad value falls back rather than breaking the CLI outright. process.env.SIM_OUTPUT = 'xml' expect(resolveProfile().output).toBe('table') + + process.env.SIM_OUTPUT = undefined + writeConfigProfile('default', { output: 'xml' }) + expect(resolveProfile().output).toBe('table') + }) + + it('takes the output format from the profile, and lets the env override it', () => { + // There is deliberately no `--output` flag: format is a profile setting. + writeConfigProfile('default', { output: 'yaml' }) + expect(resolveProfile()).toMatchObject({ output: 'yaml', sources: { output: 'config' } }) + + process.env.SIM_OUTPUT = 'json' + expect(resolveProfile()).toMatchObject({ output: 'json', sources: { output: 'env' } }) }) it('accepts every documented output format from the environment', () => { diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index d2e5e85c683..48fca121498 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -47,7 +47,6 @@ export interface ProfileOverrides { endpoint?: string apiKey?: string workspaceId?: string - output?: string } /** @@ -186,9 +185,13 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil 'unset' ) + /** + * No flag tier: output format is a profile setting, not a per-command one. + * `SIM_OUTPUT` stays as the one-off escape hatch (`SIM_OUTPUT=json sim … | jq`) + * and as the file-less path for CI, but there is deliberately no `--output`. + */ const output = resolve( [ - ['flag', parseOutput(overrides.output)], ['env', parseOutput(process.env.SIM_OUTPUT)], ['config', parseOutput(config.output)], ], diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index 9e706baa404..7486100815f 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -7,7 +7,6 @@ export interface GlobalOptions { profile?: string endpoint?: string workspace?: string - output?: string } /** @@ -25,7 +24,6 @@ export function profileFrom(command: Command, extra: ProfileOverrides = {}): Res profile: globals.profile, endpoint: globals.endpoint, workspaceId: globals.workspace, - output: globals.output, ...extra, }) } diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 10a1fb7c191..84daf9104db 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import chalk from 'chalk' -import { Command, Option } from 'commander' +import { Command } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' import { filesCommand } from './commands/files.js' @@ -9,7 +9,6 @@ import { knowledgeCommand } from './commands/knowledge.js' import { logsCommand } from './commands/logs.js' import { tablesCommand } from './commands/tables.js' import { workflowsCommand } from './commands/workflows.js' -import { OUTPUT_FORMATS } from './config/index.js' import { SimApiError } from './http/client.js' const program = new Command() @@ -21,16 +20,6 @@ program .option('-p, --profile ', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint ', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace ', 'Workspace to target (env: SIM_WORKSPACE)') - // `.choices` so a typo'd format is an error, not a silent fall back to - // `table`. Deliberately stricter than SIM_OUTPUT and the config file, which - // tolerate an unknown value: those are ambient and set once, and a bad one - // should not make every command fail — but a flag is an instruction just - // typed, so honouring something else is a lie. - .addOption( - new Option('-o, --output ', 'Output format (env: SIM_OUTPUT)').choices([ - ...OUTPUT_FORMATS, - ]) - ) program.addCommand(loginCommand()) program.addCommand(logoutCommand()) @@ -54,6 +43,7 @@ Examples: $ sim login --profile dev --endpoint http://localhost:3000 $ sim workflows list $ sim logs list --level error --limit 20 + $ sim configure --set-output json Output format is a profile setting $ sim knowledge search "refund policy" --kb kb_123 $ sim whoami --profile dev ` From 3e423bab386e620c19ca10c105e4dd70d366a17e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 18:40:42 -0700 Subject: [PATCH 10/28] feat(cli): runtime that builds every command from the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the CLI contract into working commands. 43 leaves across 7 groups, up from the 6 hand-written ones — every v2 operation the contract does not hide is now reachable, including `sim tables upsert`, `sim workflows run`, and the whole tables surface. ## What the generator now emits `V2_OPERATIONS` carries a field→slot map per operation: each query/body field's kind, whether it is required, its enum values, and its server-side default. Types alone could not drive this — the runtime has to *iterate* fields to build flags, and everything from argv arrives as a string, so it needs the kind to turn "50" into 50 and '{"a":1}' into an object. It also lifts each operation's one-line `summary` from the OpenAPI specs. The contracts carry validation, not prose, so `--help` had been showing raw URLs; the specs already hold a written summary per operation and `check:openapi` guarantees one exists, so this reuses documentation rather than inventing a second place to describe the same endpoint. ## The runtime `derive.ts` names a command ` [sub-resource] ` from the route, covering 41 of 47. `request.ts` assembles the call: path params from positional args, `workspaceId` injected from the profile into whichever slot declares it, everything else coerced and validated locally — so a bad enum, malformed JSON, missing required flag, or absent workspace fails before any network call. `build.ts` constructs the commander tree, auto-pages cursor lists up to `--limit` (0 for everything), and renders through the contract's columns or, for runtime-shaped rows, keys unioned across the page. Fixed while wiring: `new Command('upsert ')` makes the *whole string* the command name, so `sim tables upsert` never matched and fell through to the group's help. Arguments have to be declared with `.argument()`. ## What stays hand-written Two leaves, each for a reason generation cannot satisfy in principle: `files download` streams binary rather than the JSON envelope, and `tables rows list` discovers columns from user-defined row data nested under `data`. They attach onto the generated groups, so `sim files --help` lists them alongside the rest. The five previous command files are deleted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/commands/files.ts | 122 ------- packages/sim-cli/src/commands/hand-written.ts | 152 +++++++++ packages/sim-cli/src/commands/knowledge.ts | 142 -------- packages/sim-cli/src/commands/logs.ts | 138 -------- packages/sim-cli/src/commands/tables.ts | 262 -------------- packages/sim-cli/src/commands/workflows.ts | 133 -------- packages/sim-cli/src/generated/v2-api.ts | 323 +++++++++++++++++- packages/sim-cli/src/index.ts | 30 +- packages/sim-cli/src/runtime/build.ts | 298 ++++++++++++++++ packages/sim-cli/src/runtime/derive.ts | 58 ++++ packages/sim-cli/src/runtime/request.test.ts | 110 ++++++ packages/sim-cli/src/runtime/request.ts | 164 +++++++++ scripts/generate-v2-cli-api.ts | 150 +++++++- 13 files changed, 1264 insertions(+), 818 deletions(-) delete mode 100644 packages/sim-cli/src/commands/files.ts create mode 100644 packages/sim-cli/src/commands/hand-written.ts delete mode 100644 packages/sim-cli/src/commands/knowledge.ts delete mode 100644 packages/sim-cli/src/commands/logs.ts delete mode 100644 packages/sim-cli/src/commands/tables.ts delete mode 100644 packages/sim-cli/src/commands/workflows.ts create mode 100644 packages/sim-cli/src/runtime/build.ts create mode 100644 packages/sim-cli/src/runtime/derive.ts create mode 100644 packages/sim-cli/src/runtime/request.test.ts create mode 100644 packages/sim-cli/src/runtime/request.ts diff --git a/packages/sim-cli/src/commands/files.ts b/packages/sim-cli/src/commands/files.ts deleted file mode 100644 index 3433d8b89c2..00000000000 --- a/packages/sim-cli/src/commands/files.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { once } from 'node:events' -import { createWriteStream, type WriteStream } from 'node:fs' -import { basename } from 'node:path' -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { ListFilesResponse } from '../generated/v2-api.js' -import { SimApiError } from '../http/client.js' -import { bytes, type Column, printList, timestamp } from '../output/render.js' - -type WorkspaceFile = ListFilesResponse['data'][number] - -/** - * Streams a fetch body to disk, honouring backpressure. - * - * Written as an explicit reader loop rather than `Readable.fromWeb`: the DOM - * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares - * are structurally incompatible under this TS config, and bridging them needs a - * cast that would erase exactly the typing this loop keeps honest. - */ -async function streamToFile(body: ReadableStream, file: WriteStream): Promise { - const reader = body.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - // `write` returning false means the internal buffer is full; waiting for - // `drain` is what stops a large file from being buffered in memory. - if (!file.write(value)) await once(file, 'drain') - } - } finally { - reader.releaseLock() - } - - await new Promise((resolve, reject) => { - file.once('error', reject) - file.end(resolve) - }) -} - -const LIST_COLUMNS: Column[] = [ - { header: 'id', value: (file) => file.id }, - { header: 'name', value: (file) => file.name }, - { header: 'size', value: (file) => bytes(file.size) }, - { header: 'type', value: (file) => file.type }, - { header: 'uploaded', value: (file) => timestamp(file.uploadedAt) }, -] - -export function filesCommand(): Command { - const files = new Command('files').alias('file').description('List and download workspace files') - - files - .command('list') - .alias('ls') - .description('List files in a workspace') - .option('--limit ', 'Maximum files to return', '100') - .action(async (options: { limit: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - '/api/v2/files', - { query: { workspaceId: client.requireWorkspace(), limit: Math.min(limit, 1000) } }, - limit - ) - - printList(profile.output, rows, LIST_COLUMNS) - }) - - files - .command('download ') - .description('Download a file') - .option('-o, --output-file ', 'Where to write it (defaults to the file name)') - .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - - // Streamed rather than routed through the JSON client: the response is - // binary of unbounded size, so buffering it just to write it out would put - // the whole file in memory. - const url = new URL(`${profile.endpoint}/api/v2/files/${fileId}`) - url.searchParams.set('workspaceId', workspaceId) - - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status - ) - } - - const target = - options.outputFile ?? - basename( - // `filename="…"` from the route's content-disposition, when present. - /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? - fileId - ) - - await streamToFile(response.body, createWriteStream(target)) - console.log(chalk.green(`✓ Saved ${target}`)) - }) - - files - .command('delete ') - .description('Archive a file') - .action(async (fileId: string, _options: unknown, command: Command) => { - const { client } = clientFrom(command) - await client.getData(`/api/v2/files/${fileId}`, { - method: 'DELETE', - query: { workspaceId: client.requireWorkspace() }, - }) - console.log(chalk.green(`✓ Deleted ${fileId}`)) - }) - - return files -} diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts new file mode 100644 index 00000000000..1a107c96cf1 --- /dev/null +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -0,0 +1,152 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { basename } from 'node:path' +import chalk from 'chalk' +import type { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { QueryRowsResponse } from '../generated/v2-api.js' +import { SimApiError } from '../http/client.js' +import { type Column, printList, text } from '../output/render.js' + +/** + * Commands the generated runtime cannot produce. + * + * Kept deliberately small — each entry needs a reason that generation could not + * satisfy even in principle, not merely "not migrated yet". They attach onto the + * groups the runtime already built, so `sim files --help` lists them alongside + * the generated leaves rather than in a second group. + */ + +type Row = QueryRowsResponse['data'][number] + +/** + * Streams a fetch body to disk, honouring backpressure. + * + * An explicit reader loop rather than `Readable.fromWeb`: the DOM + * `ReadableStream` that `fetch` returns and the one `node:stream/web` declares + * are structurally incompatible under this TS config, and bridging them needs a + * cast that would erase exactly the typing this keeps honest. + */ +async function streamToFile(body: ReadableStream, file: WriteStream): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the buffer is full; waiting for `drain` is + // what stops a large file being buffered entirely in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve, reject) => { + file.once('error', reject) + file.end(resolve) + }) +} + +/** + * Row `data` is name-keyed and user-defined, so columns exist only at runtime. + * Keys are unioned across the page rather than read off the first row — a + * sparse row would otherwise hide every column it happens to omit. + */ +function rowColumns(rows: Row[]): Column[] { + const keys: string[] = [] + const seen = new Set() + for (const row of rows) { + for (const key of Object.keys(row.data)) { + if (seen.has(key)) continue + seen.add(key) + keys.push(key) + } + } + + return [ + { header: 'id', value: (row) => row.id }, + ...keys.map((key) => ({ + header: key, + value: (row: Row) => { + const value = row.data[key] + if (value === null || value === undefined) return text(null) + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }, + })), + ] +} + +function group(program: Command, name: string): Command { + const existing = program.commands.find((command) => command.name() === name) + if (existing) return existing + const created = program.command(name) + return created +} + +export function attachHandWritten(program: Command): void { + // ── files download ── the response is binary, not the JSON envelope ──────── + group(program, 'files') + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + await streamToFile(response.body, createWriteStream(target)) + console.log(chalk.green(`✓ Saved ${target}`)) + }) + + // ── tables rows list ── columns come from user-defined row data ─────────── + const tables = group(program, 'tables') + const rows = + tables.commands.find((command) => command.name() === 'rows') ?? tables.command('rows') + rows + .command('list ') + .description('List rows, with columns discovered from the data') + .option('--limit ', 'Maximum rows to return (0 for everything)', '100') + .action(async (tableId: string, options: { limit: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const parsed = Number.parseInt(options.limit, 10) + if (Number.isNaN(parsed) || parsed < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + const limit = parsed === 0 ? Number.POSITIVE_INFINITY : parsed + + const collected: Row[] = [] + let cursor: string | null = null + do { + const page = (await client.request(`/api/v2/tables/${encodeURIComponent(tableId)}/rows`, { + query: { workspaceId: client.requireWorkspace(), cursor }, + })) as QueryRowsResponse + collected.push(...page.data) + cursor = page.nextCursor + } while (cursor && collected.length < limit) + + const page = Number.isFinite(limit) ? collected.slice(0, limit) : collected + printList(profile.output, page, rowColumns(page)) + }) +} diff --git a/packages/sim-cli/src/commands/knowledge.ts b/packages/sim-cli/src/commands/knowledge.ts deleted file mode 100644 index 130a7e02394..00000000000 --- a/packages/sim-cli/src/commands/knowledge.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { - ListKnowledgeBasesResponse, - ListKnowledgeDocumentsResponse, - SearchKnowledgeResponse, -} from '../generated/v2-api.js' -import { bytes, type Column, printList, printRecord, text, timestamp } from '../output/render.js' - -type KnowledgeBase = ListKnowledgeBasesResponse['data'][number] -type KnowledgeDocument = ListKnowledgeDocumentsResponse['data'][number] -type SearchHit = SearchKnowledgeResponse['data']['results'][number] - -const BASE_COLUMNS: Column[] = [ - { header: 'id', value: (kb) => kb.id }, - { header: 'name', value: (kb) => kb.name }, - { header: 'docs', value: (kb) => String(kb.docCount) }, - { header: 'tokens', value: (kb) => String(kb.tokenCount) }, - { header: 'model', value: (kb) => kb.embeddingModel }, -] - -const DOCUMENT_COLUMNS: Column[] = [ - { header: 'id', value: (doc) => doc.id }, - { header: 'filename', value: (doc) => doc.filename }, - { header: 'size', value: (doc) => bytes(doc.fileSize) }, - { header: 'status', value: (doc) => doc.processingStatus }, - { header: 'chunks', value: (doc) => String(doc.chunkCount) }, - { header: 'created', value: (doc) => timestamp(doc.createdAt) }, -] - -/** Search hits are long prose; keep the table readable and single-line. */ -function preview(content: string): string { - const collapsed = content.replace(/\s+/g, ' ').trim() - return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` -} - -export function knowledgeCommand(): Command { - const knowledge = new Command('knowledge') - .alias('kb') - .description('Browse and search knowledge bases') - - knowledge - .command('list') - .alias('ls') - .description('List knowledge bases in a workspace') - .action(async (_options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const page = await client.getPage('/api/v2/knowledge', { - query: { workspaceId: client.requireWorkspace() }, - }) - printList(profile.output, page.data, BASE_COLUMNS) - }) - - knowledge - .command('get ') - .description('Show one knowledge base') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const { knowledgeBase } = await client.getData<{ knowledgeBase: KnowledgeBase }>( - `/api/v2/knowledge/${id}`, - { query: { workspaceId: client.requireWorkspace() } } - ) - - printRecord( - profile.output, - [ - ['ID', knowledgeBase.id], - ['Name', knowledgeBase.name], - ['Description', text(knowledgeBase.description)], - ['Documents', String(knowledgeBase.docCount)], - ['Tokens', String(knowledgeBase.tokenCount)], - ['Embedding model', knowledgeBase.embeddingModel], - ['Updated', timestamp(knowledgeBase.updatedAt)], - ], - knowledgeBase - ) - }) - - knowledge - .command('documents ') - .alias('docs') - .description('List the documents in a knowledge base') - .option('--search ', 'Filter by filename') - .option('--status ', 'Filter by enabled state: all, enabled, or disabled', 'all') - .option('--limit ', 'Maximum documents to return', '50') - .action( - async ( - id: string, - options: { search?: string; status: string; limit: string }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - `/api/v2/knowledge/${id}/documents`, - { - query: { - workspaceId: client.requireWorkspace(), - search: options.search, - enabledFilter: options.status, - limit: Math.min(limit, 100), - }, - }, - limit - ) - - printList(profile.output, rows, DOCUMENT_COLUMNS) - } - ) - - knowledge - .command('search ') - .description('Vector-search one or more knowledge bases') - .requiredOption('--kb ', 'Knowledge base ids to search') - .option('--top-k ', 'Number of hits to return', '10') - .action(async (query: string, options: { kb: string[]; topK: string }, command: Command) => { - const { client, profile } = clientFrom(command) - - const result = await client.getData<{ results: SearchHit[]; totalResults: number }>( - '/api/v2/knowledge/search', - { - method: 'POST', - body: { - workspaceId: client.requireWorkspace(), - knowledgeBaseIds: options.kb, - query, - topK: Number.parseInt(options.topK, 10), - }, - } - ) - - printList(profile.output, result.results, [ - { header: 'score', value: (hit) => hit.similarity.toFixed(3) }, - { header: 'document', value: (hit) => text(hit.documentName ?? hit.documentId) }, - { header: 'chunk', value: (hit) => String(hit.chunkIndex) }, - { header: 'content', value: (hit) => preview(hit.content) }, - ]) - }) - - return knowledge -} diff --git a/packages/sim-cli/src/commands/logs.ts b/packages/sim-cli/src/commands/logs.ts deleted file mode 100644 index 47525e925c5..00000000000 --- a/packages/sim-cli/src/commands/logs.ts +++ /dev/null @@ -1,138 +0,0 @@ -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { GetExecutionResponse, GetLogResponse, ListLogsResponse } from '../generated/v2-api.js' -import { type Column, duration, printList, printRecord, text, timestamp } from '../output/render.js' - -type LogListItem = ListLogsResponse['data'][number] -type LogDetail = GetLogResponse['data'] -type ExecutionDetail = GetExecutionResponse['data'] - -function level(value: string): string { - return value === 'error' ? chalk.red(value) : value -} - -function cost(value: { total: number } | null): string { - return value ? `$${value.total.toFixed(4)}` : text(null) -} - -const LIST_COLUMNS: Column[] = [ - { header: 'started', value: (log) => timestamp(log.startedAt) }, - { header: 'level', value: (log) => level(log.level) }, - { header: 'trigger', value: (log) => log.trigger }, - { header: 'workflow', value: (log) => text(log.workflow?.name ?? log.workflowId) }, - { header: 'duration', value: (log) => duration(log.totalDurationMs) }, - { header: 'cost', value: (log) => cost(log.cost) }, - { header: 'execution', value: (log) => log.executionId }, -] - -export function logsCommand(): Command { - const logs = new Command('logs').alias('log').description('Read workflow execution logs') - - logs - .command('list') - .alias('ls') - .description('List execution logs in a workspace') - .option('--workflow ', 'Restrict to these workflow ids') - .option('--trigger ', 'Restrict to these triggers (api, schedule, webhook, manual…)') - .option('--level ', 'Filter by level: info or error') - .option('--execution ', 'Restrict to a single execution id') - .option('--start ', 'Only runs starting at or after this ISO date') - .option('--end ', 'Only runs starting at or before this ISO date') - .option('--order ', 'Sort by start time: desc or asc', 'desc') - .option('--limit ', 'Maximum logs to return', '50') - .action( - async ( - options: { - workflow?: string[] - trigger?: string[] - level?: string - execution?: string - start?: string - end?: string - order: string - limit: string - }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - '/api/v2/logs', - { - query: { - workspaceId: client.requireWorkspace(), - // The route takes these as comma-joined strings, not repeated params. - workflowIds: options.workflow?.join(','), - triggers: options.trigger?.join(','), - level: options.level, - executionId: options.execution, - startDate: options.start, - endDate: options.end, - order: options.order, - details: 'full', - limit: Math.min(limit, 1000), - }, - }, - limit - ) - - printList(profile.output, rows, LIST_COLUMNS) - } - ) - - logs - .command('get ') - .description('Show one log, including its execution trace') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const log = await client.getData(`/api/v2/logs/${id}`) - - printRecord( - profile.output, - [ - ['ID', log.id], - ['Execution', log.executionId], - ['Workflow', text(log.workflow?.name ?? log.workflowId)], - ['Level', level(log.level)], - ['Trigger', log.trigger], - ['Started', timestamp(log.startedAt)], - ['Ended', timestamp(log.endedAt)], - ['Duration', duration(log.totalDurationMs)], - ['Cost', cost(log.cost)], - ], - log - ) - - if (profile.output === 'table') { - console.log(chalk.dim('\nRun with --output json to see the full execution trace.')) - } - }) - - logs - .command('execution ') - .description('Show the workflow state snapshot for an execution') - .action(async (executionId: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const execution = await client.getData( - `/api/v2/logs/executions/${executionId}` - ) - - printRecord( - profile.output, - [ - ['Execution', execution.executionId], - ['Workflow', text(execution.workflowId)], - ['Trigger', execution.executionMetadata.trigger], - ['Started', timestamp(execution.executionMetadata.startedAt)], - ['Ended', timestamp(execution.executionMetadata.endedAt)], - ['Duration', duration(execution.executionMetadata.totalDurationMs)], - ['Cost', cost(execution.executionMetadata.cost)], - ], - execution - ) - }) - - return logs -} diff --git a/packages/sim-cli/src/commands/tables.ts b/packages/sim-cli/src/commands/tables.ts deleted file mode 100644 index 9362d7ded17..00000000000 --- a/packages/sim-cli/src/commands/tables.ts +++ /dev/null @@ -1,262 +0,0 @@ -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { - CreateTableRowsResponse, - DeleteTableRowsResponse, - GetTableResponse, - ListTablesResponse, - QueryRowsResponse, -} from '../generated/v2-api.js' -import { SimApiError } from '../http/client.js' -import { type Column, printList, printRecord, text, timestamp } from '../output/render.js' - -type Table = ListTablesResponse['data'][number] -type TableColumn = Table['schema']['columns'][number] -type Row = QueryRowsResponse['data'][number] - -const TABLE_COLUMNS: Column
[] = [ - { header: 'id', value: (t) => t.id }, - { header: 'name', value: (t) => t.name }, - { header: 'rows', value: (t) => `${t.rowCount}${t.maxRows ? ` / ${t.maxRows}` : ''}` }, - { header: 'columns', value: (t) => String(t.schema.columns.length) }, - { header: 'updated', value: (t) => timestamp(t.updatedAt) }, -] - -const COLUMN_COLUMNS: Column[] = [ - { header: 'name', value: (c) => c.name }, - { header: 'type', value: (c) => c.type }, - { header: 'required', value: (c) => (c.required ? 'yes' : '') }, - { header: 'unique', value: (c) => (c.unique ? 'yes' : '') }, - { header: 'options', value: (c) => (c.options ?? []).map((o) => o.name).join(', ') }, -] - -/** - * Parses a `--filter` / `--data` argument. - * - * The predicate grammar is a nested object (`{all|any: [{field, op, value}]}`), - * which has no honest flag encoding — so it is passed as JSON and the parse - * error names the flag rather than surfacing a bare `SyntaxError`. - */ -function parseJsonArg(value: string, flag: string): unknown { - try { - return JSON.parse(value) - } catch (error) { - throw new SimApiError(`${flag} must be valid JSON: ${(error as Error).message}`, 0) - } -} - -/** `name:desc` / `name` → the wire sort spec. */ -function parseSort(specs: string[]): Array<{ field: string; direction: 'asc' | 'desc' }> { - return specs.map((spec) => { - const [field, direction = 'asc'] = spec.split(':') - if (direction !== 'asc' && direction !== 'desc') { - throw new SimApiError(`Sort direction must be asc or desc, got "${direction}"`, 0) - } - if (!field) throw new SimApiError(`Invalid --sort value "${spec}"`, 0) - return { field, direction } - }) -} - -/** - * Row `data` is name-keyed and user-defined, so the columns are only known at - * runtime. Union the keys across the page rather than trusting the first row — - * a sparse row would otherwise hide every column it happens to omit. - */ -function rowColumns(rows: Row[]): Column[] { - const keys: string[] = [] - const seen = new Set() - for (const row of rows) { - for (const key of Object.keys(row.data)) { - if (!seen.has(key)) { - seen.add(key) - keys.push(key) - } - } - } - - return [ - { header: 'id', value: (row) => row.id }, - ...keys.map((key) => ({ - header: key, - value: (row: Row) => { - const value = row.data[key] - if (value === null || value === undefined) return text(null) - return typeof value === 'object' ? JSON.stringify(value) : String(value) - }, - })), - ] -} - -export function tablesCommand(): Command { - const tables = new Command('tables').alias('table').description('Browse and edit tables') - - tables - .command('list') - .alias('ls') - .description('List tables in a workspace') - .action(async (_options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('listTables', { - query: { workspaceId: client.requireWorkspace() }, - })) as ListTablesResponse - printList(profile.output, result.data, TABLE_COLUMNS) - }) - - tables - .command('get ') - .description('Show a table and its schema') - .action(async (tableId: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('getTable', { - pathParams: { tableId }, - query: { workspaceId: client.requireWorkspace() }, - })) as GetTableResponse - const { table } = result.data - - printRecord( - profile.output, - [ - ['ID', table.id], - ['Name', table.name], - ['Description', text(table.description)], - ['Rows', `${table.rowCount}${table.maxRows ? ` / ${table.maxRows}` : ''}`], - ['Columns', table.schema.columns.map((c) => `${c.name}:${c.type}`).join(', ')], - ['Updated', timestamp(table.updatedAt)], - ], - table - ) - }) - - tables - .command('columns ') - .description("Show a table's columns") - .action(async (tableId: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('getTable', { - pathParams: { tableId }, - query: { workspaceId: client.requireWorkspace() }, - })) as GetTableResponse - printList(profile.output, result.data.table.schema.columns, COLUMN_COLUMNS) - }) - - tables - .command('rows ') - .description('List rows, optionally filtered with the predicate grammar') - .option( - '--filter ', - 'Predicate tree, e.g. \'{"all":[{"field":"status","op":"eq","value":"open"}]}\'' - ) - .option('--sort ', 'Sort spec, e.g. --sort created_at:desc') - .option('--limit ', 'Maximum rows to return', '100') - .action( - async ( - tableId: string, - options: { filter?: string; sort?: string[]; limit: string }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const limit = Number.parseInt(options.limit, 10) - - const rows: Row[] = [] - let cursor: string | null = null - - // Always the POST query endpoint, even unfiltered: it is the only shape - // that carries the predicate, so one path covers both cases instead of - // two that could format rows differently. - do { - const page = (await client.call('queryRows', { - pathParams: { tableId }, - body: { - workspaceId, - ...(options.filter ? { predicate: parseJsonArg(options.filter, '--filter') } : {}), - ...(options.sort ? { sort: parseSort(options.sort) } : {}), - limit: Math.min(limit, 1000), - ...(cursor ? { cursor } : {}), - }, - })) as QueryRowsResponse - rows.push(...page.data) - cursor = page.nextCursor - } while (cursor && rows.length < limit) - - printList(profile.output, rows.slice(0, limit), rowColumns(rows)) - } - ) - - tables - .command('insert ') - .description('Insert a row') - .requiredOption('--data ', 'Row data, e.g. \'{"name":"Ada","score":9}\'') - .action(async (tableId: string, options: { data: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const result = (await client.call('createTableRows', { - pathParams: { tableId }, - body: { - workspaceId: client.requireWorkspace(), - data: parseJsonArg(options.data, '--data'), - }, - })) as CreateTableRowsResponse - - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - const inserted = 'row' in result.data ? 1 : result.data.rows.length - console.log(chalk.green(`✓ Inserted ${inserted} row${inserted === 1 ? '' : 's'}`)) - }) - - tables - .command('delete-rows ') - .description('Delete rows by id or filter') - .option('--row ', 'Row ids to delete') - .option('--filter ', 'Predicate tree selecting the rows to delete') - .option('-y, --yes', 'Skip the confirmation') - .action( - async ( - tableId: string, - options: { row?: string[]; filter?: string; yes?: boolean }, - command: Command - ) => { - const { client, profile } = clientFrom(command) - - if (!options.row && !options.filter) { - // Without this, an argument-less call would delete the whole table. - throw new SimApiError( - 'Pass --row or --filter to choose what to delete.', - 0 - ) - } - - if (!options.yes) { - const target = options.row - ? `${options.row.length} row${options.row.length === 1 ? '' : 's'}` - : 'every row matching the filter' - throw new SimApiError( - `This deletes ${target} from ${tableId} and cannot be undone. Re-run with --yes to confirm.`, - 0 - ) - } - - const result = (await client.call('deleteTableRows', { - pathParams: { tableId }, - body: { - workspaceId: client.requireWorkspace(), - ...(options.row ? { rowIds: options.row } : {}), - ...(options.filter ? { filter: parseJsonArg(options.filter, '--filter') } : {}), - }, - })) as DeleteTableRowsResponse - - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Deleted ${result.data.deletedCount} row(s)`)) - if (result.data.missingRowIds?.length) { - console.log(chalk.dim(` Not found: ${result.data.missingRowIds.join(', ')}`)) - } - } - ) - - return tables -} diff --git a/packages/sim-cli/src/commands/workflows.ts b/packages/sim-cli/src/commands/workflows.ts deleted file mode 100644 index fcedfd790d0..00000000000 --- a/packages/sim-cli/src/commands/workflows.ts +++ /dev/null @@ -1,133 +0,0 @@ -import chalk from 'chalk' -import { Command } from 'commander' -import { clientFrom } from '../context.js' -import type { GetWorkflowResponse, ListWorkflowsResponse } from '../generated/v2-api.js' -import { bool, type Column, printList, printRecord, text, timestamp } from '../output/render.js' - -type WorkflowListItem = ListWorkflowsResponse['data'][number] -type WorkflowDetail = GetWorkflowResponse['data'] - -const LIST_COLUMNS: Column[] = [ - { header: 'id', value: (w) => w.id }, - { header: 'name', value: (w) => w.name }, - { header: 'deployed', value: (w) => bool(w.isDeployed) }, - { header: 'runs', value: (w) => String(w.runCount) }, - { header: 'last run', value: (w) => timestamp(w.lastRunAt) }, -] - -export function workflowsCommand(): Command { - const workflows = new Command('workflows') - .alias('workflow') - .description('List and manage workflows') - - workflows - .command('list') - .alias('ls') - .description('List workflows in a workspace') - .option('--folder ', 'Only workflows in this folder') - .option('--deployed', 'Only deployed workflows') - .option('--limit ', 'Maximum workflows to return', '50') - .action( - async (options: { folder?: string; deployed?: boolean; limit: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const limit = Number.parseInt(options.limit, 10) - - const rows = await client.collect( - '/api/v2/workflows', - { - query: { - workspaceId: client.requireWorkspace(), - folderId: options.folder, - deployedOnly: options.deployed ? 'true' : undefined, - // The route caps a page at 100; `collect` pages past that up to `limit`. - limit: Math.min(limit, 100), - }, - }, - limit - ) - - printList(profile.output, rows, LIST_COLUMNS) - } - ) - - workflows - .command('get ') - .description('Show one workflow, including its trigger inputs') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const workflow = await client.getData(`/api/v2/workflows/${id}`) - - printRecord( - profile.output, - [ - ['ID', workflow.id], - ['Name', workflow.name], - ['Description', text(workflow.description)], - ['Workspace', workflow.workspaceId], - ['Folder', text(workflow.folderId)], - ['Deployed', bool(workflow.isDeployed)], - ['Deployed at', timestamp(workflow.deployedAt)], - ['Runs', String(workflow.runCount)], - ['Last run', timestamp(workflow.lastRunAt)], - [ - 'Inputs', - workflow.inputs.length > 0 - ? workflow.inputs.map((input) => `${input.name}:${input.type}`).join(', ') - : text(null), - ], - ['Updated', timestamp(workflow.updatedAt)], - ], - workflow - ) - }) - - workflows - .command('deploy ') - .description('Deploy a workflow') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = await client.getData>( - `/api/v2/workflows/${id}/deploy`, - { method: 'POST' } - ) - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Deployed ${id}`)) - }) - - workflows - .command('undeploy ') - .description('Take a workflow out of deployment') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = await client.getData>( - `/api/v2/workflows/${id}/deploy`, - { method: 'DELETE' } - ) - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Undeployed ${id}`)) - }) - - workflows - .command('rollback ') - .description('Roll a deployed workflow back to its previous version') - .action(async (id: string, _options: unknown, command: Command) => { - const { client, profile } = clientFrom(command) - const result = await client.getData>( - `/api/v2/workflows/${id}/rollback`, - { method: 'POST' } - ) - if (profile.output === 'json') { - console.log(JSON.stringify(result, null, 2)) - return - } - console.log(chalk.green(`✓ Rolled back ${id}`)) - }) - - return workflows -} diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index f1bcb5ddd52..6223d5d3329 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -20,7 +20,7 @@ export type AddTableColumnBody = { column: { id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required?: boolean unique?: boolean position?: number @@ -29,6 +29,7 @@ export type AddTableColumnBody = { name: string }> multiple?: boolean + currencyCode?: string } } @@ -37,7 +38,7 @@ export type AddTableColumnResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -46,6 +47,7 @@ export type AddTableColumnResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } } @@ -122,7 +124,7 @@ export type CreateTableBody = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required?: boolean unique?: boolean workflowGroupId?: string @@ -131,6 +133,7 @@ export type CreateTableBody = { name: string }> multiple?: boolean + currencyCode?: string }> } workspaceId: string @@ -147,7 +150,7 @@ export type CreateTableResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -156,6 +159,7 @@ export type CreateTableResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } rowCount: number @@ -285,7 +289,7 @@ export type DeleteTableColumnResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -294,6 +298,7 @@ export type DeleteTableColumnResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } } @@ -724,7 +729,7 @@ export type GetTableResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -733,6 +738,7 @@ export type GetTableResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } rowCount: number @@ -1092,7 +1098,7 @@ export type ListTablesResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -1101,6 +1107,7 @@ export type ListTablesResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } rowCount: number @@ -1255,6 +1262,7 @@ export type SearchKnowledgeBody = { value: string | number | boolean valueTo?: string | number }> + searchMode?: 'vector' | 'hybrid' | null } export type SearchKnowledgeResponse = { @@ -1387,7 +1395,7 @@ export type UpdateTableColumnBody = { columnName: string updates: { name?: string - type?: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required?: boolean unique?: boolean options?: Array<{ @@ -1395,6 +1403,7 @@ export type UpdateTableColumnBody = { name: string }> multiple?: boolean + currencyCode?: string } } @@ -1403,7 +1412,7 @@ export type UpdateTableColumnResponse = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' required: boolean unique: boolean workflowGroupId?: string @@ -1412,6 +1421,7 @@ export type UpdateTableColumnResponse = { name: string }> multiple?: boolean + currencyCode?: unknown }> } } @@ -1505,289 +1515,582 @@ export type UpsertTableRowResponse = { } } -/** Every v2 operation, keyed by name. */ +/** + * Every v2 operation, keyed by name. + * + * `query` and `body` describe each field well enough for the CLI to build a + * flag for it and coerce the string argv gives back: its kind, whether it is + * required, its enum values, and its server-side default. A slot the contract + * does not declare — or one whose shape is a union with no flat field list — + * is absent, and the runtime falls back to taking it as JSON. + * + * `summary` is the operation's one-line description, lifted from the OpenAPI + * specs so `--help` reuses prose that is already written and already checked. + */ export const V2_OPERATIONS = { addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Add Column', + body: { + workspaceId: { kind: 'string', required: true }, + column: { kind: 'object', required: true }, + }, }, cancelWorkflowExecution: { method: 'POST', path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', pathParams: ['id', 'executionId'] as const, responseMode: 'json', + summary: 'Cancel an execution', }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', pathParams: [] as const, responseMode: 'json', + summary: 'Create Knowledge Base', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + }, }, createTable: { method: 'POST', path: '/api/v2/tables', pathParams: [] as const, responseMode: 'json', + summary: 'Create Table', + body: { + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + schema: { kind: 'object', required: true }, + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + }, }, createTableRows: { method: 'POST', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Create Rows', }, deleteFile: { method: 'DELETE', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, responseMode: 'json', + summary: 'Delete File', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteKnowledgeBase: { method: 'DELETE', path: '/api/v2/knowledge/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Delete Knowledge Base', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteKnowledgeDocument: { method: 'DELETE', path: '/api/v2/knowledge/[id]/documents/[documentId]', pathParams: ['id', 'documentId'] as const, responseMode: 'json', + summary: 'Delete Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteTable: { method: 'DELETE', path: '/api/v2/tables/[tableId]', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Delete Table', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteTableColumn: { method: 'DELETE', path: '/api/v2/tables/[tableId]/columns', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Delete Column', + body: { + workspaceId: { kind: 'string', required: true }, + columnName: { kind: 'string', required: true }, + }, }, deleteTableRow: { method: 'DELETE', path: '/api/v2/tables/[tableId]/rows/[rowId]', pathParams: ['tableId', 'rowId'] as const, responseMode: 'json', + summary: 'Delete Row', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, deleteTableRows: { method: 'DELETE', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Delete Rows', + body: { + workspaceId: { kind: 'string', required: true }, + filter: { kind: 'unknown' }, + limit: { kind: 'integer' }, + rowIds: { kind: 'array' }, + }, }, deployWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/deploy', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Deploy Workflow', }, downloadFile: { method: 'GET', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, responseMode: 'binary', + summary: 'Download File', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, executeWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/execute', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Execute a workflow', + body: { + input: { kind: 'object' }, + async: { kind: 'boolean', default: false }, + stream: { kind: 'boolean', default: false }, + selectedOutputs: { kind: 'array' }, + includeThinking: { kind: 'boolean', default: false }, + includeToolCalls: { kind: 'boolean', default: false }, + includeFileBase64: { kind: 'boolean' }, + base64MaxBytes: { kind: 'integer' }, + }, }, exportWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]/export', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Export a workflow', }, getAuditLog: { method: 'GET', path: '/api/v2/audit-logs/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Audit Log', }, getExecution: { method: 'GET', path: '/api/v2/logs/executions/[executionId]', pathParams: ['executionId'] as const, responseMode: 'json', + summary: 'Get Execution', }, getKnowledgeBase: { method: 'GET', path: '/api/v2/knowledge/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Knowledge Base', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getKnowledgeDocument: { method: 'GET', path: '/api/v2/knowledge/[id]/documents/[documentId]', pathParams: ['id', 'documentId'] as const, responseMode: 'json', + summary: 'Get Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getLog: { method: 'GET', path: '/api/v2/logs/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Log', }, getTable: { method: 'GET', path: '/api/v2/tables/[tableId]', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Get Table', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getTableRow: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]', pathParams: ['tableId', 'rowId'] as const, responseMode: 'json', + summary: 'Get Row', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, getUsageSummary: { method: 'GET', path: '/api/v2/billing/usage', pathParams: [] as const, responseMode: 'json', + summary: 'Get Usage Summary', + query: { + workspaceId: { kind: 'string' }, + }, }, getWorkflow: { method: 'GET', path: '/api/v2/workflows/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Get Workflow', }, getWorkflowExecution: { method: 'GET', path: '/api/v2/workflows/[id]/executions/[executionId]', pathParams: ['id', 'executionId'] as const, responseMode: 'json', + summary: 'Get execution status', + query: { + includeOutput: { kind: 'enum', values: ['true', 'false'] as const }, + selectedOutputs: { kind: 'string' }, + }, }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', pathParams: [] as const, responseMode: 'json', + summary: 'Import a workflow', + body: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + name: { kind: 'string' }, + description: { kind: 'string' }, + workflow: { kind: 'unknown', required: true }, + }, }, listAuditLogs: { method: 'GET', path: '/api/v2/audit-logs', pathParams: [] as const, responseMode: 'json', + summary: 'List Audit Logs', + query: { + action: { kind: 'string' }, + resourceType: { kind: 'string' }, + resourceId: { kind: 'string' }, + workspaceId: { kind: 'string' }, + actorId: { kind: 'string' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + includeDeparted: { kind: 'enum', values: ['true', 'false'] as const }, + limit: { kind: 'number', default: 50 }, + cursor: { kind: 'string' }, + }, }, listFiles: { method: 'GET', path: '/api/v2/files', pathParams: [] as const, responseMode: 'json', + summary: 'List Files', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + }, }, listKnowledgeBases: { method: 'GET', path: '/api/v2/knowledge', pathParams: [] as const, responseMode: 'json', + summary: 'List Knowledge Bases', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, listKnowledgeDocuments: { method: 'GET', path: '/api/v2/knowledge/[id]/documents', pathParams: ['id'] as const, responseMode: 'json', + summary: 'List Documents', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer', default: 50 }, + search: { kind: 'string' }, + enabledFilter: { + kind: 'enum', + values: ['all', 'enabled', 'disabled'] as const, + default: 'all', + }, + sortBy: { + kind: 'enum', + values: [ + 'filename', + 'fileSize', + 'tokenCount', + 'chunkCount', + 'uploadedAt', + 'processingStatus', + 'enabled', + ] as const, + default: 'uploadedAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + cursor: { kind: 'string' }, + }, }, listLogs: { method: 'GET', path: '/api/v2/logs', pathParams: [] as const, responseMode: 'json', + summary: 'List Logs', + query: { + workspaceId: { kind: 'string', required: true }, + workflowIds: { kind: 'string' }, + folderIds: { kind: 'string' }, + triggers: { kind: 'string' }, + level: { kind: 'enum', values: ['info', 'error'] as const }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + executionId: { kind: 'string' }, + minDurationMs: { kind: 'number' }, + maxDurationMs: { kind: 'number' }, + minCost: { kind: 'number' }, + maxCost: { kind: 'number' }, + model: { kind: 'string' }, + details: { kind: 'enum', values: ['basic', 'full'] as const, default: 'basic' }, + includeTraceSpans: { kind: 'boolean' }, + includeFinalOutput: { kind: 'boolean' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + }, }, listTableRows: { method: 'GET', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'List rows', + query: { + workspaceId: { kind: 'string', required: true }, + limit: { kind: 'integer' }, + cursor: { kind: 'string' }, + }, }, listTables: { method: 'GET', path: '/api/v2/tables', pathParams: [] as const, responseMode: 'json', + summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, listUsageLogs: { method: 'GET', path: '/api/v2/billing/usage/logs', pathParams: [] as const, responseMode: 'json', + summary: 'List Usage Logs', + query: { + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'copilot', + 'workspace-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + ] as const, + }, + workspaceId: { kind: 'string' }, + period: { + kind: 'enum', + values: ['1d', '7d', '30d', 'all', 'custom'] as const, + default: '30d', + }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, }, listWorkflows: { method: 'GET', path: '/api/v2/workflows', pathParams: [] as const, responseMode: 'json', + summary: 'List Workflows', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + deployedOnly: { kind: 'boolean' }, + limit: { kind: 'number', default: 50 }, + cursor: { kind: 'string' }, + }, }, queryRows: { method: 'POST', path: '/api/v2/tables/[tableId]/query', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Query Rows', + body: { + workspaceId: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + sort: { kind: 'array' }, + limit: { kind: 'integer' }, + cursor: { kind: 'string' }, + }, }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Rollback Workflow', }, searchKnowledge: { method: 'POST', path: '/api/v2/knowledge/search', pathParams: [] as const, responseMode: 'json', + summary: 'Search Knowledge', + body: { + workspaceId: { kind: 'string', required: true }, + knowledgeBaseIds: { kind: 'unknown', required: true }, + query: { kind: 'string' }, + topK: { kind: 'number', default: 10 }, + tagFilters: { kind: 'array' }, + searchMode: { kind: 'enum', default: 'vector' }, + }, }, undeployWorkflow: { method: 'DELETE', path: '/api/v2/workflows/[id]/deploy', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Undeploy Workflow', }, updateKnowledgeBase: { method: 'PUT', path: '/api/v2/knowledge/[id]', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Update Knowledge Base', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + chunkingConfig: { kind: 'object' }, + }, }, updateRowsByFilter: { method: 'PUT', path: '/api/v2/tables/[tableId]/rows', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Update Rows by Filter', + body: { + workspaceId: { kind: 'string', required: true }, + filter: { kind: 'unknown', required: true }, + data: { kind: 'unknown', required: true }, + limit: { kind: 'integer' }, + }, }, updateTableColumn: { method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Update Column', + body: { + workspaceId: { kind: 'string', required: true }, + columnName: { kind: 'string', required: true }, + updates: { kind: 'object', required: true }, + }, }, updateTableRow: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows/[rowId]', pathParams: ['tableId', 'rowId'] as const, responseMode: 'json', + summary: 'Update Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'unknown', required: true }, + }, }, uploadFile: { method: 'POST', path: '/api/v2/files', pathParams: [] as const, responseMode: 'json', + summary: 'Upload File', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, uploadKnowledgeDocument: { method: 'POST', path: '/api/v2/knowledge/[id]/documents', pathParams: ['id'] as const, responseMode: 'json', + summary: 'Upload Document', + query: { + workspaceId: { kind: 'string', required: true }, + }, }, upsertTableRow: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', pathParams: ['tableId'] as const, responseMode: 'json', + summary: 'Upsert Row', + body: { + workspaceId: { kind: 'string', required: true }, + data: { kind: 'unknown', required: true }, + conflictTarget: { kind: 'string' }, + }, }, } as const diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 84daf9104db..ab5728183bf 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -4,12 +4,9 @@ import chalk from 'chalk' import { Command } from 'commander' import { loginCommand, logoutCommand, profilesCommand, whoamiCommand } from './commands/auth.js' import { configureCommand } from './commands/configure.js' -import { filesCommand } from './commands/files.js' -import { knowledgeCommand } from './commands/knowledge.js' -import { logsCommand } from './commands/logs.js' -import { tablesCommand } from './commands/tables.js' -import { workflowsCommand } from './commands/workflows.js' +import { attachHandWritten } from './commands/hand-written.js' import { SimApiError } from './http/client.js' +import { buildGeneratedCommands } from './runtime/build.js' const program = new Command() @@ -26,11 +23,24 @@ program.addCommand(logoutCommand()) program.addCommand(whoamiCommand()) program.addCommand(profilesCommand()) program.addCommand(configureCommand()) -program.addCommand(workflowsCommand()) -program.addCommand(logsCommand()) -program.addCommand(tablesCommand()) -program.addCommand(filesCommand()) -program.addCommand(knowledgeCommand()) + +/** + * Leaves owned by hand-written commands, which the generated runtime skips. + * + * Each is here because generation genuinely cannot produce it, not because it + * has not been migrated: `files download` streams binary rather than JSON, and + * `tables rows list` discovers its columns from user-defined row data at + * runtime with a nested `data` object the generic renderer would flatten badly. + */ +const HAND_WRITTEN = new Set(['files download', 'tables rows list']) + +for (const command of buildGeneratedCommands(HAND_WRITTEN)) { + program.addCommand(command) +} + +// Added after the generated groups so their leaves merge into the same group +// object rather than creating a duplicate top-level command. +attachHandWritten(program) program.addHelpText( 'after', diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts new file mode 100644 index 00000000000..96a9e217403 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.ts @@ -0,0 +1,298 @@ +import { Command, Option } from 'commander' +import { clientFrom } from '../context.js' +import { CLI_CONTRACT } from '../contract/commands.js' +import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { SimApiError, type V2Page } from '../http/client.js' +import { + bytes, + type Column, + duration, + printList, + printRecord, + text, + timestamp, +} from '../output/render.js' +import { deriveCommandPath } from './derive.js' +import { + buildRequest, + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + takesJson, +} from './request.js' + +/** Default page size when a list command is run without `--limit`. */ +const DEFAULT_LIMIT = 100 + +/** Reads `a.b.c` out of a row, tolerating a missing link anywhere along the way. */ +function at(row: unknown, path: string): unknown { + return path + .split('.') + .reduce( + (value, key) => (value && typeof value === 'object' ? (value as never)[key] : undefined), + row + ) +} + +function renderCell(value: unknown, format: ColumnSpec['format']): string { + switch (format) { + case 'timestamp': + return timestamp(value as string | null) + case 'bytes': + return bytes(value as number | null) + case 'duration': + return duration(value as number | null) + case 'bool': + return value === null || value === undefined ? text(null) : value ? 'yes' : 'no' + case 'cost': + return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) + default: + if (value === null || value === undefined || value === '') return text(null) + return typeof value === 'object' ? JSON.stringify(value) : String(value) + } +} + +function columnsFrom(specs: ColumnSpec[]): Column[] { + return specs.map((spec) => ({ + header: spec.header, + value: (row: unknown) => renderCell(at(row, spec.path ?? spec.header), spec.format), + })) +} + +/** + * Columns for a list command with none declared in the contract. + * + * Row shapes are only known at runtime here — a table's `data` is user-defined — + * so the keys are unioned across the page rather than read off the first row, + * which would let a sparse row hide every column it happens to omit. Nested + * values are skipped: they render as JSON blobs and make the table unreadable. + */ +function inferColumns(rows: unknown[]): Column[] { + const keys: string[] = [] + const seen = new Set() + + for (const row of rows) { + if (!row || typeof row !== 'object') continue + for (const [key, value] of Object.entries(row)) { + if (seen.has(key)) continue + if (value !== null && typeof value === 'object') continue + seen.add(key) + keys.push(key) + } + } + + return keys.map((key) => ({ + header: key, + value: (row: unknown) => renderCell(at(row, key), 'auto'), + })) +} + +/** The operation's one-line help, taken from the OpenAPI summary at generation time. */ +function summaryFor(operation: V2OperationName): string | undefined { + return (V2_OPERATIONS[operation] as { summary?: string }).summary +} + +/** Whether the operation answers with the `{ data, nextCursor }` list envelope. */ +function isCursorList(operation: V2OperationName): boolean { + const spec = V2_OPERATIONS[operation] as { query?: Record } + return Boolean(spec.query && 'cursor' in spec.query) +} + +/** Adds the flags a field needs, or nothing when the contract omits it. */ +function addFieldOption( + command: Command, + operation: V2OperationName, + field: string, + descriptor: FieldSpec +): void { + // Never a flag: it comes from the profile, and `cursor`/`limit` are owned by + // the auto-pager rather than exposed as raw request fields. + if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return + + const flag = flagSpecFor(operation, field) + if (flag.omit) return + + const name = flagNameFor(operation, field) + const short = flag.short ? `-${flag.short}, ` : '' + + if (field === 'limit') { + command.option( + `--limit `, + 'Maximum items to return (0 for everything)', + String(DEFAULT_LIMIT) + ) + return + } + + if (descriptor.kind === 'boolean') { + command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + return + } + + const takesList = flag.list === true + const placeholder = takesList ? `` : takesJson(descriptor, flag) ? `` : `` + const describe = + flag.describe ?? + (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`) + + const option = new Option(`${short}--${name} ${placeholder}`, describe) + if (descriptor.values && !takesList) option.choices([...descriptor.values]) + if (descriptor.default !== undefined && field !== 'limit') { + option.default(undefined, String(descriptor.default)) + } + command.addOption(option) +} + +/** + * Builds one leaf command for an operation. + * + * The action closure is the whole runtime: coerce and assemble the request, + * auto-page it when the response is a cursor list, then render through whatever + * the contract says about columns. + */ +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + const operationSpec = V2_OPERATIONS[operation] as { + method: string + pathParams: readonly string[] + query?: Record + body?: Record + } + + // `new Command('upsert ')` would make the whole string the command's + // NAME, so `sim tables upsert` would never match it and would silently fall + // through to the group's help. Arguments have to be declared separately. + const command = new Command(leafName) + for (const param of operationSpec.pathParams) { + command.argument(`<${param}>`) + } + + command.description( + spec.describe ?? + summaryFor(operation) ?? + `${operationSpec.method} ${V2_OPERATIONS[operation].path}` + ) + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + addFieldOption(command, operation, field, descriptor) + } + } + + if (spec.confirm) { + command.option('-y, --yes', 'Skip the confirmation') + } + + command.action(async (...invocation: unknown[]) => { + // commander passes positionals, then the options object, then the Command. + const host = invocation[invocation.length - 1] as Command + const flags = invocation[invocation.length - 2] as Record + const positional = invocation.slice(0, operationSpec.pathParams.length) as string[] + + if (spec.confirm && !flags.yes) { + throw new SimApiError(`${spec.confirm} Re-run with --yes to confirm.`, 0) + } + + const { client, profile } = clientFrom(host) + const request = buildRequest(operation, positional, flags, profile.workspaceId) + + if (isCursorList(operation)) { + const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) + if (Number.isNaN(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative number', 0) + } + // 0 means everything; Infinity lets the loop run until the cursor dries up. + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + + const rows: unknown[] = [] + let cursor: string | null = null + do { + const page: V2Page = await client.request(request.path, { + method: operationSpec.method as 'GET' | 'POST', + query: { ...request.query, cursor }, + body: request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows + printList(profile.output, page, spec.columns ? columnsFrom(spec.columns) : inferColumns(page)) + return + } + + const result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method as 'GET' | 'POST', + query: request.query, + body: request.body, + }) + const data = result?.data ?? result + + if (spec.columns && Array.isArray(data)) { + printList(profile.output, data, columnsFrom(spec.columns)) + return + } + + const fields: Array<[string, string]> = + data && typeof data === 'object' && !Array.isArray(data) + ? Object.entries(data) + .filter(([, value]) => value === null || typeof value !== 'object') + .map(([key, value]) => [key, renderCell(value, 'auto')]) + : [] + + printRecord(profile.output, fields, data) + }) + + return command +} + +/** + * Builds every command the contract and the generated operation table describe. + * + * Iterates `V2_OPERATIONS`, not the contract — an operation added to a Zod + * contract shows up here after `generate:cli-api` with no CLI edit at all. The + * contract is consulted only for the things a schema cannot say. + * + * `reserved` are groups owned by hand-written commands (`files download` streams + * binary, `logs get` prints a trace). A generated leaf never displaces one. + */ +export function buildGeneratedCommands(reserved: ReadonlySet): Command[] { + const groups = new Map() + + for (const operation of Object.keys(V2_OPERATIONS) as V2OperationName[]) { + const spec = CLI_CONTRACT[operation] ?? {} + if (spec.hidden) continue + // Non-JSON responses (binary downloads) need a bespoke consumer. + if (V2_OPERATIONS[operation].responseMode !== 'json') continue + + const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) + const [groupName, ...rest] = segments + const leafName = rest.join(' ') || 'run' + + if (reserved.has(`${groupName} ${leafName}`)) continue + + let group = groups.get(groupName) + if (!group) { + group = new Command(groupName) + groups.set(groupName, group) + } + + // A multi-word leaf (`rows batch-delete`) nests one more level so help reads + // as a tree rather than a flat list of hyphenated names. + if (rest.length > 1) { + const [subName, ...tail] = rest + let sub = group.commands.find((candidate) => candidate.name() === subName) + if (!sub) { + sub = new Command(subName) + group.addCommand(sub) + } + sub.addCommand(buildLeaf(operation, spec, tail.join(' '))) + continue + } + + group.addCommand(buildLeaf(operation, spec, leafName)) + } + + return [...groups.values()].sort((a, b) => a.name().localeCompare(b.name())) +} diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts new file mode 100644 index 00000000000..5bcd9be3e75 --- /dev/null +++ b/packages/sim-cli/src/runtime/derive.ts @@ -0,0 +1,58 @@ +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' + +/** + * Trailing path segments that read as verbs rather than sub-resources, so + * `/tables/[id]/rows/upsert` derives `tables upsert` instead of + * `tables rows upsert create`. + * + * `execute` and `cancel` are deliberately absent: they are verbs, but their + * derived names read badly enough that the contract names them explicitly, and + * listing them here would produce `workflows execute` — close, but not the + * `workflows run` the contract asks for. Keeping them out means the contract is + * the only place that decision lives. + */ +const ACTION_SEGMENTS = new Set([ + 'upsert', + 'query', + 'search', + 'export', + 'import', + 'deploy', + 'rollback', +]) + +/** + * Derives a command path from an operation's route. + * + * ` [sub-resource] `, where the verb comes from the method and + * whether the path ends in a parameter (an item) or not (a collection). This + * covers 41 of the 47 operations; the rest are named in the CLI contract. + */ +export function deriveCommandPath(operation: V2OperationName): string[] { + const spec = V2_OPERATIONS[operation] + const segments = spec.path.replace('/api/v2/', '').split('/') + const resource = segments[0] + const nouns = segments.slice(1).filter((segment) => !segment.startsWith('[')) + const last = nouns[nouns.length - 1] + + if (last && ACTION_SEGMENTS.has(last)) return [resource, last] + + const isItem = spec.path.endsWith(']') + const verb = + spec.method === 'GET' + ? isItem + ? 'get' + : 'list' + : spec.method === 'POST' + ? 'create' + : spec.method === 'DELETE' + ? 'delete' + : 'update' + + return last ? [resource, last, verb] : [resource, verb] +} + +/** `conflictTarget` → `conflict-target`. */ +export function kebab(value: string): string { + return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts new file mode 100644 index 00000000000..77199260fa3 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { SimApiError } from '../http/client.js' +import { deriveCommandPath } from './derive.js' +import { buildRequest } from './request.js' + +const WORKSPACE = 'ws_local' + +describe('buildRequest', () => { + it('substitutes path params from positional args and injects the workspace', () => { + expect(buildRequest('upsertTableRow', ['tbl_1'], { data: '{"a":1}' }, WORKSPACE)).toEqual({ + path: '/api/v2/tables/tbl_1/rows/upsert', + query: {}, + body: { workspaceId: WORKSPACE, data: { a: 1 } }, + }) + }) + + it('puts the workspace in whichever slot the contract declares it', () => { + // Same field, different slot: body for upsert above, query here. + const built = buildRequest('listTables', [], {}, WORKSPACE) + expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.body).toBeUndefined() + }) + + it('maps a contract flag alias back to its field name', () => { + const built = buildRequest('upsertTableRow', ['t'], { data: '{}', on: 'email' }, WORKSPACE) + expect(built.body).toMatchObject({ conflictTarget: 'email' }) + }) + + it('comma-joins a list flag the route splits, which the type calls a string', () => { + const built = buildRequest('listLogs', [], { workflow: ['wf_1', 'wf_2'] }, WORKSPACE) + expect(built.query.workflowIds).toBe('wf_1,wf_2') + }) + + it('coerces numeric flags out of the strings argv gives', () => { + const built = buildRequest('listLogs', [], { 'min-duration-ms': '250' }, WORKSPACE) + expect(built.query.minDurationMs).toBe(250) + }) + + it('omits absent optional fields so the server applies its own default', () => { + const built = buildRequest('listLogs', [], {}, WORKSPACE) + expect(built.query).toEqual({ workspaceId: WORKSPACE }) + expect(built.query).not.toHaveProperty('order') + }) + + it('never sends a field the contract marked omit', () => { + // `stream` would switch the response to SSE, which the JSON client cannot read. + const built = buildRequest('executeWorkflow', ['wf_1'], { stream: true }, WORKSPACE) + expect(built.body ?? {}).not.toHaveProperty('stream') + }) + + it('percent-encodes path params so an id cannot retarget the request', () => { + expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') + }) + + describe('failures, all before any network call', () => { + it('rejects a missing path arg', () => { + expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') + }) + + it('rejects a missing required flag', () => { + expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( + '--data is required' + ) + }) + + it('rejects malformed JSON, naming the flag the caller typed', () => { + expect(() => buildRequest('upsertTableRow', ['t'], { data: '{oops' }, WORKSPACE)).toThrow( + '--data must be valid JSON' + ) + }) + + it('rejects a value outside an enum', () => { + expect(() => buildRequest('listLogs', [], { level: 'warn' }, WORKSPACE)).toThrow( + '--level must be one of: info, error' + ) + }) + + it('rejects a non-numeric number', () => { + expect(() => buildRequest('listLogs', [], { 'min-cost': 'lots' }, WORKSPACE)).toThrow( + '--min-cost must be a number' + ) + }) + + it('explains an unset workspace in terms of how to set one', () => { + expect(() => buildRequest('listTables', [], {}, null)).toThrow(SimApiError) + expect(() => buildRequest('listTables', [], {}, null)).toThrow( + 'sim configure --set-workspace' + ) + }) + }) +}) + +describe('deriveCommandPath', () => { + it('derives collection and item verbs from the method and path shape', () => { + expect(deriveCommandPath('listTables')).toEqual(['tables', 'list']) + expect(deriveCommandPath('getTable')).toEqual(['tables', 'get']) + expect(deriveCommandPath('createTable')).toEqual(['tables', 'create']) + expect(deriveCommandPath('deleteTable')).toEqual(['tables', 'delete']) + }) + + it('nests a sub-resource', () => { + expect(deriveCommandPath('getKnowledgeDocument')).toEqual(['knowledge', 'documents', 'get']) + expect(deriveCommandPath('listTableRows')).toEqual(['tables', 'rows', 'list']) + }) + + it('treats a verb-like trailing segment as the command name', () => { + expect(deriveCommandPath('upsertTableRow')).toEqual(['tables', 'upsert']) + expect(deriveCommandPath('searchKnowledge')).toEqual(['knowledge', 'search']) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts new file mode 100644 index 00000000000..c35afdd0976 --- /dev/null +++ b/packages/sim-cli/src/runtime/request.ts @@ -0,0 +1,164 @@ +import { CLI_CONTRACT } from '../contract/commands.js' +import type { FlagSpec } from '../contract/types.js' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' +import { type QueryValue, SimApiError } from '../http/client.js' +import { kebab } from './derive.js' + +/** One request field, as the generator describes it. */ +export interface FieldSpec { + kind: 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'array' | 'object' | 'unknown' + required?: boolean + values?: readonly string[] + default?: unknown +} + +/** + * The workspace never becomes a flag. + * + * It is the one field every workspace-scoped operation declares, and it comes + * from the profile — surfacing it as `--workspace-id` on 30-odd commands would + * duplicate the global `--workspace` and invite the two to disagree. + */ +export const PROFILE_INJECTED_FIELD = 'workspaceId' + +/** Kinds the CLI can only accept as a JSON string. */ +const JSON_KINDS = new Set(['object', 'array', 'unknown']) + +export function flagSpecFor(operation: V2OperationName, field: string): FlagSpec { + return CLI_CONTRACT[operation]?.flags?.[field] ?? {} +} + +/** The flag name a field is exposed under, honouring any contract override. */ +export function flagNameFor(operation: V2OperationName, field: string): string { + return flagSpecFor(operation, field).name ?? kebab(field) +} + +export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { + return flag.json === true || JSON_KINDS.has(field.kind) +} + +/** + * Turns the string argv provides into the value the contract expects. + * + * Every failure names the flag rather than the field, because the flag is what + * the caller typed — and every one of these is caught before any request is + * made, so a typo costs nothing. + */ +export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: string): unknown { + if (raw === undefined) return undefined + + // A repeated flag whose wire form is one comma-joined string. The schema + // types these as `string`, so only the contract knows. + if (flag.list) { + const values = Array.isArray(raw) ? raw : [raw] + return values.join(',') + } + + if (takesJson(field, flag)) { + if (typeof raw !== 'string') return raw + try { + return JSON.parse(raw) + } catch (error) { + throw new SimApiError(`--${flagName} must be valid JSON: ${(error as Error).message}`, 0) + } + } + + if (field.kind === 'number' || field.kind === 'integer') { + const value = Number(raw) + if (Number.isNaN(value)) throw new SimApiError(`--${flagName} must be a number`, 0) + return value + } + + if (field.kind === 'boolean') return raw === true || raw === 'true' + + if (field.kind === 'enum' && field.values && !field.values.includes(String(raw))) { + throw new SimApiError(`--${flagName} must be one of: ${field.values.join(', ')}`, 0) + } + + return raw +} + +export interface BuiltRequest { + path: string + query: Record + body: Record | undefined +} + +/** + * A query string can only carry scalars. Every v2 query field is one today, but + * a structured field could be added — serializing it here keeps that a working + * request rather than `[object Object]`. + */ +function asQueryValue(value: unknown): QueryValue { + if (value === null || value === undefined) return undefined + if (typeof value === 'object') return JSON.stringify(value) + return value as QueryValue +} + +/** + * Assembles one operation's HTTP request from positional args, parsed flags, + * and the profile's workspace. + * + * Path params come from positional arguments in declared order; every other + * field is looked up by its flag name in the slot the contract declares it in, + * so a field that moved from query to body moves here on the next regeneration. + */ +export function buildRequest( + operation: V2OperationName, + positional: string[], + flags: Record, + workspaceId: string | null +): BuiltRequest { + const spec = V2_OPERATIONS[operation] as { + method: string + path: string + pathParams: readonly string[] + query?: Record + body?: Record + } + + let path = spec.path + spec.pathParams.forEach((param, index) => { + const value = positional[index] + if (value === undefined) throw new SimApiError(`Missing <${param}>`, 0) + // Ids are opaque; an unencoded `/` or `?` would silently retarget the request. + path = path.replace(`[${param}]`, encodeURIComponent(value)) + }) + + const query: Record = {} + const body: Record = {} + + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) { + const flag = flagSpecFor(operation, field) + if (flag.omit) continue + + const flagName = flagNameFor(operation, field) + const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[flagName] + const value = coerce(raw ?? undefined, descriptor, flag, flagName) + + if (value === undefined) { + if (descriptor.required) { + throw new SimApiError( + field === PROFILE_INJECTED_FIELD + ? 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + : `--${flagName} is required`, + 0 + ) + } + // Omitted rather than sent as null: the server applies its own default, + // and sending an explicit undefined would override it with nothing. + continue + } + + if (slot === 'query') query[field] = asQueryValue(value) + else body[field] = value + } + } + + return { + path, + query, + body: Object.keys(body).length > 0 ? body : undefined, + } +} diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index b0dbc74624b..ea0359d1883 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -34,6 +34,52 @@ import { z } from 'zod' const ROOT = path.resolve(import.meta.dir, '..') const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') +const DOCS_DIR = path.join(ROOT, 'apps/docs') + +/** OpenAPI documents to read operation summaries from. */ +const SPEC_FILES = [ + 'openapi-core.json', + 'openapi-v2-workflows.json', + 'openapi-v2-logs.json', + 'openapi-v2-tables.json', + 'openapi-v2-knowledge.json', + 'openapi-v2-files-audit.json', +] as const + +/** + * `METHOD /api/v2/{id}/…` → the spec's one-line summary. + * + * The contracts carry validation, not prose, so `--help` text has to come from + * somewhere else. The specs already hold a hand-written summary per operation + * and `check:openapi` guarantees every contract has one, so reading them here + * reuses documentation that is already written and already verified rather than + * inventing a second place to describe the same endpoint. + */ +function loadSummaries(): Map { + const summaries = new Map() + + for (const file of SPEC_FILES) { + let spec: Record + try { + spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) + } catch { + // A missing spec is not fatal: the CLI falls back to `METHOD path`, and + // `check:openapi` is what actually enforces the specs' presence. + continue + } + + for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods as Record)) { + const summary = operation?.summary + if (typeof summary === 'string') { + summaries.set(`${method.toUpperCase()} ${specPath}`, summary) + } + } + } + } + + return summaries +} /** Contract modules to read, in emit order. */ const DOMAINS = [ @@ -179,8 +225,90 @@ function pathParams(routePath: string): string[] { return [...routePath.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1]) } +/** + * The kind a request field reduces to for the CLI's purposes. + * + * Everything from argv arrives as a string, so this is what tells the runtime + * how to turn `"50"` into `50`, a bare `--flag` into `true`, and `'{"a":1}'` + * into an object. `unknown` covers `z.unknown()`/`z.any()`, which the CLI can + * only accept as JSON. + */ +type FieldKind = + | 'string' + | 'number' + | 'integer' + | 'boolean' + | 'enum' + | 'array' + | 'object' + | 'unknown' + +function fieldKind(schema: JsonSchema): FieldKind { + if (schema.enum) return 'enum' + + const variants = schema.anyOf ?? schema.oneOf + if (variants) { + // Nullable is spelled as a union with `null`; a single non-null branch is + // the field's real kind. A genuine multi-branch union has no single flag + // shape, so it falls through to `unknown` and is taken as JSON. + const concrete = variants.filter((v: JsonSchema) => v.type !== 'null') + return concrete.length === 1 ? fieldKind(concrete[0]) : 'unknown' + } + + const type = Array.isArray(schema.type) + ? schema.type.find((t: string) => t !== 'null') + : schema.type + + switch (type) { + case 'string': + case 'number': + case 'integer': + case 'boolean': + case 'array': + case 'object': + return type + default: + return 'unknown' + } +} + +/** + * Describes one request slot's fields for the runtime that builds flags. + * + * Emitted as data rather than baked into types because the CLI has to *iterate* + * these at startup to construct commands — a type alone cannot be walked. + */ +function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null { + if (!schema) return null + + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema + const properties: Record = json.properties ?? {} + const required = new Set(json.required ?? []) + const keys = Object.keys(properties) + + // A union body (e.g. single-row vs batch insert) has no flat field list; the + // runtime falls back to taking the whole body as JSON. + if (keys.length === 0) return null + + const lines = keys.map((key) => { + const property = properties[key] + const parts = [`kind: '${fieldKind(property)}'`] + if (required.has(key)) parts.push('required: true') + if (property.enum) { + parts.push( + `values: [${property.enum.map((v: unknown) => JSON.stringify(v)).join(', ')}] as const` + ) + } + if (property.default !== undefined) parts.push(`default: ${JSON.stringify(property.default)}`) + return `${indent} ${JSON.stringify(key)}: { ${parts.join(', ')} },` + }) + + return `{\n${lines.join('\n')}\n${indent}}` +} + function render(operations: Operation[]): string { const out: string[] = [] + const summaries = loadSummaries() out.push('/**') out.push(' * GENERATED FILE — DO NOT EDIT.') @@ -217,7 +345,18 @@ function render(operations: Operation[]): string { out.push('') } - out.push('/** Every v2 operation, keyed by name. */') + out.push('/**') + out.push(' * Every v2 operation, keyed by name.') + out.push(' *') + out.push(' * `query` and `body` describe each field well enough for the CLI to build a') + out.push(' * flag for it and coerce the string argv gives back: its kind, whether it is') + out.push(' * required, its enum values, and its server-side default. A slot the contract') + out.push(' * does not declare — or one whose shape is a union with no flat field list —') + out.push(' * is absent, and the runtime falls back to taking it as JSON.') + out.push(' *') + out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") + out.push(' * specs so `--help` reuses prose that is already written and already checked.') + out.push(' */') out.push('export const V2_OPERATIONS = {') for (const op of operations) { const params = pathParams(op.contract.path) @@ -226,6 +365,15 @@ function render(operations: Operation[]): string { out.push(` path: '${op.contract.path}',`) out.push(` pathParams: [${params.map((p) => `'${p}'`).join(', ')}] as const,`) out.push(` responseMode: '${op.contract.response.mode}',`) + // OpenAPI writes `{id}` where the contract writes `[id]`. + const summary = summaries.get( + `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` + ) + if (summary) out.push(` summary: ${JSON.stringify(summary)},`) + for (const slot of ['query', 'body'] as const) { + const map = renderSlotMap(op.contract[slot], ' ') + if (map) out.push(` ${slot}: ${map},`) + } out.push(' },') } out.push('} as const') From 4a6ac48db890e2124d17fa5c79ad9a1f36c55c22 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:14:19 -0700 Subject: [PATCH 11/28] =?UTF-8?q?fix(cli):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20flag=20lookup,=20terminal=20controls,=20download=20?= =?UTF-8?q?safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## CLI flags silently dropped (Cursor, High) Commander camelCases every multi-word flag, so `--min-duration-ms` is stored as `minDurationMs`. `buildRequest` looked flags up by their own kebab name, found nothing, and dropped the field — no error, it just never reached the API. That was every multi-word flag on every generated command. The unit tests passed because they fed flag values already keyed by flag name, which is not what commander produces — they validated a fiction. Added `build.test.ts`, which parses real argv through the built commands; three of its assertions fail against the previous code. The old tests now use camelCase keys with a comment saying why. ## Terminal control sequences (Greptile, P1 security) `stripAnsi` matched only SGR (`ESC [ … m`), so a knowledge document, table cell, or workflow name could carry OSC, non-SGR CSI, or `ESC c` through to an interactive terminal — setting the window title, moving the cursor to overwrite what was already printed, or resetting the terminal. Replaced with a `sanitize` covering OSC (BEL- and ST-terminated), CSI, any ESC + printable, and the bare C0/C1 range, keeping tab and newline. Applied where API values become display text, so the colour the CLI adds afterwards still works. ## Downloads (Greptile, P1 ×2) `createWriteStream` truncated silently, and the destination name usually comes from the server's content-disposition rather than anything the caller typed — so a download could irreversibly replace an unrelated local file. Now opens `wx` and fails with a message naming `--force`, which was added for the deliberate overwrite. The stream's error listener was attached after the read loop finished, so an EEXIST/EACCES/ENOSPC during writing was an unhandled 'error' event that took down the process. It is now registered before the first write and raced against the pump. ## Personal-key caption (Cursor, Low) With "No workspace (personal key)" picked, the caption still promised a default workspace the approval does not send. It now distinguishes no-pick from picked-but-not-admin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/cli/auth/cli-auth-view.tsx | 7 +- packages/sim-cli/src/commands/auth.ts | 22 +++- packages/sim-cli/src/commands/hand-written.ts | 123 +++++++++++------- packages/sim-cli/src/output/render.test.ts | 49 +++++++ packages/sim-cli/src/output/render.ts | 43 +++++- packages/sim-cli/src/runtime/build.test.ts | 110 ++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 4 +- packages/sim-cli/src/runtime/derive.ts | 12 ++ packages/sim-cli/src/runtime/request.test.ts | 6 +- packages/sim-cli/src/runtime/request.ts | 6 +- 10 files changed, 324 insertions(+), 58 deletions(-) create mode 100644 packages/sim-cli/src/runtime/build.test.ts diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 74f53ec5019..7a2af2ae600 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -121,7 +121,12 @@ export function CliAuthView() { ? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.' : bindsToWorkspace ? `Issues a key that can only reach ${chosen.name}.` - : 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'} + : chosen + ? 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.' + : // No workspace picked, so none is sent and none becomes the + // profile default — promising one here would describe a + // grant that Connect is not about to make. + 'Issues a personal key tied to your account, with no default workspace.'}

)} diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 262e2f51bac..ded0de9440e 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -25,14 +25,22 @@ import { printRecord } from '../output/render.js' * falls through to the user pasting it somewhere. */ function openBrowser(url: string): void { - const command = - process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + /** + * Windows needs `cmd /c start "" `. + * + * `start` is a cmd builtin, so it needs a shell — but its first quoted + * argument is the *window title*, and node quotes the URL because of the `?` + * and `&` in the query. Passing the URL alone therefore opens a console + * titled with the handoff link and no browser at all. The empty `""` takes + * the title slot so the URL lands where it belongs. + */ + const [command, args] = + process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : [process.platform === 'darwin' ? 'open' : 'xdg-open', [url]] + try { - const child = spawn(command, [url], { - stdio: 'ignore', - detached: true, - shell: process.platform === 'win32', - }) + const child = spawn(command, args, { stdio: 'ignore', detached: true }) child.on('error', () => {}) child.unref() } catch {} diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 1a107c96cf1..534eba0aa08 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -6,7 +6,7 @@ import type { Command } from 'commander' import { clientFrom } from '../context.js' import type { QueryRowsResponse } from '../generated/v2-api.js' import { SimApiError } from '../http/client.js' -import { type Column, printList, text } from '../output/render.js' +import { type Column, printList, sanitize, text } from '../output/render.js' /** * Commands the generated runtime cannot produce. @@ -28,23 +28,44 @@ type Row = QueryRowsResponse['data'][number] * cast that would erase exactly the typing this keeps honest. */ async function streamToFile(body: ReadableStream, file: WriteStream): Promise { - const reader = body.getReader() + // Registered before the first write, not after the loop. `createWriteStream` + // opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no + // listener attached it is an unhandled 'error' event that takes down the + // process instead of failing the download. + const failed = new Promise((_resolve, reject) => { + file.once('error', reject) + }) + + const pump = (async () => { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + // `write` returning false means the buffer is full; waiting for `drain` + // is what stops a large file being buffered entirely in memory. + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve) => file.end(resolve)) + })() + try { - while (true) { - const { done, value } = await reader.read() - if (done) break - // `write` returning false means the buffer is full; waiting for `drain` is - // what stops a large file being buffered entirely in memory. - if (!file.write(value)) await once(file, 'drain') + await Promise.race([pump, failed]) + } catch (error) { + file.destroy() + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new SimApiError( + `${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`, + 0 + ) } - } finally { - reader.releaseLock() + throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) } - - await new Promise((resolve, reject) => { - file.once('error', reject) - file.end(resolve) - }) } /** @@ -70,7 +91,8 @@ function rowColumns(rows: Row[]): Column[] { value: (row: Row) => { const value = row.data[key] if (value === null || value === undefined) return text(null) - return typeof value === 'object' ? JSON.stringify(value) : String(value) + // User-defined cell data is remote content; strip terminal controls. + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) }, })), ] @@ -89,36 +111,49 @@ export function attachHandWritten(program: Command): void { .command('download ') .description('Download a file') .option('-o, --output-file ', 'Where to write it (defaults to the file name)') - .action(async (fileId: string, options: { outputFile?: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - - const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) - url.searchParams.set('workspaceId', workspaceId) - - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status + .option('--force', 'Overwrite the destination if it already exists') + .action( + async ( + fileId: string, + options: { outputFile?: string; force?: boolean }, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`) + url.searchParams.set('workspaceId', workspaceId) + + const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + if (!response.ok || !response.body) { + const raw = await response.text().catch(() => '') + throw new SimApiError( + raw || `Download failed with status ${response.status}`, + response.status + ) + } + + const target = + options.outputFile ?? + basename( + /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? + fileId + ) + + // `wx` fails rather than truncating: a download that silently replaces an + // existing file is unrecoverable, and the name often comes from the + // server's content-disposition rather than anything the caller typed. + await streamToFile( + response.body, + createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) ) + console.log(chalk.green(`✓ Saved ${target}`)) } - - const target = - options.outputFile ?? - basename( - /filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ?? - fileId - ) - - await streamToFile(response.body, createWriteStream(target)) - console.log(chalk.green(`✓ Saved ${target}`)) - }) + ) // ── tables rows list ── columns come from user-defined row data ─────────── const tables = group(program, 'tables') diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 0212bfbcd6d..1bce7ecdf30 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -7,10 +7,13 @@ import { duration, printList, printRecord, + sanitize, text, visibleWidth, } from './render.js' +const ESC = String.fromCharCode(27) + /** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ const coloured = new Chalk({ level: 1 }) @@ -189,3 +192,49 @@ describe('formatters', () => { expect(duration(90_000)).toBe('1m30s') }) }) + +describe('sanitize', () => { + // Remote content — knowledge document text, table cell values, workflow names — + // reaches an interactive terminal through the human-readable renderers. + it('removes an OSC window-title sequence', () => { + expect(sanitize(`${ESC}]0;pwned\u0007hello`)).toBe('hello') + }) + + it('removes OSC terminated by ST rather than BEL', () => { + expect(sanitize(`${ESC}]0;pwned${ESC}\\hello`)).toBe('hello') + }) + + it('removes cursor movement that would overwrite what was already printed', () => { + expect(sanitize(`before${ESC}[2A${ESC}[2Kafter`)).toBe('beforeafter') + }) + + it('removes a full terminal reset', () => { + expect(sanitize(`${ESC}creset`)).toBe('reset') + }) + + it('removes non-SGR CSI, which the old SGR-only pattern left executable', () => { + // The reported hole: stripping only `ESC [ … m` passed everything else through. + expect(sanitize(`${ESC}[6n`)).toBe('') + expect(sanitize(`${ESC}[?1049h`)).toBe('') + }) + + it('removes bare C0 and C1 control characters', () => { + expect(sanitize('a\u0000b\u0008c\u009bd')).toBe('abcd') + }) + + it('takes the following byte with a bare ESC, since ESC + printable is a sequence', () => { + expect(sanitize('a\u001bdb')).toBe('ab') + }) + + it('keeps tabs and newlines, which are legitimate content', () => { + expect(sanitize('a\tb\nc')).toBe('a\tb\nc') + }) + + it('leaves ordinary text untouched', () => { + expect(sanitize('refund policy — 30 days')).toBe('refund policy — 30 days') + }) + + it('is applied to values passing through text()', () => { + expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 0803973467a..0e885ada487 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -13,9 +13,50 @@ const EMPTY_GLYPH = '—' /** Cell text for values that have no useful rendering, kept visually quiet. */ const EMPTY = chalk.dim(EMPTY_GLYPH) +/** + * Escape sequences and control characters that must never reach a terminal + * from server-supplied data. + * + * Covers CSI (`ESC [ … final`), OSC (`ESC ] … BEL|ST`), single-character escapes + * such as `ESC c` (full reset), and the bare C0/C1 control range. Anything a + * knowledge document, table cell, or workflow name contains is remote content — + * a document could set the window title, move the cursor to overwrite what was + * already printed, reset the terminal, or on some emulators drive clipboard and + * paste controls. + * + * Matching only SGR (`… m`) was the hole: it stripped colour and left every + * other sequence executable. + */ +const ESC = String.fromCharCode(27) +const CONTROL_PATTERN = new RegExp( + [ + `${ESC}\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\)?`, // OSC … BEL or ST + `${ESC}\\[[0-9;?]*[ -/]*[@-~]`, // CSI … final byte + // Any other ESC + printable: `ESC c` (full reset), `ESC 7`/`ESC 8` (cursor + // save/restore), `ESC (0` (line-drawing charset), and the rest. ESC is never + // legitimate content, so the whole two-byte form goes. OSC and CSI are + // matched above, so they win at the same position. + `${ESC}[ -~]`, + `${ESC}`, // a lone ESC with nothing valid after it + '[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1, keeping \t and \n + ].join('|'), + 'g' +) + +/** + * Removes terminal control sequences from a server-supplied string. + * + * Applied where API values become display text, so the colour the CLI adds + * afterwards still works — sanitizing the finished cell would strip our own + * formatting too. + */ +export function sanitize(value: string): string { + return value.replace(CONTROL_PATTERN, '') +} + export function text(value: unknown): string { if (value === null || value === undefined || value === '') return EMPTY - return String(value) + return sanitize(String(value)) } /** ISO timestamps are the wire format everywhere; show them without the milliseconds. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts new file mode 100644 index 00000000000..c9b625594b6 --- /dev/null +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -0,0 +1,110 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from './build.js' + +/** + * Drives commands through commander's own parsing rather than calling + * `buildRequest` directly. + * + * The unit tests below `request.ts` fed flag values in already-keyed by flag + * name, which is not what commander produces — it camelCases every multi-word + * flag. That gap let `--min-duration-ms` and every other multi-word flag be + * silently dropped while the tests passed. Parsing real argv is the only way to + * catch that class of bug. + */ + +const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() })) + +vi.mock('../context.js', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + return root +} + +async function run(argv: string[]) { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await program().parseAsync(['node', 'sim', ...argv]) + return mockRequest.mock.calls[0] +} + +describe('commands parsed through commander', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('carries a multi-word flag all the way to the request', async () => { + // The regression: commander stores this as `minDurationMs`, so a lookup by + // `min-duration-ms` found nothing and the filter never reached the API. + const [, options] = await run(['logs', 'list', '--min-duration-ms', '250']) + expect(options.query).toMatchObject({ minDurationMs: 250 }) + }) + + it('carries every multi-word flag on a command, not just the first', async () => { + const [, options] = await run([ + 'logs', + 'list', + '--min-duration-ms', + '10', + '--max-duration-ms', + '20', + '--min-cost', + '1', + '--execution-id', + 'exec_1', + ]) + expect(options.query).toMatchObject({ + minDurationMs: 10, + maxDurationMs: 20, + minCost: 1, + executionId: 'exec_1', + }) + }) + + it('applies a contract flag alias', async () => { + const [path, options] = await run([ + 'tables', + 'upsert', + 'tbl_1', + '--data', + '{"a":1}', + '--on', + 'email', + ]) + expect(path).toBe('/api/v2/tables/tbl_1/rows/upsert') + expect(options.body).toMatchObject({ conflictTarget: 'email', data: { a: 1 } }) + }) + + it('comma-joins a repeated list flag', async () => { + const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) + expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) + }) + + it('injects the profile workspace without a flag', async () => { + const [, options] = await run(['tables', 'list']) + expect(options.query).toMatchObject({ workspaceId: 'ws_local' }) + }) + + it('sends a boolean flag only when present', async () => { + const [, withFlag] = await run(['workflows', 'list', '--deployed-only']) + expect(withFlag.query).toMatchObject({ deployedOnly: true }) + + const [, without] = await run(['workflows', 'list']) + expect(without.query).not.toHaveProperty('deployedOnly') + }) + + it('refuses a destructive command without --yes, before any request', async () => { + await expect(run(['tables', 'rows', 'batch-delete', 'tbl_1', '--row', 'a'])).rejects.toThrow( + /cannot be undone/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 96a9e217403..db394b112a0 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -10,6 +10,7 @@ import { duration, printList, printRecord, + sanitize, text, timestamp, } from '../output/render.js' @@ -50,7 +51,8 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) default: if (value === null || value === undefined || value === '') return text(null) - return typeof value === 'object' ? JSON.stringify(value) : String(value) + // Server-supplied: strip terminal control sequences before it can reach a tty. + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) } } diff --git a/packages/sim-cli/src/runtime/derive.ts b/packages/sim-cli/src/runtime/derive.ts index 5bcd9be3e75..f91aac678e1 100644 --- a/packages/sim-cli/src/runtime/derive.ts +++ b/packages/sim-cli/src/runtime/derive.ts @@ -56,3 +56,15 @@ export function deriveCommandPath(operation: V2OperationName): string[] { export function kebab(value: string): string { return value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) } + +/** + * `min-duration-ms` → `minDurationMs`, the key commander actually stores. + * + * Commander camelCases every multi-word flag when it builds its options object, + * so a lookup by the flag's own name finds nothing and the value is silently + * dropped — no error, the field just never reaches the API. Every read of a + * parsed flag has to go through this. + */ +export function camel(flag: string): string { + return flag.replace(/-([a-z])/g, (_match, character: string) => character.toUpperCase()) +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 77199260fa3..37ab5926b66 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -31,8 +31,10 @@ describe('buildRequest', () => { expect(built.query.workflowIds).toBe('wf_1,wf_2') }) + // Keys here are camelCase because that is what commander stores — feeding + // flag-shaped keys is what let the camelCase mismatch through review. it('coerces numeric flags out of the strings argv gives', () => { - const built = buildRequest('listLogs', [], { 'min-duration-ms': '250' }, WORKSPACE) + const built = buildRequest('listLogs', [], { minDurationMs: '250' }, WORKSPACE) expect(built.query.minDurationMs).toBe(250) }) @@ -76,7 +78,7 @@ describe('buildRequest', () => { }) it('rejects a non-numeric number', () => { - expect(() => buildRequest('listLogs', [], { 'min-cost': 'lots' }, WORKSPACE)).toThrow( + expect(() => buildRequest('listLogs', [], { minCost: 'lots' }, WORKSPACE)).toThrow( '--min-cost must be a number' ) }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index c35afdd0976..b519d90d273 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -2,7 +2,7 @@ import { CLI_CONTRACT } from '../contract/commands.js' import type { FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { type QueryValue, SimApiError } from '../http/client.js' -import { kebab } from './derive.js' +import { camel, kebab } from './derive.js' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -134,7 +134,9 @@ export function buildRequest( if (flag.omit) continue const flagName = flagNameFor(operation, field) - const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[flagName] + // Commander stores `--min-duration-ms` as `minDurationMs`; reading by the + // flag's own name silently finds nothing. + const raw = field === PROFILE_INJECTED_FIELD ? workspaceId : flags[camel(flagName)] const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { From 0ca127c4833c33f343f93d537da0fa5ef427d59c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:22:07 -0700 Subject: [PATCH 12/28] =?UTF-8?q?fix(cli):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20body-cursor=20paging,=20timestamp=20sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## `tables rows query` printed nothing (Cursor, High) `isCursorList` only looked for `cursor` on the query slot, but `queryRows` is a POST whose whole filter — cursor included — is in the body. It therefore took the single-request path, which handed an array of rows to `printRecord` and printed an empty record, and it never auto-paged past the first page. Replaced with `cursorSlot`, which checks both slots and tells the pager where to put the cursor back. Added a defensive branch so an array reaching the single-resource path renders as a list with inferred columns rather than silently printing nothing. ## Invalid timestamps bypassed sanitization (Greptile, P1 security) `timestamp()` echoes an unparseable value verbatim, and that value is still server-supplied — so the branch was a way past every other formatter for the control sequences round 1 closed. Now sanitized on that path too. Audited the remaining formatters: no other path returns a server value unsanitized. Both fixes have tests that fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/output/render.test.ts | 11 ++++++ packages/sim-cli/src/output/render.ts | 5 ++- packages/sim-cli/src/runtime/build.test.ts | 36 ++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 39 +++++++++++++++++----- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 1bce7ecdf30..b9b87bbaefe 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -9,6 +9,7 @@ import { printRecord, sanitize, text, + timestamp, visibleWidth, } from './render.js' @@ -237,4 +238,14 @@ describe('sanitize', () => { it('is applied to values passing through text()', () => { expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') }) + + it('is applied to an unparseable timestamp, which is echoed verbatim', () => { + // The invalid-date branch returns the server's own string, so it was a way + // past every other formatter. + expect(timestamp(`${ESC}]0;pwned\u0007not-a-date`)).toBe('not-a-date') + }) + + it('still formats a valid timestamp normally', () => { + expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22') + }) }) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 0e885ada487..e4e057339a4 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -63,7 +63,10 @@ export function text(value: unknown): string { export function timestamp(value: string | null | undefined): string { if (!value) return EMPTY const date = new Date(value) - if (Number.isNaN(date.getTime())) return String(value) + // Sanitized on the way out: an unparseable value is echoed verbatim, and it is + // still server-supplied, so this branch was a way to smuggle control sequences + // past every other formatter. + if (Number.isNaN(date.getTime())) return sanitize(String(value)) return date.toISOString().replace('T', ' ').slice(0, 19) } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index c9b625594b6..1d22e329331 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -108,3 +108,39 @@ describe('commands parsed through commander', () => { expect(mockRequest).not.toHaveBeenCalled() }) }) + +describe('pagination slot', () => { + it('pages a body-cursor operation and renders its rows', async () => { + // `queryRows` is a POST whose cursor is in the body, not the query. Reading + // only the query made it take the single-request path and print nothing. + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'r1' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'r2' }], nextCursor: null }) + const lines: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1']) + + expect(mockRequest).toHaveBeenCalledTimes(2) + // Second call resumes from the cursor — in the body, where the contract puts it. + expect(mockRequest.mock.calls[1][1].body).toMatchObject({ cursor: 'c1' }) + expect(mockRequest.mock.calls[1][1].query).not.toHaveProperty('cursor') + // And the rows actually render rather than printing an empty record. + expect(JSON.parse(lines[0])).toEqual([{ id: 'r1' }, { id: 'r2' }]) + }) + + it('keeps a query-cursor operation on the query slot', async () => { + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'logs', 'list']) + + expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index db394b112a0..425e08d35e0 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -96,10 +96,24 @@ function summaryFor(operation: V2OperationName): string | undefined { return (V2_OPERATIONS[operation] as { summary?: string }).summary } -/** Whether the operation answers with the `{ data, nextCursor }` list envelope. */ -function isCursorList(operation: V2OperationName): boolean { - const spec = V2_OPERATIONS[operation] as { query?: Record } - return Boolean(spec.query && 'cursor' in spec.query) +/** + * Which request slot carries the pagination cursor, or null for a non-list + * operation. + * + * Both slots have to be checked: most lists take `cursor` as a query param, but + * `queryRows` is a POST whose whole filter — cursor included — is in the body. + * Looking only at the query made it fall through to the single-request path, + * which then rendered its array of rows through `printRecord` and printed + * nothing at all, and never auto-paged. + */ +function cursorSlot(operation: V2OperationName): 'query' | 'body' | null { + const spec = V2_OPERATIONS[operation] as { + query?: Record + body?: Record + } + if (spec.query && 'cursor' in spec.query) return 'query' + if (spec.body && 'cursor' in spec.body) return 'body' + return null } /** Adds the flags a field needs, or nothing when the contract omits it. */ @@ -199,7 +213,8 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri const { client, profile } = clientFrom(host) const request = buildRequest(operation, positional, flags, profile.workspaceId) - if (isCursorList(operation)) { + const paging = cursorSlot(operation) + if (paging) { const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) if (Number.isNaN(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a non-negative number', 0) @@ -210,10 +225,14 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri const rows: unknown[] = [] let cursor: string | null = null do { + // The cursor goes back in whichever slot the contract declared it. const page: V2Page = await client.request(request.path, { method: operationSpec.method as 'GET' | 'POST', - query: { ...request.query, cursor }, - body: request.body, + query: paging === 'query' ? { ...request.query, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...(cursor ? { cursor } : {}) } + : request.body, }) rows.push(...page.data) cursor = page.nextCursor @@ -231,8 +250,10 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri }) const data = result?.data ?? result - if (spec.columns && Array.isArray(data)) { - printList(profile.output, data, columnsFrom(spec.columns)) + if (Array.isArray(data)) { + // Reached when a non-paginated operation answers with a collection. + // `printRecord` would silently print nothing for an array. + printList(profile.output, data, spec.columns ? columnsFrom(spec.columns) : inferColumns(data)) return } From 9c0317d3c780deb1690047cbe62e514c0301026e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:29:49 -0700 Subject: [PATCH 13/28] =?UTF-8?q?fix(cli):=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20poll=20retry,=20download=20flush=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are flaws in round 1's fixes rather than in the original code. ## A redeemable login was thrown away (Cursor, High) `pollForKey` treated every non-429 status as terminal. But the poll route releases its mint reservation on any mint failure — its own comment says "a later poll can retry" — so a transient 5xx or a same-second name conflict ended the login after the user had already approved in the browser, forcing a full restart for something the server had deliberately left recoverable. Retryable is now 409, 429, and 5xx. Everything else stays terminal: 400 means a malformed request id or verifier and 401/403/404 mean the server is refusing on purpose, so retrying those would just spin to the 15-minute timeout. ## A failed download reported success (Greptile, P1) `file.end(resolve)` passes the flush error to the callback as its argument, so the pump fulfilled *with* the error and the command printed "Saved" for a truncated file. Confirmed against node directly — `end`'s callback receives the errno. It now rejects on that argument, which is the path an ENOSPC actually takes, since the bytes may not reach disk until the final flush. Adds `device-flow.test.ts` (11 tests: the retry matrix, transport failure, terminal refusals, and that the poll secret never enters the browser URL) and `hand-written.test.ts` covering the download's overwrite guard and flush failure. The two retry tests fail against the previous code; the flush test needs `/dev/full` and so runs in CI rather than on macOS. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/auth/device-flow.test.ts | 124 ++++++++++++++++++ packages/sim-cli/src/auth/device-flow.ts | 21 ++- .../sim-cli/src/commands/hand-written.test.ts | 61 +++++++++ packages/sim-cli/src/commands/hand-written.ts | 13 +- 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 packages/sim-cli/src/auth/device-flow.test.ts create mode 100644 packages/sim-cli/src/commands/hand-written.test.ts diff --git a/packages/sim-cli/src/auth/device-flow.test.ts b/packages/sim-cli/src/auth/device-flow.test.ts new file mode 100644 index 00000000000..80df1109946 --- /dev/null +++ b/packages/sim-cli/src/auth/device-flow.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildApprovalUrl, createAuthRequest, pollForKey } from './device-flow.js' + +const ENDPOINT = 'https://sim.test' + +function reply(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status }) as Response +} + +const COMPLETE = { + status: 'complete', + key: { id: 'k1', apiKey: 'sim_abc' }, + scope: 'platform', + workspaceId: 'ws_1', + workspaceBound: true, +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +/** Drives the poll loop without waiting out its real 2s interval. */ +async function poll(responses: Array<() => Response>) { + let call = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => responses[call++]()) + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn() + return 0 as unknown as NodeJS.Timeout + }) as never) + + const auth = createAuthRequest() + return { result: await pollForKey(ENDPOINT, auth), calls: () => call } +} + +describe('pollForKey', () => { + it('returns the key once the approval completes', async () => { + const { result } = await poll([() => reply(200, COMPLETE)]) + expect(result).toMatchObject({ apiKey: 'sim_abc', scope: 'platform', workspaceBound: true }) + }) + + it('keeps polling while the approval is pending', async () => { + const { result, calls } = await poll([ + () => reply(200, { status: 'pending' }), + () => reply(200, { status: 'pending' }), + () => reply(200, COMPLETE), + ]) + expect(calls()).toBe(3) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a 5xx, because the server released the approval for a later poll', async () => { + // The regression: treating every non-429 as terminal threw away an approval + // the user had already granted in the browser. + const { result } = await poll([ + () => reply(500, { error: 'Failed to generate API key' }), + () => reply(200, COMPLETE), + ]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a same-second name conflict', async () => { + const { result } = await poll([ + () => reply(409, { error: 'A personal API key named "CLI (…)" already exists.' }), + () => reply(200, COMPLETE), + ]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('retries a rate-limited poll', async () => { + const { result } = await poll([() => reply(429, {}), () => reply(200, COMPLETE)]) + expect(result.apiKey).toBe('sim_abc') + }) + + it('survives a transport failure without ending the login', async () => { + let call = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + if (call++ === 0) throw new Error('ECONNRESET') + return reply(200, COMPLETE) + }) + vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { + fn() + return 0 as unknown as NodeJS.Timeout + }) as never) + + const result = await pollForKey(ENDPOINT, createAuthRequest()) + expect(result.apiKey).toBe('sim_abc') + }) + + it('gives up on a deliberate refusal rather than spinning to the timeout', async () => { + await expect( + poll([() => reply(400, { error: 'verifier must be a base64url secret' })]) + ).rejects.toThrow('verifier must be a base64url secret') + }) + + it('gives up on a 403', async () => { + await expect(poll([() => reply(403, { error: 'Forbidden' })])).rejects.toThrow('Forbidden') + }) +}) + +describe('createAuthRequest', () => { + it('mints a 43-character base64url request id, challenge, and secret', () => { + const auth = createAuthRequest() + for (const value of [auth.request, auth.challenge, auth.pollSecret]) { + expect(value).toMatch(/^[A-Za-z0-9\-_]{43}$/) + } + }) + + it('uses a pairing alphabet with no look-alike characters', () => { + // The code is compared across two screens; O/0 and I/1 would defeat that. + for (let i = 0; i < 50; i++) { + expect(createAuthRequest().pairing).toMatch( + /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/ + ) + } + }) + + it('never puts the poll secret in the browser URL', () => { + const auth = createAuthRequest() + const url = buildApprovalUrl(ENDPOINT, auth, 'platform', 'ws_1') + expect(url).toContain(encodeURIComponent(auth.challenge)) + expect(url).not.toContain(auth.pollSecret) + }) +}) diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index 01b428b817b..31fb0a5b5d7 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -18,6 +18,23 @@ const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const POLL_INTERVAL_MS = 2000 const POLL_TIMEOUT_MS = 15 * 60 * 1000 +/** + * Poll statuses that leave the approval still redeemable, so the login should + * keep waiting rather than making the user restart the browser handoff. + * + * The poll route releases its mint reservation on any mint failure — its own + * comment says "a later poll can retry" — so giving up on those threw away an + * approval the user had already granted. A transient 5xx or a same-second name + * conflict (409) is exactly that case. + * + * 429 is the poll cadence hitting the per-IP bucket, not a refusal. + * + * Everything else stays terminal: 400 means a malformed request id or verifier, + * and 401/403/404 mean the server is refusing on purpose. Retrying those just + * spins until the 15-minute timeout. + */ +const RETRYABLE_POLL_STATUSES = new Set([409, 429, 500, 502, 503, 504]) + export type CliAuthScope = 'copilot' | 'platform' export interface AuthRequest { @@ -122,9 +139,7 @@ export async function pollForKey( const raw = await response.text() if (!response.ok) { - // 429 is the poll cadence bumping the per-IP bucket, not a refusal — - // back off and keep the login alive instead of making the user restart. - if (response.status !== 429) { + if (!RETRYABLE_POLL_STATUSES.has(response.status)) { let message = `Login failed with status ${response.status}` try { const body = JSON.parse(raw) as { error?: unknown } diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts new file mode 100644 index 00000000000..eb3cedc1f50 --- /dev/null +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -0,0 +1,61 @@ +import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { streamToFile } from './hand-written.js' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function bodyOf(chunks: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) + controller.close() + }, + }) +} + +describe('streamToFile', () => { + it('writes the body to disk', async () => { + const target = join(dir, 'out.txt') + await streamToFile(bodyOf(['hello ', 'world']), createWriteStream(target, { flags: 'wx' })) + expect(existsSync(target)).toBe(true) + }) + + it('refuses to clobber an existing file, naming --force', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + // The destination usually comes from the server's content-disposition, so a + // silent truncate could destroy a file the caller never named. + await expect( + streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'wx' })) + ).rejects.toThrow(/already exists.*--force/s) + }) + + it('overwrites when the caller asked for it', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'old') + await streamToFile(bodyOf(['new']), createWriteStream(target, { flags: 'w' })) + expect(existsSync(target)).toBe(true) + }) + + it.skipIf(!existsSync('/dev/full'))( + 'rejects when the final flush fails instead of reporting success', + async () => { + // `end`'s callback receives the flush error; passing `resolve` straight in + // made that error the resolution value, so a truncated download printed + // "Saved". /dev/full only errors at flush time, which is the exact path. + await expect( + streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) + ).rejects.toThrow(/Could not write/) + } + ) +}) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 534eba0aa08..782a0324dbb 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -27,7 +27,10 @@ type Row = QueryRowsResponse['data'][number] * are structurally incompatible under this TS config, and bridging them needs a * cast that would erase exactly the typing this keeps honest. */ -async function streamToFile(body: ReadableStream, file: WriteStream): Promise { +export async function streamToFile( + body: ReadableStream, + file: WriteStream +): Promise { // Registered before the first write, not after the loop. `createWriteStream` // opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no // listener attached it is an unhandled 'error' event that takes down the @@ -50,7 +53,13 @@ async function streamToFile(body: ReadableStream, file: WriteStream) reader.releaseLock() } - await new Promise((resolve) => file.end(resolve)) + // `end`'s callback receives the error from a failed final flush (ENOSPC is + // the common one, since the bytes may not hit disk until here). Passing + // `resolve` directly made that error the resolution *value*, so the pump + // fulfilled and the command printed "Saved" for a truncated file. + await new Promise((resolve, reject) => { + file.end((error?: Error | null) => (error ? reject(error) : resolve())) + }) })() try { From 678bdc44e99b82d62a14f3ab0d38e8f6b156e9c8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:36:37 -0700 Subject: [PATCH 14/28] =?UTF-8?q?fix(cli):=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20repeated=20flags=20encode=20per=20field=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coerce` comma-joined every `list` flag, but that is only correct for the three fields whose wire type is a `string` the route splits (`workflowIds`, `folderIds`, `triggers`). The others genuinely want an array: - `rowIds` and `selectedOutputs` are `array`, so joining sent a string where the schema expects a list — `sim tables rows batch-delete --row a b` failed validation, and so did a single `--row a` - `knowledgeBaseIds` is a string-or-array union whose array branch is the right one; joining made `kb_1,kb_2` a single bogus id, so multi-`--kb` search silently searched nothing `list` now means only "accept the flag more than once" — the encoding follows the field's kind, which the generator already records. The two questions were conflated under one contract field and the `FlagSpec` doc now says so. Four tests, three of which fail against the previous code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- packages/sim-cli/src/contract/types.ts | 13 +++++++-- packages/sim-cli/src/runtime/request.test.ts | 30 ++++++++++++++++++++ packages/sim-cli/src/runtime/request.ts | 16 +++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index f255ecc88e5..6e11f4cfe32 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -30,9 +30,16 @@ export interface FlagSpec { /** Short alias, e.g. `w` for `--workspace`. */ short?: string /** - * Accept a repeated flag and send it comma-joined. For fields the schema - * types as `string` but the route splits — invisible to any type-driven - * generator, so it has to be stated. + * Accept the flag more than once. + * + * Only says that several values are allowed — how they reach the wire is + * decided by the field's kind, not here. A `string` field is one the route + * splits on commas (`workflowIds`), so the values are joined; anything else + * genuinely wants an array (`rowIds`, `knowledgeBaseIds`). Conflating the two + * turned multi-value `--kb` and `--row` into a single bogus value. + * + * Still needed on the string case because "this string is really a list" is + * invisible to any type-driven generator. */ list?: boolean /** Take a JSON string. Implied for object/array/unknown fields. */ diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 37ab5926b66..d4e8cb12e42 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -110,3 +110,33 @@ describe('deriveCommandPath', () => { expect(deriveCommandPath('searchKnowledge')).toEqual(['knowledge', 'search']) }) }) + +describe('repeated flags encode per the field kind, not uniformly', () => { + it('joins a string field the route splits', () => { + const built = buildRequest('listLogs', [], { workflow: ['wf_1', 'wf_2'] }, WORKSPACE) + expect(built.query.workflowIds).toBe('wf_1,wf_2') + }) + + it('keeps an array field as an array', () => { + // Joining these produced a string where the wire wants an array, so + // `--row a b` failed validation — and so did a single `--row a`. + const built = buildRequest('deleteTableRows', ['tbl_1'], { row: ['r1', 'r2'] }, WORKSPACE) + expect(built.body?.rowIds).toEqual(['r1', 'r2']) + }) + + it('keeps a single repeated value as a one-element array, not a bare string', () => { + const built = buildRequest('deleteTableRows', ['tbl_1'], { row: ['r1'] }, WORKSPACE) + expect(built.body?.rowIds).toEqual(['r1']) + }) + + it('sends the array branch of a string-or-array union', () => { + // `knowledgeBaseIds` accepts either; joining made "kb_1,kb_2" a single id. + const built = buildRequest( + 'searchKnowledge', + [], + { kb: ['kb_1', 'kb_2'], query: 'refunds' }, + WORKSPACE + ) + expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index b519d90d273..44f4393fed4 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -47,11 +47,21 @@ export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: string): unknown { if (raw === undefined) return undefined - // A repeated flag whose wire form is one comma-joined string. The schema - // types these as `string`, so only the contract knows. + /** + * A repeated flag. `list` says the CLI accepts several values; the *wire* + * encoding follows the field's own kind, because the two are not the same + * question: + * + * - `string` — the route splits on commas (`workflowIds`, `folderIds`, + * `triggers`), so the values are joined. + * - anything else — the wire genuinely wants an array (`rowIds`, + * `selectedOutputs`) or a string-or-array union whose array branch is the + * right one (`knowledgeBaseIds`). Joining those produced a single bogus id + * or failed validation outright. + */ if (flag.list) { const values = Array.isArray(raw) ? raw : [raw] - return values.join(',') + return field.kind === 'string' ? values.join(',') : values } if (takesJson(field, flag)) { From 681ee3818cf51fed05835bfa89c3a788962e11b3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 31 Jul 2026 19:45:02 -0700 Subject: [PATCH 15/28] =?UTF-8?q?fix(cli):=20review=20round=205=20?= =?UTF-8?q?=E2=80=94=20header=20sanitization,=20auth=20ordering,=20stale?= =?UTF-8?q?=20suggestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Table headers stayed executable (Greptile, P1 security) Round 1 sanitized cell *values* but not the column *names*, and a table's columns are user-defined — so the same control sequences were still executable one row higher, in the header. Sanitizing is now done inside `renderTable` rather than at each call site, so a future column source cannot reopen it, with the two key-derived column builders covered as well. ## Fresh install was told the wrong first step (Cursor, Low) Generated commands read `profile.workspaceId` directly, bypassing `requireWorkspace()` — which checks the key first precisely so a new user is told to log in rather than to set a workspace they cannot use yet. That ordering was fixed for the hand-written commands earlier and reintroduced by the runtime. `sim tables list` on an empty profile now says "Not logged in" again. ## A stale suggestion shadowed the fallback (Cursor, Medium) The picker took `selected ?? suggestedWorkspaceId ?? lastActiveWorkspaceId`. The suggestion comes from a profile the CLI wrote earlier, so it can name a workspace the user has since left — and merely being truthy, it blocked the last-active fallback and left the card on "no workspace" with a perfectly good one available. It now counts only when it resolves against the loaded list. Two of the three have tests that fail against the previous code; the third is verified end-to-end (`sim tables list` on an empty profile). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- apps/sim/app/cli/auth/cli-auth-view.tsx | 20 ++++++++++++++----- packages/sim-cli/src/commands/hand-written.ts | 4 +++- packages/sim-cli/src/output/render.test.ts | 10 ++++++++++ packages/sim-cli/src/output/render.ts | 12 +++++++---- packages/sim-cli/src/runtime/build.ts | 19 ++++++++++++++++-- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 7a2af2ae600..080b872f297 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -71,11 +71,21 @@ export function CliAuthView() { */ const loadingWorkspaces = isPlatform && workspaces.isPending - // The terminal's suggestion, then the user's last active workspace. Derived at - // render rather than synced into state through an effect, so the first paint - // after the list loads already shows the right row. - const workspaceId = - selected ?? request.suggestedWorkspaceId ?? workspaces.data?.lastActiveWorkspaceId ?? null + /** + * The terminal's suggestion, then the user's last active workspace. Derived at + * render rather than synced into state through an effect, so the first paint + * after the list loads already shows the right row. + * + * The suggestion only counts when it resolves to a workspace the user + * actually has. It comes from a profile the CLI wrote earlier, so it can name + * a workspace they have since left or one that no longer exists — and being + * merely truthy, it used to shadow the last-active fallback and leave the card + * on "no workspace" with a perfectly good one available. + */ + const suggested = workspaces.data?.workspaces.some((w) => w.id === request.suggestedWorkspaceId) + ? request.suggestedWorkspaceId + : null + const workspaceId = selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) // Only an admin can bind a key to a workspace. Anything less still gets a diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 782a0324dbb..4afce929849 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -96,7 +96,9 @@ function rowColumns(rows: Row[]): Column[] { return [ { header: 'id', value: (row) => row.id }, ...keys.map((key) => ({ - header: key, + // A table's column names are user-defined, so the header is remote + // content just as much as the cell beneath it. + header: sanitize(key), value: (row: Row) => { const value = row.data[key] if (value === null || value === undefined) return text(null) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index b9b87bbaefe..cffd2cc677b 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -14,6 +14,7 @@ import { } from './render.js' const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) /** Colour is stripped when not writing to a TTY, so force it on for these assertions. */ const coloured = new Chalk({ level: 1 }) @@ -239,6 +240,15 @@ describe('sanitize', () => { expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe') }) + it('is applied to a table header, not only its cells', () => { + // A table's column names are user-defined, so the header is remote content + // too — sanitizing cells alone left the sequences executable one row up. + const hostile = `${ESC}]0;pwned${BEL}email` + printList('table', [{ v: 'a@b.co' }], [{ header: hostile, value: () => 'a@b.co' }]) + expect(logged[0]).not.toContain(ESC) + expect(logged[0]).toContain('EMAIL') + }) + it('is applied to an unparseable timestamp, which is echoed verbatim', () => { // The invalid-date branch returns the server's own string, so it was a way // past every other formatter. diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index e4e057339a4..8a2a3eb1d0f 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -134,13 +134,17 @@ function pad(value: string, width: number): string { function renderTable(rows: T[], columns: Column[]): string { if (rows.length === 0) return chalk.dim('No results.') + // A header can be a user-defined column name (a table's own columns), so it is + // remote content and gets the same treatment as a cell. Doing it here rather + // than only at each call site means a future column source cannot reopen this. + const headers = columns.map((column) => sanitize(column.header)) const cells = rows.map((row) => columns.map((column) => column.value(row))) - const widths = columns.map((column, index) => - Math.max(visibleWidth(column.header), ...cells.map((line) => visibleWidth(line[index]))) + const widths = columns.map((_column, index) => + Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) ) - const header = columns - .map((column, index) => chalk.dim(pad(column.header.toUpperCase(), widths[index]))) + const header = headers + .map((label, index) => chalk.dim(pad(label.toUpperCase(), widths[index]))) .join(' ') .trimEnd() diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 425e08d35e0..bb2d4a38a61 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -86,7 +86,10 @@ function inferColumns(rows: unknown[]): Column[] { } return keys.map((key) => ({ - header: key, + // The key itself is remote data when the rows are user-defined, and the + // header is printed just like a cell — sanitizing values but not headers + // left the same control sequences executable one row higher. + header: sanitize(key), value: (row: unknown) => renderCell(at(row, key), 'auto'), })) } @@ -211,7 +214,19 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri } const { client, profile } = clientFrom(host) - const request = buildRequest(operation, positional, flags, profile.workspaceId) + // `requireWorkspace` checks the key first on purpose, so a fresh install is + // told to log in rather than to set a workspace it cannot use yet. Reading + // `profile.workspaceId` directly skipped that ordering. + const needsWorkspace = Boolean( + (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || + (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) + ) + const request = buildRequest( + operation, + positional, + flags, + needsWorkspace ? client.requireWorkspace() : profile.workspaceId + ) const paging = cursorSlot(operation) if (paging) { From 9a0e1e3651e8cba5786db8343ec0475ed5092b2c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:14:01 -0700 Subject: [PATCH 16/28] feat(cli): pick up the new v2 domains; discover modules instead of listing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges `v2-api-spec` (#6150 — v2 endpoints for MCP servers, skills, custom tools, folders, credentials) and the newer `improvement/v2-endpoints`. ## The generator was list-driven, so none of it would have appeared `DOMAINS` and `SPEC_FILES` were hardcoded. Five new contract modules and a new `openapi-v2-resources.json` had landed, and the generator would have skipped every one — silently, with `--check` still passing, because the generated file matched a generator that never looked. Both are now discovered from disk. That is the same silent-drop class the review rounds kept surfacing, and it is the property the whole pipeline rests on: a new v2 domain should reach the CLI by regenerating, not by remembering to edit a list. Result: 47 → 72 operations, 13 contract modules, and 25 new commands (`sim skills list`, `sim mcp-servers get`, `sim folders delete`, …) with no CLI change beyond the discovery fix. Summaries for the new domains now resolve too, so their `--help` reads properly instead of falling back to `METHOD /path`. ## Confirmation gates for the new destructive operations Five new DELETEs arrived ungated. `deleteFolder` is the sharpest — the route archives the folder *and cascades to its contents* — so its message says so rather than reading like a single-item removal. Added a test asserting every DELETE carries a confirmation, with `undeployWorkflow` the one documented exception (reversible by redeploying). It fails against this commit's own starting state, so the next domain to arrive cannot land ungated the way these did. ## One fix outside the CLI `lib/skills/orchestration/skill-lifecycle.ts`, added by #6150, imports `OrchestrationErrorCode` from `@/lib/workflows/orchestration/types`, which does not exist — the type lives in `@/lib/core/orchestration/types`, where every other consumer reads it. The branch does not type-check without this. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj --- .../skills/orchestration/skill-lifecycle.ts | 2 +- packages/sim-cli/src/contract/commands.ts | 14 + packages/sim-cli/src/generated/v2-api.ts | 1078 ++++++++++++++++- packages/sim-cli/src/http/client.test.ts | 31 + scripts/generate-v2-cli-api.ts | 75 +- 5 files changed, 1167 insertions(+), 33 deletions(-) diff --git a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts index b45d6b7db75..5cb13daf037 100644 --- a/apps/sim/lib/skills/orchestration/skill-lifecycle.ts +++ b/apps/sim/lib/skills/orchestration/skill-lifecycle.ts @@ -9,9 +9,9 @@ import { skillDescriptionSchema, skillNameSchema, } from '@/lib/api/contracts/skills' +import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { getSkillActorContext } from '@/lib/skills/access' -import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types' import { getBuiltinSkillByName, isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import { deleteSkill, getSkillById, upsertSkills } from '@/lib/workflows/skills/operations' diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 8f000240b36..e9642ac3eda 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -42,6 +42,20 @@ export const CLI_CONTRACT: CliContract = { deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, deleteFile: { confirm: 'This archives the file.' }, + deleteSkill: { confirm: 'This deletes the skill.' }, + deleteCustomTool: { confirm: 'This deletes the custom tool.' }, + deleteMcpServer: { + confirm: 'This removes the MCP server and the tools it provides.', + }, + deleteCredential: { + confirm: 'This deletes the credential; anything authenticating with it stops working.', + }, + deleteFolder: { + // The route archives the folder *and cascades to its contents*, so this is + // the broadest delete on the surface — the message says so rather than + // reading like a single-item removal. + confirm: 'This archives the folder and everything inside it.', + }, // ─── Fields whose type misdescribes their meaning ───────────────────────── // `z.string()` that the route splits on commas. No generator can infer this. diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 6223d5d3329..895dbcdae3d 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -75,6 +75,110 @@ export type CancelWorkflowExecutionResponse = { } } +/** `POST /api/v2/credentials` */ +export type CreateCredentialBody = { + workspaceId: string + type: 'env_workspace' | 'env_personal' | 'service_account' + displayName?: string + description?: string + providerId?: string + envKey?: string + serviceAccountJson?: string + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + clientId?: string + clientSecret?: string + orgId?: string +} + +export type CreateCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/custom-tools` */ +export type CreateCustomToolBody = { + workspaceId: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string +} + +export type CreateCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/folders` */ +export type CreateFolderBody = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + name: string + parentId?: string | null + sortOrder?: number +} + +export type CreateFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + /** `POST /api/v2/knowledge` */ export type CreateKnowledgeBaseBody = { workspaceId: string @@ -116,6 +220,71 @@ export type CreateKnowledgeBaseResponse = { } } +/** `POST /api/v2/mcp-servers` */ +export type CreateMcpServerBody = { + workspaceId: string + name: string + description?: string + transport?: 'streamable-http' + url: string + authType?: 'none' | 'headers' | 'oauth' + headers?: Record + timeout?: number + retries?: number + enabled?: boolean + oauthClientId?: string | null + oauthClientSecret?: string | null +} + +export type CreateMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `POST /api/v2/skills` */ +export type CreateSkillBody = { + workspaceId: string + name: string + description: string + content: string +} + +export type CreateSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + /** `POST /api/v2/tables` */ export type CreateTableBody = { name: string @@ -210,6 +379,38 @@ export type CreateTableRowsResponse = } } +/** `DELETE /api/v2/credentials/[id]` */ +export type DeleteCredentialParams = { + id: string +} + +export type DeleteCredentialQuery = { + workspaceId: string +} + +export type DeleteCredentialResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/custom-tools/[id]` */ +export type DeleteCustomToolParams = { + id: string +} + +export type DeleteCustomToolQuery = { + workspaceId: string +} + +export type DeleteCustomToolResponse = { + data: { + id: string + deleted: true + } +} + /** `DELETE /api/v2/files/[fileId]` */ export type DeleteFileParams = { fileId: string @@ -226,6 +427,30 @@ export type DeleteFileResponse = { } } +/** `DELETE /api/v2/folders/[id]` */ +export type DeleteFolderParams = { + id: string +} + +export type DeleteFolderQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' +} + +export type DeleteFolderResponse = { + data: { + id: string + deleted: true + deletedItems?: { + folders: number + workflows?: number + files?: number + knowledgeBases?: number + tables?: number + } + } +} + /** `DELETE /api/v2/knowledge/[id]` */ export type DeleteKnowledgeBaseParams = { id: string @@ -259,6 +484,38 @@ export type DeleteKnowledgeDocumentResponse = { } } +/** `DELETE /api/v2/mcp-servers/[id]` */ +export type DeleteMcpServerParams = { + id: string +} + +export type DeleteMcpServerQuery = { + workspaceId: string +} + +export type DeleteMcpServerResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/skills/[id]` */ +export type DeleteSkillParams = { + id: string +} + +export type DeleteSkillQuery = { + workspaceId: string +} + +export type DeleteSkillResponse = { + data: { + id: string + deleted: true + } +} + /** `DELETE /api/v2/tables/[tableId]` */ export type DeleteTableParams = { tableId: string @@ -581,6 +838,66 @@ export type GetAuditLogResponse = { } } +/** `GET /api/v2/credentials/[id]` */ +export type GetCredentialParams = { + id: string +} + +export type GetCredentialQuery = { + workspaceId: string +} + +export type GetCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `GET /api/v2/custom-tools/[id]` */ +export type GetCustomToolParams = { + id: string +} + +export type GetCustomToolQuery = { + workspaceId: string +} + +export type GetCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + /** `GET /api/v2/logs/executions/[executionId]` */ export type GetExecutionParams = { executionId: string @@ -603,6 +920,32 @@ export type GetExecutionResponse = { } } +/** `GET /api/v2/folders/[id]` */ +export type GetFolderParams = { + id: string +} + +export type GetFolderQuery = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' +} + +export type GetFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + /** `GET /api/v2/knowledge/[id]` */ export type GetKnowledgeBaseParams = { id: string @@ -710,6 +1053,65 @@ export type GetLogResponse = { } } +/** `GET /api/v2/mcp-servers/[id]` */ +export type GetMcpServerParams = { + id: string +} + +export type GetMcpServerQuery = { + workspaceId: string +} + +export type GetMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + +/** `GET /api/v2/skills/[id]` */ +export type GetSkillParams = { + id: string +} + +export type GetSkillQuery = { + workspaceId: string +} + +export type GetSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + /** `GET /api/v2/tables/[tableId]` */ export type GetTableParams = { tableId: string @@ -921,6 +1323,58 @@ export type ListAuditLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/credentials` */ +export type ListCredentialsQuery = { + workspaceId: string + type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + providerId?: string +} + +export type ListCredentialsResponse = { + data: Array<{ + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/custom-tools` */ +export type ListCustomToolsQuery = { + workspaceId: string +} + +export type ListCustomToolsResponse = { + data: Array<{ + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/files` */ export type ListFilesQuery = { workspaceId: string @@ -941,21 +1395,43 @@ export type ListFilesResponse = { nextCursor: string | null } -/** `GET /api/v2/knowledge` */ -export type ListKnowledgeBasesQuery = { +/** `GET /api/v2/folders` */ +export type ListFoldersQuery = { workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + scope?: 'active' | 'archived' } -export type ListKnowledgeBasesResponse = { +export type ListFoldersResponse = { data: Array<{ id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' name: string - description: string | null - tokenCount: number - embeddingModel: string - embeddingDimension: number - chunkingConfig: { - maxSize: number + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + }> + nextCursor: string | null +} + +/** `GET /api/v2/knowledge` */ +export type ListKnowledgeBasesQuery = { + workspaceId: string +} + +export type ListKnowledgeBasesResponse = { + data: Array<{ + id: string + name: string + description: string | null + tokenCount: number + embeddingModel: string + embeddingDimension: number + chunkingConfig: { + maxSize: number minSize: number overlap: number strategy?: 'auto' | 'text' | 'regex' | 'recursive' | 'sentence' | 'token' @@ -1063,6 +1539,54 @@ export type ListLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/mcp-servers` */ +export type ListMcpServersQuery = { + workspaceId: string +} + +export type ListMcpServersResponse = { + data: Array<{ + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + }> + nextCursor: string | null +} + +/** `GET /api/v2/skills` */ +export type ListSkillsQuery = { + workspaceId: string +} + +export type ListSkillsResponse = { + data: Array<{ + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/rows` */ export type ListTableRowsParams = { tableId: string @@ -1321,6 +1845,120 @@ export type UndeployWorkflowResponse = { } } +/** `PATCH /api/v2/credentials/[id]` */ +export type UpdateCredentialParams = { + id: string +} + +export type UpdateCredentialBody = { + workspaceId: string + displayName?: string + description?: string | null + serviceAccountJson?: string + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + clientId?: string + clientSecret?: string + orgId?: string +} + +export type UpdateCredentialResponse = { + data: { + credential: { + id: string + type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + displayName: string + description: string | null + providerId: string | null + accountId: string | null + envKey: string | null + hasServiceAccountKey: boolean + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/custom-tools/[id]` */ +export type UpdateCustomToolParams = { + id: string +} + +export type UpdateCustomToolBody = { + workspaceId: string + title?: string + schema?: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code?: string +} + +export type UpdateCustomToolResponse = { + data: { + customTool: { + id: string + title: string + schema: { + type: 'function' + function: { + name: string + description?: string + parameters: { + type: string + properties: Record + required?: Array + } + } + } + code: string + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/folders/[id]` */ +export type UpdateFolderParams = { + id: string +} + +export type UpdateFolderBody = { + workspaceId: string + resourceType: 'workflow' | 'knowledge_base' | 'table' + name?: string + locked?: boolean + parentId?: string | null + sortOrder?: number +} + +export type UpdateFolderResponse = { + data: { + folder: { + id: string + resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' + name: string + parentId: string | null + locked: boolean + sortOrder: number + createdAt: string + updatedAt: string + deletedAt: string | null + } + } +} + /** `PUT /api/v2/knowledge/[id]` */ export type UpdateKnowledgeBaseParams = { id: string @@ -1366,6 +2004,53 @@ export type UpdateKnowledgeBaseResponse = { } } +/** `PATCH /api/v2/mcp-servers/[id]` */ +export type UpdateMcpServerParams = { + id: string +} + +export type UpdateMcpServerBody = { + workspaceId: string + name?: string + description?: string + transport?: 'streamable-http' + url?: string + authType?: 'none' | 'headers' | 'oauth' + headers?: Record + timeout?: number + retries?: number + enabled?: boolean + oauthClientId?: string | null + oauthClientSecret?: string | null +} + +export type UpdateMcpServerResponse = { + data: { + mcpServer: { + id: string + name: string + description?: string + transport: 'streamable-http' + authType?: 'none' | 'headers' | 'oauth' + url?: string + timeout?: number + retries?: number + enabled: boolean + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string | null + toolCount?: number + lastToolsRefresh?: string + lastConnected?: string + createdAt: string + updatedAt: string + oauthClientId?: string + hasHeaders: boolean + headerNames: Array + hasOauthClientSecret: boolean + } + } +} + /** `PUT /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -1385,6 +2070,32 @@ export type UpdateRowsByFilterResponse = { } } +/** `PATCH /api/v2/skills/[id]` */ +export type UpdateSkillParams = { + id: string +} + +export type UpdateSkillBody = { + workspaceId: string + name?: string + description?: string + content?: string +} + +export type UpdateSkillResponse = { + data: { + skill: { + id: string + name: string + description: string + readOnly: boolean + createdAt: string + updatedAt: string + content: string + } + } +} + /** `PATCH /api/v2/tables/[tableId]/columns` */ export type UpdateTableColumnParams = { tableId: string @@ -1546,6 +2257,64 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel an execution', }, + createCredential: { + method: 'POST', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Credential', + body: { + workspaceId: { kind: 'string', required: true }, + type: { + kind: 'enum', + required: true, + values: ['env_workspace', 'env_personal', 'service_account'] as const, + }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + providerId: { kind: 'string' }, + envKey: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + orgId: { kind: 'string' }, + }, + }, + createCustomTool: { + method: 'POST', + path: '/api/v2/custom-tools', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Custom Tool', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string', required: true }, + schema: { kind: 'object', required: true }, + code: { kind: 'string', required: true }, + }, + }, + createFolder: { + method: 'POST', + path: '/api/v2/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + name: { kind: 'string', required: true }, + parentId: { kind: 'string' }, + sortOrder: { kind: 'integer' }, + }, + }, createKnowledgeBase: { method: 'POST', path: '/api/v2/knowledge', @@ -1559,6 +2328,40 @@ export const V2_OPERATIONS = { chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, }, }, + createMcpServer: { + method: 'POST', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create MCP Server', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + transport: { kind: 'enum', values: ['streamable-http'] as const }, + url: { kind: 'string', required: true }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer' }, + retries: { kind: 'integer' }, + enabled: { kind: 'boolean' }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, + createSkill: { + method: 'POST', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Skill', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string', required: true }, + content: { kind: 'string', required: true }, + }, + }, createTable: { method: 'POST', path: '/api/v2/tables', @@ -1580,6 +2383,26 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Create Rows', }, + deleteCredential: { + method: 'DELETE', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Credential', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteCustomTool: { + method: 'DELETE', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Custom Tool', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, deleteFile: { method: 'DELETE', path: '/api/v2/files/[fileId]', @@ -1590,6 +2413,21 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteFolder: { + method: 'DELETE', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + }, + }, deleteKnowledgeBase: { method: 'DELETE', path: '/api/v2/knowledge/[id]', @@ -1610,6 +2448,26 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteMcpServer: { + method: 'DELETE', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete MCP Server', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteSkill: { + method: 'DELETE', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Skill', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, deleteTable: { method: 'DELETE', path: '/api/v2/tables/[tableId]', @@ -1702,6 +2560,26 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Audit Log', }, + getCredential: { + method: 'GET', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Credential', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getCustomTool: { + method: 'GET', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Custom Tool', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getExecution: { method: 'GET', path: '/api/v2/logs/executions/[executionId]', @@ -1709,6 +2587,21 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Execution', }, + getFolder: { + method: 'GET', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Folder', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + }, + }, getKnowledgeBase: { method: 'GET', path: '/api/v2/knowledge/[id]', @@ -1736,6 +2629,26 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Log', }, + getMcpServer: { + method: 'GET', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get MCP Server', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getSkill: { + method: 'GET', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Get Skill', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getTable: { method: 'GET', path: '/api/v2/tables/[tableId]', @@ -1817,6 +2730,31 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listCredentials: { + method: 'GET', + path: '/api/v2/credentials', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Credentials', + query: { + workspaceId: { kind: 'string', required: true }, + type: { + kind: 'enum', + values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, + }, + providerId: { kind: 'string' }, + }, + }, + listCustomTools: { + method: 'GET', + path: '/api/v2/custom-tools', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Custom Tools', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, listFiles: { method: 'GET', path: '/api/v2/files', @@ -1829,6 +2767,22 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listFolders: { + method: 'GET', + path: '/api/v2/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + }, + }, listKnowledgeBases: { method: 'GET', path: '/api/v2/knowledge', @@ -1899,6 +2853,26 @@ export const V2_OPERATIONS = { order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, }, }, + listMcpServers: { + method: 'GET', + path: '/api/v2/mcp-servers', + pathParams: [] as const, + responseMode: 'json', + summary: 'List MCP Servers', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + listSkills: { + method: 'GET', + path: '/api/v2/skills', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Skills', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, listTableRows: { method: 'GET', path: '/api/v2/tables/[tableId]/rows', @@ -2011,6 +2985,58 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Undeploy Workflow', }, + updateCredential: { + method: 'PATCH', + path: '/api/v2/credentials/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Credential', + body: { + workspaceId: { kind: 'string', required: true }, + displayName: { kind: 'string' }, + description: { kind: 'string' }, + serviceAccountJson: { kind: 'string' }, + signingSecret: { kind: 'string' }, + botToken: { kind: 'string' }, + apiToken: { kind: 'string' }, + domain: { kind: 'string' }, + clientId: { kind: 'string' }, + clientSecret: { kind: 'string' }, + orgId: { kind: 'string' }, + }, + }, + updateCustomTool: { + method: 'PATCH', + path: '/api/v2/custom-tools/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Custom Tool', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string' }, + schema: { kind: 'object' }, + code: { kind: 'string' }, + }, + }, + updateFolder: { + method: 'PATCH', + path: '/api/v2/folders/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Folder', + body: { + workspaceId: { kind: 'string', required: true }, + resourceType: { + kind: 'enum', + required: true, + values: ['workflow', 'knowledge_base', 'table'] as const, + }, + name: { kind: 'string' }, + locked: { kind: 'boolean' }, + parentId: { kind: 'string' }, + sortOrder: { kind: 'integer' }, + }, + }, updateKnowledgeBase: { method: 'PUT', path: '/api/v2/knowledge/[id]', @@ -2024,6 +3050,27 @@ export const V2_OPERATIONS = { chunkingConfig: { kind: 'object' }, }, }, + updateMcpServer: { + method: 'PATCH', + path: '/api/v2/mcp-servers/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update MCP Server', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + transport: { kind: 'enum', values: ['streamable-http'] as const }, + url: { kind: 'string' }, + authType: { kind: 'enum', values: ['none', 'headers', 'oauth'] as const }, + headers: { kind: 'object' }, + timeout: { kind: 'integer' }, + retries: { kind: 'integer' }, + enabled: { kind: 'boolean' }, + oauthClientId: { kind: 'string' }, + oauthClientSecret: { kind: 'string' }, + }, + }, updateRowsByFilter: { method: 'PUT', path: '/api/v2/tables/[tableId]/rows', @@ -2037,6 +3084,19 @@ export const V2_OPERATIONS = { limit: { kind: 'integer' }, }, }, + updateSkill: { + method: 'PATCH', + path: '/api/v2/skills/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Skill', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + description: { kind: 'string' }, + content: { kind: 'string' }, + }, + }, updateTableColumn: { method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 8542593159b..af5189b9aab 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { resolvePath, SimApiError } from './client.js' @@ -89,3 +90,33 @@ describe('generated operation table', () => { } }) }) + +describe('destructive operations are gated', () => { + /** + * `DELETE /workflows/[id]/deploy` is an undeploy — reversible by redeploying, + * and the contract renames it accordingly. Everything else that deletes is + * gated behind `--yes`. + */ + const NOT_DESTRUCTIVE = new Set(['undeployWorkflow']) + + it('every DELETE carries a confirmation message', () => { + // Without this, a new v2 domain arrives through generation with working + // delete commands and no gate — which is exactly what happened when the + // MCP/skills/folders/credentials endpoints landed. + const ungated = (Object.keys(V2_OPERATIONS) as V2OperationName[]).filter( + (name) => + V2_OPERATIONS[name].method === 'DELETE' && + !NOT_DESTRUCTIVE.has(name) && + !CLI_CONTRACT[name]?.confirm + ) + expect(ungated).toEqual([]) + }) + + it('states what is destroyed, not just that something is', () => { + for (const [name, spec] of Object.entries(CLI_CONTRACT)) { + if (!spec?.confirm) continue + expect(spec.confirm, name).toMatch(/^This /) + expect(spec.confirm.length, name).toBeGreaterThan(20) + } + }) +}) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index ea0359d1883..67d3c2c741d 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -27,7 +27,7 @@ */ import { spawnSync } from 'node:child_process' -import { readFileSync, writeFileSync } from 'node:fs' +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { z } from 'zod' @@ -36,15 +36,30 @@ const CONTRACTS_DIR = path.join(ROOT, 'apps/sim/lib/api/contracts/v2') const OUTPUT = path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts') const DOCS_DIR = path.join(ROOT, 'apps/docs') -/** OpenAPI documents to read operation summaries from. */ -const SPEC_FILES = [ - 'openapi-core.json', - 'openapi-v2-workflows.json', - 'openapi-v2-logs.json', - 'openapi-v2-tables.json', - 'openapi-v2-knowledge.json', - 'openapi-v2-files-audit.json', -] as const +/** + * OpenAPI documents to read operation summaries from, discovered rather than + * listed — same reason as {@link contractModules}. + * + * A new spec file (`openapi-v2-resources.json` arrived with the MCP/skills/ + * folders/credentials endpoints) would otherwise go unread, and the only symptom + * would be `--help` quietly falling back to `METHOD /path` for a whole domain. + * + * `openapi.json` is the retired single-document spec, superseded by the split + * files; it is excluded by name because it still exists on disk and would + * contribute stale duplicates. + */ +function specFiles(): string[] { + return readdirSync(DOCS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith('openapi') && + entry.name.endsWith('.json') && + entry.name !== 'openapi.json' + ) + .map((entry) => entry.name) + .sort() +} /** * `METHOD /api/v2/{id}/…` → the spec's one-line summary. @@ -58,7 +73,7 @@ const SPEC_FILES = [ function loadSummaries(): Map { const summaries = new Map() - for (const file of SPEC_FILES) { + for (const file of specFiles()) { let spec: Record try { spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) @@ -81,16 +96,30 @@ function loadSummaries(): Map { return summaries } -/** Contract modules to read, in emit order. */ -const DOMAINS = [ - 'workflows', - 'logs', - 'tables', - 'files', - 'knowledge', - 'audit-logs', - 'billing', -] as const +/** + * Every contract module under `contracts/v2`, discovered rather than listed. + * + * A hardcoded list is the wrong shape for this: adding a v2 domain would leave + * its operations silently absent from the CLI, with no error and nothing in + * `--check` to notice, because the generated file would still match a generator + * that never looked. Discovery makes a new domain appear on the next + * regeneration, which is the property the whole pipeline is built on. + * + * `shared.ts` holds the response-envelope helpers, not contracts; it is skipped + * because it exports no route contract, not because it is named here. + */ +function contractModules(): string[] { + return readdirSync(CONTRACTS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.endsWith('.test.ts') && + entry.name !== 'index.ts' + ) + .map((entry) => entry.name.replace(/\.ts$/, '')) + .sort() +} interface RouteContract { method: string @@ -132,7 +161,7 @@ function pascal(name: string): string { async function collectOperations(): Promise { const operations: Operation[] = [] - for (const domain of DOMAINS) { + for (const domain of contractModules()) { const mod: Record = await import(path.join(CONTRACTS_DIR, `${domain}.ts`)) for (const [exportName, value] of Object.entries(mod)) { if (!exportName.endsWith('Contract') || !isRouteContract(value)) continue @@ -440,7 +469,7 @@ async function main() { writeFileSync(OUTPUT, generated) console.log( - `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${DOMAINS.length} contract modules.` + `Wrote ${path.relative(ROOT, OUTPUT)} — ${operations.length} operations from ${contractModules().length} contract modules.` ) } From 344a01222154983c1de09f56718f65cb6be0d287 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 00:55:01 -0700 Subject: [PATCH 17/28] fix(cli): render single-key resource envelopes, and column the new domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim mcp-servers create` created the server, exited 0, and printed nothing. The v2 route answers `{ data: { mcpServer: {...} } }`, and the record renderer keeps only scalar fields — one key holding an object left it with none. Unwrap a lone object-valued key before rendering; a payload with siblings (`{ row, operation }` from upsert) is a real result and is left alone. The five domains that arrived with the last generation had no contract columns, so `mcp-servers list` inferred 20 including `hasOauthClientSecret`. Give each a column set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 46 +++++++++++++++++ packages/sim-cli/src/runtime/build.test.ts | 60 +++++++++++++++++++++- packages/sim-cli/src/runtime/build.ts | 21 +++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index e9642ac3eda..a7d67728ff9 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -145,6 +145,52 @@ export const CLI_CONTRACT: CliContract = { { header: 'chunks', path: 'chunkCount' }, ], }, + // Without these the inferred fallback dumps every scalar field — 20 columns + // for an MCP server, including `hasOauthClientSecret`. + listMcpServers: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'transport' }, + { header: 'url' }, + { header: 'status', path: 'connectionStatus' }, + { header: 'tools', path: 'toolCount' }, + { header: 'enabled', format: 'bool' }, + ], + }, + listSkills: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCustomTools: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'description' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listFolders: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'parent', path: 'parentId' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listCredentials: { + columns: [ + { header: 'id' }, + { header: 'name' }, + { header: 'provider' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listAuditLogs: { columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 1d22e329331..8ec2cdb2880 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -13,12 +13,15 @@ import { buildGeneratedCommands } from './build.js' * catch that class of bug. */ -const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() })) +const { mockRequest, output } = vi.hoisted(() => ({ + mockRequest: vi.fn(), + output: { format: 'json' }, +})) vi.mock('../context.js', () => ({ clientFrom: () => ({ client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, - profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + profile: { workspaceId: 'ws_local', output: output.format, name: 'default', apiKey: 'k' }, }), })) @@ -109,6 +112,59 @@ describe('commands parsed through commander', () => { }) }) +describe('single-resource rendering', () => { + async function lines(argv: string[], data: unknown, format = 'json'): Promise { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data }) + const captured: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + captured.push(line) + }) + output.format = format + try { + await program().parseAsync(['node', 'sim', ...argv]) + } finally { + output.format = 'json' + } + return captured + } + + it('unwraps the single-key envelope a resource is returned in', async () => { + // `createMcpServer` answers `{ data: { mcpServer: {...} } }`. Rendering that + // as-is found one key holding an object, filtered it out as non-scalar, and + // printed nothing at all — the server was created and the CLI said so + // nowhere. Same silent-empty class as the body-cursor bug below. + const printed = await lines( + [ + 'mcp-servers', + 'create', + '--name', + 'Deepwiki', + '--transport', + 'streamable-http', + '--url', + 'https://mcp.deepwiki.com/mcp', + ], + { mcpServer: { id: 'mcp-1', name: 'Deepwiki', enabled: true } }, + 'text' + ) + + expect(printed.join('\n')).toMatch(/mcp-1/) + expect(printed.join('\n')).toMatch(/Deepwiki/) + }) + + it('leaves a payload with sibling keys intact', async () => { + // `upsertTableRow` returns `{ row, operation }` — two real fields, not an + // envelope. Unwrapping there would drop whether it inserted or updated. + const printed = await lines(['tables', 'upsert', 'tbl_1', '--data', '{}'], { + row: { id: 'r1' }, + operation: 'inserted', + }) + + expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) + }) +}) + describe('pagination slot', () => { it('pages a body-cursor operation and renders its rows', async () => { // `queryRows` is a POST whose cursor is in the body, not the query. Reading diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index bb2d4a38a61..499b3afb4b5 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -94,6 +94,25 @@ function inferColumns(rows: unknown[]): Column[] { })) } +/** + * Unwraps the single-key envelope several v2 responses put their resource in — + * `{ mcpServer }`, `{ knowledgeBase }`, `{ row }`, `{ document }`, `{ table }`. + * + * Without this the record renderer sees one key whose value is an object, + * filters it out as non-scalar, and prints nothing at all: `sim mcp-servers + * create` exited 0 having created the server and said nothing about it. + * + * Only a lone key is unwrapped. A payload with siblings (`{ row, operation }` + * from upsert) is a real multi-field result and is rendered as it stands. + */ +function unwrapResource(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data + const entries = Object.entries(data) + if (entries.length !== 1) return data + const [, value] = entries[0] + return value && typeof value === 'object' && !Array.isArray(value) ? value : data +} + /** The operation's one-line help, taken from the OpenAPI summary at generation time. */ function summaryFor(operation: V2OperationName): string | undefined { return (V2_OPERATIONS[operation] as { summary?: string }).summary @@ -263,7 +282,7 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri query: request.query, body: request.body, }) - const data = result?.data ?? result + const data = unwrapResource(result?.data ?? result) if (Array.isArray(data)) { // Reached when a non-paginated operation answers with a collection. From 676bd83f2eb9e520021659287fa911d6ae235d33 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:26:21 -0700 Subject: [PATCH 18/28] fix(cli): stop dropping nested fields, and emit exports as documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim workflows export ` printed `version` and `exportedAt` and nothing else. The record builder kept only scalar fields, so `workflow` and `state` — the entire export — were discarded with nothing to say they had been. Same for `workflows get`, which silently dropped `variables` and `inputs`. Record views now render every field. Nested values serialize to one line and are cut at 160 chars: visibly partial beats silently absent, and json/yaml output still prints them whole. Export is a document, not a record — it exists to be redirected to a file and fed back to `import`, and table/text flatten and truncate, so neither can round-trip it. `document: true` in the contract makes those formats fall back to JSON; yaml is honoured because it round-trips. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 8 ++++ packages/sim-cli/src/contract/types.ts | 9 +++++ packages/sim-cli/src/output/render.ts | 15 ++++++++ packages/sim-cli/src/runtime/build.test.ts | 43 ++++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 37 ++++++++++++++++--- 5 files changed, 107 insertions(+), 5 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a7d67728ff9..de3e2749147 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -200,6 +200,14 @@ export const CLI_CONTRACT: CliContract = { ], }, + // ─── Documents, not records ─────────────────────────────────────────────── + // The payload is the artifact: `sim workflows export > wf.json` has to + // produce something `sim workflows import` accepts back. + exportWorkflow: { + describe: 'Print a workflow as a portable JSON document', + document: true, + }, + // ─── Execution ──────────────────────────────────────────────────────────── // The derived names land badly here: `/execute` and `/cancel` are verbs in // the path, but neither is in the action list, so POST would derive diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 6e11f4cfe32..9158f64813b 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -84,6 +84,15 @@ export interface CommandSpec { * the point is that the caller can tell whether they meant it. */ confirm?: string + /** + * The response IS a document, not a record to look at. + * + * `workflows export` exists to be redirected into a file and fed back to + * `import`, so a key/value view of it is wrong at any fidelity — the useful + * artifact is the payload itself. Document commands emit raw JSON (or YAML + * when the profile says so) whatever the profile's display format is. + */ + document?: boolean /** Keep the operation out of the CLI surface entirely. */ hidden?: boolean } diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 8a2a3eb1d0f..3905bab418c 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -201,6 +201,21 @@ export function printList(format: OutputFormat, rows: T[], columns: Column console.log(renderTable(rows, columns)) } +/** + * Prints a payload whose value IS the deliverable — `workflows export`, which + * is meant to be redirected to a file and fed back to `import`. + * + * `table` and `text` are display formats: they flatten, truncate and colour, so + * neither can round-trip a document. Rather than emit something that looks like + * an export but cannot be re-imported, those two fall back to JSON. Only `yaml` + * is honoured, because it round-trips. + */ +export function printDocument(format: OutputFormat, raw: unknown): void { + console.log( + format === 'yaml' ? (renderMachine('yaml', raw) as string) : JSON.stringify(raw, null, 2) + ) +} + /** Prints a single record: machine formats from the raw value, otherwise aligned lines. */ export function printRecord(format: OutputFormat, fields: Array<[string, string]>, raw: unknown) { const machine = renderMachine(format, raw) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 8ec2cdb2880..958d4741493 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -153,6 +153,49 @@ describe('single-resource rendering', () => { expect(printed.join('\n')).toMatch(/Deepwiki/) }) + it('renders nested fields instead of dropping them', async () => { + // `workflows export` printed `version` and `exportedAt` and nothing else: + // the record builder kept only scalars, so `workflow` and `state` — the + // entire export — vanished with no indication anything was missing. + const printed = await lines( + ['workflows', 'get', 'wf_1'], + { id: 'wf_1', name: 'Onboarding', inputs: [{ name: 'email', type: 'string' }] }, + 'text' + ) + + expect(printed.join('\n')).toMatch(/inputs/) + expect(printed.join('\n')).toMatch(/email/) + }) + + it('truncates a nested value rather than flooding the terminal', async () => { + const printed = await lines( + ['workflows', 'get', 'wf_1'], + { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } }, + 'text' + ) + + const stateLine = printed.find((line) => line.startsWith('state')) ?? '' + expect(stateLine.length).toBeLessThan(300) + expect(stateLine).toMatch(/…$/) + }) + + it('emits a document command as JSON whatever the display format is', async () => { + // Redirecting this to a file has to yield something `import` accepts, so + // `table`/`text` — which flatten and truncate — must not be honoured here. + const printed = await lines( + ['workflows', 'export', 'wf_1'], + { version: '1.0', exportedAt: 'now', workflow: { id: 'wf_1' }, state: { blocks: {} } }, + 'text' + ) + + expect(JSON.parse(printed.join('\n'))).toEqual({ + version: '1.0', + exportedAt: 'now', + workflow: { id: 'wf_1' }, + state: { blocks: {} }, + }) + }) + it('leaves a payload with sibling keys intact', async () => { // `upsertTableRow` returns `{ row, operation }` — two real fields, not an // envelope. Unwrapping there would drop whether it inserted or updated. diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 499b3afb4b5..3fa374b1030 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -8,6 +8,7 @@ import { bytes, type Column, duration, + printDocument, printList, printRecord, sanitize, @@ -56,6 +57,25 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { } } +/** + * How wide a nested value may get before a record line stops being readable. + * A workflow's `state` serializes to tens of kilobytes on one line. + */ +const NESTED_CELL_WIDTH = 160 + +/** + * A field in a record view. + * + * Nested values are rendered, not skipped: a record that quietly omits half of + * what the server sent is worse than a long line, because nothing tells the + * caller anything is missing. Long ones are cut with an ellipsis — visibly + * partial, and `sim configure --set-output json` prints them whole. + */ +function recordCell(value: unknown): string { + const rendered = renderCell(value, 'auto') + return rendered.length > NESTED_CELL_WIDTH ? `${rendered.slice(0, NESTED_CELL_WIDTH)}…` : rendered +} + function columnsFrom(specs: ColumnSpec[]): Column[] { return specs.map((spec) => ({ header: spec.header, @@ -282,7 +302,14 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri query: request.query, body: request.body, }) - const data = unwrapResource(result?.data ?? result) + const raw = result?.data ?? result + + if (spec.document) { + printDocument(profile.output, raw) + return + } + + const data = unwrapResource(raw) if (Array.isArray(data)) { // Reached when a non-paginated operation answers with a collection. @@ -291,11 +318,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri return } + // Every field, nested ones included. Filtering to scalars here is what made + // `workflows export` print its two timestamps and drop the actual workflow. const fields: Array<[string, string]> = - data && typeof data === 'object' && !Array.isArray(data) - ? Object.entries(data) - .filter(([, value]) => value === null || typeof value !== 'object') - .map(([key, value]) => [key, renderCell(value, 'auto')]) + data && typeof data === 'object' + ? Object.entries(data).map(([key, value]) => [key, recordCell(value)]) : [] printRecord(profile.output, fields, data) From 791878472899cea3c0bc26efd3e16e7a6cd534b1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:32:15 -0700 Subject: [PATCH 19/28] feat(cli): JSON flags accept @file and @- alongside inline JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow export is hundreds of lines, and `--workflow` only took it inline. The shell makes that miserable: unquoted `$(cat wf.json)` word-splits into broken JSON, and nothing in the help said passing a file was an option. Every JSON flag now reads `@path`, or `@-` for stdin, so the round trip is `sim workflows export > wf.json` then `import --workflow @wf.json` — or one pipe. `@` cannot collide with a real value because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. Stdin drains with a readSync loop rather than readFileSync(0): a pipe is opened non-blocking, so the single-read form returned EAGAIN and died with a raw stack trace exactly when the upstream process had not written yet. Parse failures that look like a filename now say so — naming @path, or the file itself when the bare value turns out to exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/index.ts | 2 + packages/sim-cli/src/runtime/build.ts | 10 ++- packages/sim-cli/src/runtime/request.test.ts | 50 ++++++++++- packages/sim-cli/src/runtime/request.ts | 92 +++++++++++++++++++- 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index ab5728183bf..6d2185e70d8 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -55,6 +55,8 @@ Examples: $ sim logs list --level error --limit 20 $ sim configure --set-output json Output format is a profile setting $ sim knowledge search "refund policy" --kb kb_123 + $ sim workflows export wf_123 > wf.json JSON flags read files with @ + $ sim workflows import --workflow @wf.json $ sim whoami --profile dev ` ) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3fa374b1030..365a6390fda 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -190,10 +190,14 @@ function addFieldOption( } const takesList = flag.list === true - const placeholder = takesList ? `` : takesJson(descriptor, flag) ? `` : `` + const wantsJson = takesJson(descriptor, flag) + const placeholder = takesList ? `` : wantsJson ? `` : `` const describe = - flag.describe ?? - (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`) + (flag.describe ?? + (descriptor.values ? `One of: ${descriptor.values.join(', ')}` : `Set ${field}`)) + + // Otherwise the only way to discover `@file` is to read the source. A JSON + // document big enough to want a file is exactly when help gets consulted. + (wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '') const option = new Option(`${short}--${name} ${placeholder}`, describe) if (descriptor.values && !takesList) option.choices([...descriptor.values]) diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index d4e8cb12e42..286c7bd8d8b 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -1,7 +1,10 @@ +import { rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { SimApiError } from '../http/client.js' import { deriveCommandPath } from './derive.js' -import { buildRequest } from './request.js' +import { buildRequest, coerce, type FieldSpec } from './request.js' const WORKSPACE = 'ws_local' @@ -140,3 +143,48 @@ describe('repeated flags encode per the field kind, not uniformly', () => { expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) }) }) + +describe('JSON flags that name a file', () => { + const field: FieldSpec = { kind: 'object' } + + it('reads @path', () => { + const path = join(tmpdir(), 'sim-cli-arg.json') + writeFileSync(path, '{"version":"1.0","state":{"blocks":{}}}') + expect(coerce(`@${path}`, field, {}, 'workflow')).toEqual({ + version: '1.0', + state: { blocks: {} }, + }) + rmSync(path) + }) + + it('still accepts inline JSON', () => { + expect(coerce('{"a":1}', field, {}, 'workflow')).toEqual({ a: 1 }) + }) + + it('names the file it could not read', () => { + expect(() => coerce('@/nope/missing.json', field, {}, 'workflow')).toThrow( + /cannot read \/nope\/missing\.json/ + ) + }) + + it('says which file the bad JSON came from', () => { + const path = join(tmpdir(), 'sim-cli-bad.json') + writeFileSync(path, 'not json') + expect(() => coerce(`@${path}`, field, {}, 'workflow')).toThrow(/read from .*sim-cli-bad\.json/) + rmSync(path) + }) + + it('points at @ when a bare filename was passed instead', () => { + // `--workflow export.json` is the natural first guess; "must be valid JSON" + // alone never reveals that passing a file is supported at all. + const path = join(tmpdir(), 'sim-cli-bare.json') + writeFileSync(path, '{}') + expect(() => coerce(path, field, {}, 'workflow')).toThrow(new RegExp(`pass it as @${path}`)) + rmSync(path) + expect(() => coerce('export.json', field, {}, 'workflow')).toThrow(/pass @path/) + }) + + it('does not suggest a path for malformed inline JSON', () => { + expect(() => coerce('{"a":', field, {}, 'workflow')).not.toThrow(/@path/) + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 44f4393fed4..ccda57dbc89 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync, readSync } from 'node:fs' import { CLI_CONTRACT } from '../contract/commands.js' import type { FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' @@ -37,6 +38,89 @@ export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { return flag.json === true || JSON_KINDS.has(field.kind) } +/** + * Drains stdin synchronously. + * + * `readFileSync(0)` looks like the obvious way to do this and fails on the one + * case that matters: a pipe is opened non-blocking, so a single read of an + * upstream process that has not written yet returns EAGAIN rather than waiting, + * and `export … | import --workflow @-` died with a raw stack trace. Reading in + * a loop and treating EAGAIN as "not ready yet" is what makes a pipe work. + * + * `Atomics.wait` is the only synchronous sleep available; without it the retry + * spins a core for as long as the writer takes. + */ +function readStdin(): string { + const idle = new Int32Array(new SharedArrayBuffer(4)) + const buffer = Buffer.alloc(64 * 1024) + const chunks: Buffer[] = [] + + for (;;) { + let read: number + try { + read = readSync(0, buffer, 0, buffer.length, null) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EAGAIN') { + Atomics.wait(idle, 0, 0, 5) + continue + } + // Some platforms report end-of-input on a pipe as EOF rather than 0. + if (code === 'EOF') break + throw error + } + if (read === 0) break + chunks.push(Buffer.from(buffer.subarray(0, read))) + } + + return Buffer.concat(chunks).toString('utf8') +} + +/** + * Resolves a JSON flag's argument, which may name a file instead of carrying + * the document inline. + * + * `@path` reads the file and `@-` reads stdin, the curl convention. A workflow + * export is hundreds of lines, and the shell makes passing that literally + * unpleasant — unquoted `$(cat f.json)` word-splits into broken JSON, and the + * quoted form is easy to get wrong. `@` cannot collide with a real value + * because JSON only ever starts with `{ [ " -`, a digit, or t/f/n. + */ +function readJsonArgument(raw: string, flagName: string): { text: string; from: string } { + if (!raw.startsWith('@')) return { text: raw, from: '' } + + const path = raw.slice(1) + if (path === '-') { + if (process.stdin.isTTY) { + throw new SimApiError(`--${flagName} @- reads stdin, but nothing is piped in`, 0) + } + try { + return { text: readStdin(), from: ' (read from stdin)' } + } catch (error) { + throw new SimApiError(`--${flagName} cannot read stdin: ${(error as Error).message}`, 0) + } + } + + try { + return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` } + } catch (error) { + throw new SimApiError(`--${flagName} cannot read ${path}: ${(error as Error).message}`, 0) + } +} + +/** + * Points at `@` when a value that failed to parse looks like a filename. + * + * `--workflow export.json` is the natural first guess, and "must be valid JSON" + * alone gives no clue that passing a file is even supported. + */ +function pathHint(raw: string): string { + if (raw.startsWith('@') || /^\s*[[{"\-\d]|^\s*(true|false|null)/.test(raw)) return '' + return existsSync(raw) + ? `. ${raw} is a file — pass it as @${raw}` + : '. To read a file, pass @path (or @- for stdin)' +} + /** * Turns the string argv provides into the value the contract expects. * @@ -66,10 +150,14 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: if (takesJson(field, flag)) { if (typeof raw !== 'string') return raw + const source = readJsonArgument(raw, flagName) try { - return JSON.parse(raw) + return JSON.parse(source.text) } catch (error) { - throw new SimApiError(`--${flagName} must be valid JSON: ${(error as Error).message}`, 0) + throw new SimApiError( + `--${flagName} must be valid JSON${source.from}: ${(error as Error).message}${pathHint(raw)}`, + 0 + ) } } From eb9c1bb8016e089aff1ad7597b29a1133409e7fc Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:29:28 -0700 Subject: [PATCH 20/28] feat(cli): wire the expanded v2 files surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regeneration picked up seven new operations (72 → 79), every one of which derived badly. `/files/move` and `/files/bulk-archive` put a verb where the deriver expects a sub-resource, so each became a group holding a lone `create`; `GET /files/[id]/share` fetches one share and was read as a collection and named `list`; and `PATCH /files/[id]` derived to `files update` while its own summary said "Rename File". Named them: batch-archive (matching tables rows batch-delete), move, rename, restore, set-content, share get, share set. Bulk archive is gated behind --yes like the other batch destructives. `files list` gained --scope active|archived, and its rows now carry folderPath — added as a column, since which folder a file sits in is what distinguishes two rows sharing a name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 40 ++++ packages/sim-cli/src/generated/v2-api.ts | 246 ++++++++++++++++++++++ 2 files changed, 286 insertions(+) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index de3e2749147..f94b5ed2466 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -122,6 +122,9 @@ export const CLI_CONTRACT: CliContract = { columns: [ { header: 'id' }, { header: 'name' }, + // Now that files live in folders, which one is the difference between two + // identically-named rows. + { header: 'folder', path: 'folderPath' }, { header: 'size', format: 'bytes' }, { header: 'type' }, { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, @@ -200,6 +203,43 @@ export const CLI_CONTRACT: CliContract = { ], }, + // ─── The expanded files surface ─────────────────────────────────────────── + // Every one of these derives badly. `/files/move` and `/files/bulk-archive` + // are verbs sitting where the deriver expects a sub-resource, so it made them + // groups holding a lone `create`; and `GET /files/[id]/share` fetches one + // share, which the deriver read as a collection and named `list`. + bulkArchiveFileItems: { + // `batch-` for the bulk form, matching `tables rows batch-delete`. + command: 'files batch-archive', + describe: 'Archive several files and folders at once', + confirm: 'This archives every listed file and folder, and everything inside those folders.', + }, + moveFileItems: { + command: 'files move', + describe: 'Move files and folders into another folder', + }, + renameFile: { + // Derived to `files update`, which contradicted its own summary. + command: 'files rename', + describe: 'Rename a file', + }, + restoreFile: { + command: 'files restore', + describe: 'Restore an archived file', + }, + updateFileContent: { + command: 'files set-content', + describe: 'Replace a file’s contents', + }, + getFileShare: { + command: 'files share get', + describe: 'Show a file’s share settings', + }, + upsertFileShare: { + command: 'files share set', + describe: 'Enable or disable sharing for a file', + }, + // ─── Documents, not records ─────────────────────────────────────────────── // The payload is the artifact: `sim workflows export > wf.json` has to // produce something `sim workflows import` accepts back. diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 895dbcdae3d..e31c6f1df5b 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -52,6 +52,22 @@ export type AddTableColumnResponse = { } } +/** `POST /api/v2/files/bulk-archive` */ +export type BulkArchiveFileItemsBody = { + workspaceId: string + fileIds?: Array + folderIds?: Array +} + +export type BulkArchiveFileItemsResponse = { + data: { + deletedItems: { + files: number + folders: number + } + } +} + /** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ export type CancelWorkflowExecutionParams = { id: string @@ -920,6 +936,31 @@ export type GetExecutionResponse = { } } +/** `GET /api/v2/files/[fileId]/share` */ +export type GetFileShareParams = { + fileId: string +} + +export type GetFileShareQuery = { + workspaceId: string +} + +export type GetFileShareResponse = { + data: { + share: { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array + } | null + } +} + /** `GET /api/v2/folders/[id]` */ export type GetFolderParams = { id: string @@ -1378,6 +1419,7 @@ export type ListCustomToolsResponse = { /** `GET /api/v2/files` */ export type ListFilesQuery = { workspaceId: string + scope?: 'active' | 'archived' limit?: number cursor?: string } @@ -1389,8 +1431,11 @@ export type ListFilesResponse = { size: number type: string key: string + folderId: string | null + folderPath: string | null uploadedBy: string uploadedAt: string + updatedAt: string }> nextCursor: string | null } @@ -1708,6 +1753,23 @@ export type ListWorkflowsResponse = { nextCursor: string | null } +/** `POST /api/v2/files/move` */ +export type MoveFileItemsBody = { + workspaceId: string + fileIds?: Array + folderIds?: Array + targetFolderId?: string | null +} + +export type MoveFileItemsResponse = { + data: { + movedItems: { + files: number + folders: number + } + } +} + /** `POST /api/v2/tables/[tableId]/query` */ export type QueryRowsParams = { tableId: string @@ -1734,6 +1796,47 @@ export type QueryRowsResponse = { nextCursor: string | null } +/** `PATCH /api/v2/files/[fileId]` */ +export type RenameFileParams = { + fileId: string +} + +export type RenameFileBody = { + workspaceId: string + name: string +} + +export type RenameFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + +/** `POST /api/v2/files/[fileId]/restore` */ +export type RestoreFileParams = { + fileId: string +} + +export type RestoreFileBody = { + workspaceId: string +} + +export type RestoreFileResponse = { + data: { + id: string + restored: true + } +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -1929,6 +2032,32 @@ export type UpdateCustomToolResponse = { } } +/** `PUT /api/v2/files/[fileId]/content` */ +export type UpdateFileContentParams = { + fileId: string +} + +export type UpdateFileContentBody = { + workspaceId: string + content: string + encoding?: 'utf-8' | 'base64' +} + +export type UpdateFileContentResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `PATCH /api/v2/folders/[id]` */ export type UpdateFolderParams = { id: string @@ -2162,6 +2291,7 @@ export type UpdateTableRowResponse = { /** `POST /api/v2/files` */ export type UploadFileQuery = { workspaceId: string + folderId?: string } export type UploadFileResponse = { @@ -2171,8 +2301,11 @@ export type UploadFileResponse = { size: number type: string key: string + folderId: string | null + folderPath: string | null uploadedBy: string uploadedAt: string + updatedAt: string } } @@ -2203,6 +2336,35 @@ export type UploadKnowledgeDocumentResponse = { } } +/** `PUT /api/v2/files/[fileId]/share` */ +export type UpsertFileShareParams = { + fileId: string +} + +export type UpsertFileShareBody = { + workspaceId: string + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +export type UpsertFileShareResponse = { + data: { + share: { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array + } + } +} + /** `POST /api/v2/tables/[tableId]/rows/upsert` */ export type UpsertTableRowParams = { tableId: string @@ -2250,6 +2412,18 @@ export const V2_OPERATIONS = { column: { kind: 'object', required: true }, }, }, + bulkArchiveFileItems: { + method: 'POST', + path: '/api/v2/files/bulk-archive', + pathParams: [] as const, + responseMode: 'json', + summary: 'Archive Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + }, + }, cancelWorkflowExecution: { method: 'POST', path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', @@ -2587,6 +2761,16 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Execution', }, + getFileShare: { + method: 'GET', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Get File Share', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getFolder: { method: 'GET', path: '/api/v2/folders/[id]', @@ -2763,6 +2947,7 @@ export const V2_OPERATIONS = { summary: 'List Files', query: { workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, }, @@ -2942,6 +3127,19 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + moveFileItems: { + method: 'POST', + path: '/api/v2/files/move', + pathParams: [] as const, + responseMode: 'json', + summary: 'Move Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + targetFolderId: { kind: 'string' }, + }, + }, queryRows: { method: 'POST', path: '/api/v2/tables/[tableId]/query', @@ -2956,6 +3154,27 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + renameFile: { + method: 'PATCH', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Rename File', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + }, + }, + restoreFile: { + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Restore File', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -3018,6 +3237,18 @@ export const V2_OPERATIONS = { code: { kind: 'string' }, }, }, + updateFileContent: { + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Replace File Content', + body: { + workspaceId: { kind: 'string', required: true }, + content: { kind: 'string', required: true }, + encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, + }, + }, updateFolder: { method: 'PATCH', path: '/api/v2/folders/[id]', @@ -3128,6 +3359,7 @@ export const V2_OPERATIONS = { summary: 'Upload File', query: { workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, }, }, uploadKnowledgeDocument: { @@ -3140,6 +3372,20 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + upsertFileShare: { + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, + responseMode: 'json', + summary: 'Enable or Disable File Share', + body: { + workspaceId: { kind: 'string', required: true }, + isActive: { kind: 'boolean', required: true }, + authType: { kind: 'enum', values: ['public', 'password', 'email', 'sso'] as const }, + password: { kind: 'string' }, + allowedEmails: { kind: 'array' }, + }, + }, upsertTableRow: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', From 5154a92ec89ea6692ec828c24798dc0a97fc1373 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:43:08 -0700 Subject: [PATCH 21/28] feat(cli): sim files upload The counterpart to `files download`, and hand-written for the same reason: POST /api/v2/files is multipart, which the generated flag surface cannot express, so `uploadFile` has been hidden since the start. Reads the file with openAsBlob so it stays on disk while the request is written, rather than buffering the whole upload in memory. Size is checked against the route's own 100MB ceiling before anything is sent. Content type comes from the extension, since the stored type decides whether the workspace later renders a file or offers it for download. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/commands/hand-written.ts | 103 +++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 4afce929849..75910fc356a 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -1,5 +1,6 @@ import { once } from 'node:events' -import { createWriteStream, type WriteStream } from 'node:fs' +import { createWriteStream, openAsBlob, type WriteStream } from 'node:fs' +import { stat } from 'node:fs/promises' import { basename } from 'node:path' import chalk from 'chalk' import type { Command } from 'commander' @@ -116,7 +117,107 @@ function group(program: Command, name: string): Command { return created } +/** + * The server stores whatever content type the part carries, falling back to + * `application/octet-stream`, and that type is what later decides whether the + * workspace renders a file or offers it as a download. Node does not ship a + * mime table, so the common cases are listed and everything else falls back. + */ +const CONTENT_TYPES: Record = { + css: 'text/css', + csv: 'text/csv', + gif: 'image/gif', + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript', + json: 'application/json', + md: 'text/markdown', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml', + txt: 'text/plain', + webp: 'image/webp', + yaml: 'application/yaml', + yml: 'application/yaml', + zip: 'application/zip', +} + +function contentTypeFor(name: string): string { + const dot = name.lastIndexOf('.') + const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase() + return CONTENT_TYPES[extension] ?? 'application/octet-stream' +} + +/** The route's own ceiling. Checked here so a 100 MB body is never sent to be refused. */ +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 + export function attachHandWritten(program: Command): void { + // ── files upload ── multipart, which the generated flag surface cannot express ── + group(program, 'files') + .command('upload ') + .description('Upload a file to the workspace') + .option('--folder-id ', 'Target folder (defaults to the workspace root)') + .option('--name ', 'Store it under a different name') + .action( + async (path: string, options: { folderId?: string; name?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (!profile.apiKey) { + throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) + } + + let size: number + try { + const stats = await stat(path) + if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + + // Fail here rather than after streaming 100 MB the server will reject. + if (size > MAX_UPLOAD_BYTES) { + throw new SimApiError( + `${path} is ${(size / 1024 / 1024).toFixed(1)}MB; the limit is 100MB`, + 0 + ) + } + + const name = options.name ?? basename(path) + const url = new URL(`${profile.endpoint}/api/v2/files`) + url.searchParams.set('workspaceId', workspaceId) + if (options.folderId) url.searchParams.set('folderId', options.folderId) + + // `openAsBlob` keeps the file on disk and reads it as the request is + // written; building a Buffer first would hold the whole upload in memory. + const body = new FormData() + body.append('file', await openAsBlob(path, { type: contentTypeFor(name) }), name) + + const response = await fetch(url, { + method: 'POST', + headers: { 'x-api-key': profile.apiKey }, + body, + }) + + const payload = (await response.json().catch(() => null)) as { + data?: { id?: string } + error?: { message?: string } + } | null + + if (!response.ok) { + throw new SimApiError( + payload?.error?.message ?? `Upload failed with status ${response.status}`, + response.status + ) + } + + console.log(chalk.green(`✓ Uploaded ${name} (${payload?.data?.id ?? 'unknown id'})`)) + } + ) + // ── files download ── the response is binary, not the JSON envelope ──────── group(program, 'files') .command('download ') From b97987ee55800bdad5861131ee56b17b95de23a0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:56:28 -0700 Subject: [PATCH 22/28] fix(cli): make tables rows query show the rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things stacked up so the command appeared to do nothing. A row's cells live under `data`, and column inference skips object-valued fields — so the table came back listing an id and two timestamps per row and none of the content the query was run for. `expand` names the wrapper whose keys become columns, unioned across the page like the top-level ones. A cell key that shadows a top-level field is shown by its full path, so two different values never share a header. A cell containing a newline pushed the rest of its row onto the next line and every column after it lost alignment; in text mode a tab invented a field that `cut -f` reads as real. Display cells are now flattened to one line. `sanitize` still keeps \t and \n — json and yaml must round-trip them, and this is applied only to finished cells. A single cell holding an LLM response set the column width for the whole table and pushed everything after it off-screen, so table cells clamp at 60 columns. text/json/yaml are untouched: those exist for the whole value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/contract/commands.ts | 3 ++ packages/sim-cli/src/contract/types.ts | 10 ++++ packages/sim-cli/src/output/render.test.ts | 58 ++++++++++++++++++++++ packages/sim-cli/src/output/render.ts | 43 ++++++++++++++-- packages/sim-cli/src/runtime/build.test.ts | 27 ++++++++++ packages/sim-cli/src/runtime/build.ts | 46 +++++++++++++---- 6 files changed, 174 insertions(+), 13 deletions(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index f94b5ed2466..07f69806d57 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -98,6 +98,9 @@ export const CLI_CONTRACT: CliContract = { queryRows: { command: 'tables rows query', flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + // A row's cells live under `data`; without this the table showed an id and + // two timestamps per row and none of the content anyone ran the query for. + expand: 'data', }, // ─── Output columns for list commands ───────────────────────────────────── diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 9158f64813b..2460a351b4d 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -84,6 +84,16 @@ export interface CommandSpec { * the point is that the caller can tell whether they meant it. */ confirm?: string + /** + * Discover table columns from inside this nested field as well as from the + * row's own scalars. + * + * For rows whose real content sits in a wrapper the server chose — a table + * row's user-defined cells live under `data` — the inferred columns would + * otherwise be `id` and two timestamps, because a nested object cannot be a + * column. Only meaningful when `columns` is absent. + */ + expand?: string /** * The response IS a document, not a record to look at. * diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index cffd2cc677b..57e78bc8395 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -259,3 +259,61 @@ describe('sanitize', () => { expect(timestamp('2026-07-31T09:14:22.500Z')).toBe('2026-07-31 09:14:22') }) }) + +describe('cells stay on their own line', () => { + const rows = [{ note: 'first\nsecond', tabbed: 'a\tb' }] + const columns: Column<(typeof rows)[number]>[] = [ + { header: 'note', value: (row) => row.note }, + { header: 'tabbed', value: (row) => row.tabbed }, + ] + + function captured(format: 'table' | 'text' | 'json'): string[] { + const lines: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + printList(format, rows, columns) + spy.mockRestore() + return lines + } + + it('collapses a newline inside a table cell', () => { + // One newline pushed the rest of the row onto the next line and every + // column after it lost its alignment. + const table = captured('table').join('\n') + expect(table.split('\n')).toHaveLength(2) + expect(table).toContain('first second') + }) + + it('collapses a tab in text mode, so cut -f still sees real fields', () => { + const [line] = captured('text') + expect(line.split('\t')).toHaveLength(2) + expect(line).toBe('first second\ta b') + }) + + it('leaves json untouched', () => { + expect(JSON.parse(captured('json').join('\n'))).toEqual([ + { note: 'first\nsecond', tabbed: 'a\tb' }, + ]) + }) + + it('clamps a very wide cell in table mode only', () => { + const wide = [{ blob: 'x'.repeat(500) }] + const cols: Column<(typeof wide)[number]>[] = [{ header: 'blob', value: (row) => row.blob }] + const lines: string[] = [] + const spy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + printList('table', wide, cols) + printList('text', wide, cols) + spy.mockRestore() + + // The table arrives as one string: header line, then the clamped body line. + const [header, body] = lines[0].split('\n') + expect(header.trim()).toBe('BLOB') + expect(body).toMatch(/…$/) + expect(body.length).toBeLessThan(100) + // `text` feeds pipelines; truncating there would corrupt the data. + expect(lines[1]).toHaveLength(500) + }) +}) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 3905bab418c..011c9a8155b 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -131,6 +131,41 @@ function pad(value: string, width: number): string { return value + ' '.repeat(Math.max(0, width - visibleWidth(value))) } +/** + * Flattens a cell onto one line. + * + * `sanitize` keeps `\t` and `\n` on purpose — they are legitimate content, and + * json/yaml must round-trip them. Every *display* format is line-oriented + * though: one newline inside a table cell pushes the rest of the row into the + * next line and every column after it loses its alignment, and in `text` mode a + * stray tab invents a field that `cut -f` then reads as real. A table row of a + * workflow's Slack output did exactly this. + * + * Applied to finished cells only, so it cannot reach the machine formats. + */ +function oneLine(value: string): string { + return value.replace(/\s*[\r\n\t]+\s*/g, ' ') +} + +/** + * Widest a single table column may render. + * + * A table row can hold a whole LLM response; at full width one such cell sets + * the column width for every row and pushes everything after it off-screen. + * `text`, `json` and `yaml` are untouched — this is a legibility cap on the + * human view, and the other three formats exist for the whole value. + */ +const MAX_CELL_WIDTH = 60 + +function clampCell(value: string): string { + // ANSI-bearing cells come from the short formatters (`yes`/`no`, the empty + // glyph); slicing one mid-escape would corrupt it, and none are ever wide. + if (visibleWidth(value) <= MAX_CELL_WIDTH || value !== value.replace(ANSI_PATTERN, '')) { + return value + } + return `${value.slice(0, MAX_CELL_WIDTH - 1)}…` +} + function renderTable(rows: T[], columns: Column[]): string { if (rows.length === 0) return chalk.dim('No results.') @@ -138,7 +173,7 @@ function renderTable(rows: T[], columns: Column[]): string { // remote content and gets the same treatment as a cell. Doing it here rather // than only at each call site means a future column source cannot reopen this. const headers = columns.map((column) => sanitize(column.header)) - const cells = rows.map((row) => columns.map((column) => column.value(row))) + const cells = rows.map((row) => columns.map((column) => clampCell(oneLine(column.value(row))))) const widths = columns.map((_column, index) => Math.max(visibleWidth(headers[index]), ...cells.map((line) => visibleWidth(line[index]))) ) @@ -193,7 +228,7 @@ export function printList(format: OutputFormat, rows: T[], columns: Column if (format === 'text') { for (const row of rows) { - console.log(columns.map((column) => stripAnsi(column.value(row))).join('\t')) + console.log(columns.map((column) => oneLine(stripAnsi(column.value(row)))).join('\t')) } return } @@ -226,13 +261,13 @@ export function printRecord(format: OutputFormat, fields: Array<[string, string] if (format === 'text') { for (const [label, value] of fields) { - console.log(`${label}\t${stripAnsi(value)}`) + console.log(`${label}\t${oneLine(stripAnsi(value))}`) } return } const width = Math.max(...fields.map(([label]) => label.length)) for (const [label, value] of fields) { - console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${value}`) + console.log(`${chalk.dim(pad(`${label}:`, width + 1))} ${oneLine(value)}`) } } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 958d4741493..155c62fe5fe 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -243,3 +243,30 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) }) }) + +describe('rows whose content sits in a wrapper', () => { + it('discovers columns from the expanded field', async () => { + // `tables rows query` returned a table of ids and timestamps: a row's cells + // live under `data`, and column inference skipped it for being an object. + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: [ + { id: 'r1', data: { url: 'https://a', title: 'A' }, createdAt: 'now' }, + { id: 'r2', data: { url: 'https://b', extra: 'E' }, createdAt: 'now' }, + ], + nextCursor: null, + }) + const lines: string[] = [] + output.format = 'text' + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'query', 'tbl_1']) + output.format = 'json' + + // Unioned across the page: `extra` appears only on the second row. + expect(lines[0]).toContain('https://a') + expect(lines[0]).toContain('A') + expect(lines[1]).toContain('E') + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 365a6390fda..a359c6f6d16 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -89,10 +89,12 @@ function columnsFrom(specs: ColumnSpec[]): Column[] { * Row shapes are only known at runtime here — a table's `data` is user-defined — * so the keys are unioned across the page rather than read off the first row, * which would let a sparse row hide every column it happens to omit. Nested - * values are skipped: they render as JSON blobs and make the table unreadable. + * values are skipped: they render as JSON blobs and make the table unreadable — + * unless the contract names one with `expand`, which is how a row's cells reach + * the table. */ -function inferColumns(rows: unknown[]): Column[] { - const keys: string[] = [] +function inferColumns(rows: unknown[], expand?: string): Column[] { + const paths: Array<{ path: string; header: string }> = [] const seen = new Set() for (const row of rows) { @@ -101,16 +103,34 @@ function inferColumns(rows: unknown[]): Column[] { if (seen.has(key)) continue if (value !== null && typeof value === 'object') continue seen.add(key) - keys.push(key) + paths.push({ path: key, header: key }) } } - return keys.map((key) => ({ + // The wrapper named by `expand` holds the only content the caller cares about; + // the loop above skipped it for being an object, which is how `tables rows + // query` came back showing nothing but ids and timestamps. + if (expand) { + const nested = new Set() + for (const row of rows) { + const container = at(row, expand) + if (!container || typeof container !== 'object' || Array.isArray(container)) continue + for (const key of Object.keys(container)) { + if (nested.has(key)) continue + nested.add(key) + // A user-defined key that shadows a top-level one is shown by its full + // path, so two different values never appear under one header. + paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + } + } + } + + return paths.map(({ path, header }) => ({ // The key itself is remote data when the rows are user-defined, and the // header is printed just like a cell — sanitizing values but not headers // left the same control sequences executable one row higher. - header: sanitize(key), - value: (row: unknown) => renderCell(at(row, key), 'auto'), + header: sanitize(header), + value: (row: unknown) => renderCell(at(row, path), 'auto'), })) } @@ -297,7 +317,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri } while (cursor && rows.length < limit) const page = Number.isFinite(limit) ? rows.slice(0, limit) : rows - printList(profile.output, page, spec.columns ? columnsFrom(spec.columns) : inferColumns(page)) + printList( + profile.output, + page, + spec.columns ? columnsFrom(spec.columns) : inferColumns(page, spec.expand) + ) return } @@ -318,7 +342,11 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri if (Array.isArray(data)) { // Reached when a non-paginated operation answers with a collection. // `printRecord` would silently print nothing for an array. - printList(profile.output, data, spec.columns ? columnsFrom(spec.columns) : inferColumns(data)) + printList( + profile.output, + data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) + ) return } From 89b4d9b7f1fb2a91dc2dea63b857ce3f62e2b809 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 12:04:29 -0700 Subject: [PATCH 23/28] fix(cli): make boolean flags able to say false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--is-active false` turned sharing ON and reported success. Booleans were declared presence-only, so the flag meant `true` and commander dropped the `false` as an argument the command had no use for — silently, because excess arguments are ignored by default. A required boolean now takes its value (`--is-active `): it is a state to set, not a switch to flip on, and as a presence flag it could only ever send one of the two values it needs to express. Optional booleans stay presence-flags — `--deployed-only` reads better than `--deployed-only true` — but each also gets `--no-`. Omitting one means "leave it alone", which is not the same as setting it false; without the negation there was no way to disable an MCP server or unlock a folder. Excess arguments are now an error on every generated command, so a value attached to the wrong flag stops rather than being silently discarded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/runtime/build.test.ts | 58 ++++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 24 +++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 155c62fe5fe..15677b76ed6 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -28,6 +28,14 @@ vi.mock('../context.js', () => ({ function program(): Command { const root = new Command('sim').exitOverride() for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + // Recursively, not just on the root: a parse error raised by a leaf (an + // unknown option, an excess argument) exits the process otherwise, which a + // test cannot assert on. + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) return root } @@ -270,3 +278,53 @@ describe('rows whose content sits in a wrapper', () => { expect(lines[1]).toContain('E') }) }) + +describe('boolean flags', () => { + it('takes an explicit value when the field is required', async () => { + // As a presence-only flag this could only ever send `true`: `--is-active + // false` turned sharing ON and reported success, with the `false` dropped + // as a stray argument. + const [, options] = await run([ + 'files', + 'share', + 'set', + 'f_1', + '--is-active', + 'false', + '--auth-type', + 'public', + ]) + expect(options.body).toMatchObject({ isActive: false }) + + const [, on] = await run([ + 'files', + 'share', + 'set', + 'f_1', + '--is-active', + 'true', + '--auth-type', + 'public', + ]) + expect(on.body).toMatchObject({ isActive: true }) + }) + + it('negates an optional boolean, which omitting it cannot do', async () => { + // Omitting `enabled` means "leave it alone"; there was no way to say false, + // so an MCP server could not be disabled or a folder unlocked. + const [, off] = await run(['mcp-servers', 'update', 'mcp_1', '--no-enabled']) + expect(off.body).toMatchObject({ enabled: false }) + + const [, on] = await run(['mcp-servers', 'update', 'mcp_1', '--enabled']) + expect(on.body).toMatchObject({ enabled: true }) + + const [, absent] = await run(['mcp-servers', 'update', 'mcp_1', '--name', 'x']) + expect(absent.body).not.toHaveProperty('enabled') + }) + + it('rejects an argument the command has no meaning for', async () => { + await expect(run(['mcp-servers', 'update', 'mcp_1', '--enabled', 'bogus'])).rejects.toThrow( + /too many arguments/ + ) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index a359c6f6d16..3b76d0a4699 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -205,7 +205,27 @@ function addFieldOption( } if (descriptor.kind === 'boolean') { + // A required boolean is a state to set, not a switch to flip on: it takes + // the value explicitly. As a presence-only flag it could only ever send + // `true`, so `--is-active false` set sharing ON — commander read the flag as + // true and dropped the `false` as a stray argument. + if (descriptor.required) { + command.addOption( + new Option(`${short}--${name} `, flag.describe ?? `Set ${field}`).choices([ + 'true', + 'false', + ]) + ) + return + } + + // Optional booleans stay presence-flags — `--deployed-only` reads better + // than `--deployed-only true` — but every one of them also gets a negation, + // because for a state field (`enabled`, `locked`) omitting the flag means + // "leave it alone", which is not the same as setting it false. Without this + // there was no way to disable an MCP server or unlock a folder. command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + command.option(`--no-${name}`, `Set ${field} to false`) return } @@ -246,6 +266,10 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri // NAME, so `sim tables upsert` would never match it and would silently fall // through to the group's help. Arguments have to be declared separately. const command = new Command(leafName) + // Commander ignores arguments beyond those declared. That silence is how + // `--is-active false` ran as though the `false` had never been typed; an + // argument the command has no meaning for is a mistake worth stopping on. + command.allowExcessArguments(false) for (const param of operationSpec.pathParams) { command.argument(`<${param}>`) } From a0fa865c7956df6698dbac5d2b69d9d4f5de3a20 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 18:12:14 -0700 Subject: [PATCH 24/28] feat(cli): pick up v2 workflow CRUD, table transfers, and list search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 79 → 111 operations across three merged PRs. The generator could not read the new contracts at all: a table view's filter is a recursive predicate, so Zod lifts it into `$defs` and refers to it, and `toTypeScript` threw on the first `$ref`. Those definitions are now hoisted into named aliases — recursion TypeScript resolves without complaint — named after the type that owns them so two operations lifting their own `__schema0` cannot collide. Uploading is no longer one multipart POST. `POST /api/v2/files` is gone, replaced by a presigned handshake, so `files upload` was left calling a route that no longer exists. It now creates the upload, signs part URLs in batches of 100 (each is short-lived, so signing all of them up front would expire the last ones), PUTs each part straight to storage, and completes with the ETags — aborting the upload if any step fails, since a half-finished one holds storage. Parts are read through `Blob.slice`, so only the part in flight is in memory. Verified byte-identical on a 24MB round trip. The rest is naming. `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment path each put a verb where a sub-resource was expected, so each had become a group holding a lone `create`. Transfer steps keep names that say what they are, since no single command drives a table import yet. Three new DELETEs needed gates, which the existing guard test caught. Aborting an upload and cancelling an import or export stop something in flight rather than destroying something kept, so those are exempt. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/commands/hand-written.ts | 161 +- packages/sim-cli/src/contract/commands.ts | 57 +- packages/sim-cli/src/generated/v2-api.ts | 2002 ++++++++++++++++- packages/sim-cli/src/http/client.test.ts | 10 +- packages/sim-cli/src/http/client.ts | 3 + scripts/generate-v2-cli-api.ts | 69 +- 6 files changed, 2227 insertions(+), 75 deletions(-) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 75910fc356a..af16b39e2dc 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -6,7 +6,7 @@ import chalk from 'chalk' import type { Command } from 'commander' import { clientFrom } from '../context.js' import type { QueryRowsResponse } from '../generated/v2-api.js' -import { SimApiError } from '../http/client.js' +import { SimApiError, type SimClient } from '../http/client.js' import { type Column, printList, sanitize, text } from '../output/render.js' /** @@ -149,11 +149,91 @@ function contentTypeFor(name: string): string { return CONTENT_TYPES[extension] ?? 'application/octet-stream' } -/** The route's own ceiling. Checked here so a 100 MB body is never sent to be refused. */ -const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +interface FileUpload { + id: string + size: number + partSize: number + partCount: number + uploadToken: string + file: { id: string } | null +} + +/** The parts endpoint signs at most this many URLs per request. */ +const PART_URL_BATCH = 100 + +/** + * Sends every part of a file to the storage URLs the API signs for it, and + * returns what `complete` needs to reassemble them. + * + * URLs are requested in batches because each one is short-lived: signing all + * 640 possible parts up front would leave the last ones expired by the time a + * slow connection reached them. + * + * Parts go out one at a time. Concurrency would be faster, but a failure + * mid-flight has to abort the whole upload anyway, and a sequential loop makes + * "which part failed" unambiguous. + */ +async function uploadParts( + client: SimClient, + workspaceId: string, + upload: FileUpload, + blob: Blob +): Promise> { + const completed: Array<{ partNumber: number; etag?: string }> = [] + + for (let first = 1; first <= upload.partCount; first += PART_URL_BATCH) { + const partNumbers = [] + for (let n = first; n < first + PART_URL_BATCH && n <= upload.partCount; n++) { + partNumbers.push(n) + } + + const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( + `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/parts`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { partNumbers }, + } + ) + + for (const part of signed.data.parts) { + const start = (part.partNumber - 1) * upload.partSize + // `Blob.slice` is a view over the file on disk, so only the part being + // sent is ever read — the point of not buffering the upload. + const chunk = blob.slice(start, Math.min(start + upload.partSize, upload.size)) + + // boundary-raw-fetch: storage-signed URL on another origin, not the API + const response = await fetch(part.url, { + method: 'PUT', + headers: part.headers, + body: chunk, + }) + if (!response.ok) { + throw new SimApiError( + `Part ${part.partNumber} failed with status ${response.status}`, + response.status + ) + } + + // S3-compatible stores identify a part by the ETag they return; the API + // treats it as optional because not every backend sends one. + const etag = response.headers.get('etag')?.replace(/"/g, '') + completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) + } + } + + return completed +} export function attachHandWritten(program: Command): void { - // ── files upload ── multipart, which the generated flag surface cannot express ── + // ── files upload ── a presigned multipart handshake, not one request ────── group(program, 'files') .command('upload ') .description('Upload a file to the workspace') @@ -161,13 +241,9 @@ export function attachHandWritten(program: Command): void { .option('--name ', 'Store it under a different name') .action( async (path: string, options: { folderId?: string; name?: string }, command: Command) => { - const { client, profile } = clientFrom(command) + const { client } = clientFrom(command) const workspaceId = client.requireWorkspace() - if (!profile.apiKey) { - throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0) - } - let size: number try { const stats = await stat(path) @@ -178,43 +254,54 @@ export function attachHandWritten(program: Command): void { throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) } - // Fail here rather than after streaming 100 MB the server will reject. - if (size > MAX_UPLOAD_BYTES) { - throw new SimApiError( - `${path} is ${(size / 1024 / 1024).toFixed(1)}MB; the limit is 100MB`, - 0 - ) - } + // The server sizes its own parts, but it cannot reject an empty file any + // more cheaply than we can: a zero-byte upload has no parts to send. + if (size === 0) throw new SimApiError(`${path} is empty`, 0) const name = options.name ?? basename(path) - const url = new URL(`${profile.endpoint}/api/v2/files`) - url.searchParams.set('workspaceId', workspaceId) - if (options.folderId) url.searchParams.set('folderId', options.folderId) - - // `openAsBlob` keeps the file on disk and reads it as the request is - // written; building a Buffer first would hold the whole upload in memory. - const body = new FormData() - body.append('file', await openAsBlob(path, { type: contentTypeFor(name) }), name) - const response = await fetch(url, { + const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { method: 'POST', - headers: { 'x-api-key': profile.apiKey }, - body, + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folderId ? { folderId: options.folderId } : {}), + }, }) + const upload = created.data - const payload = (await response.json().catch(() => null)) as { - data?: { id?: string } - error?: { message?: string } - } | null + // Any failure past this point leaves an upload holding storage, so the + // rest runs under an abort that the server also uses to release it. + try { + const blob = await openAsBlob(path) + const parts = await uploadParts(client, workspaceId, upload, blob) - if (!response.ok) { - throw new SimApiError( - payload?.error?.message ?? `Upload failed with status ${response.status}`, - response.status + const completed = await client.request<{ data: FileUpload }>( + `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/complete`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + body: { parts }, + } + ) + console.log( + chalk.green(`✓ Uploaded ${name} (${completed.data.file?.id ?? completed.data.id})`) ) + } catch (error) { + await client + .request(`/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': upload.uploadToken }, + }) + // The original failure is what the caller needs; a failed cleanup + // must not replace it with a message about the cleanup. + .catch(() => undefined) + throw error } - - console.log(chalk.green(`✓ Uploaded ${name} (${payload?.data?.id ?? 'unknown id'})`)) } ) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 07f69806d57..92adecf48bc 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -50,6 +50,13 @@ export const CLI_CONTRACT: CliContract = { deleteCredential: { confirm: 'This deletes the credential; anything authenticating with it stops working.', }, + deleteWorkflow: { confirm: 'This deletes the workflow and its run history.' }, + deleteTableView: { confirm: 'This deletes the saved view and its filters.' }, + deleteWorkflowGroup: { + // Not just the grouping: the documented behaviour is that every column the + // group fed goes with it, values included. + confirm: 'This deletes the group, every column it fed, and the values in them.', + }, deleteFolder: { // The route archives the folder *and cascades to its contents*, so this is // the broadest delete on the surface — the message says so rather than @@ -243,6 +250,40 @@ export const CLI_CONTRACT: CliContract = { describe: 'Enable or disable sharing for a file', }, + // ─── The expanded tables surface ────────────────────────────────────────── + // `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment + // path all put a verb where the deriver expects a sub-resource, so each became + // a group holding a lone `create`. + cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' }, + findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate' }, + restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, + runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow' }, + runRowEnrichment: { + command: 'tables rows enrich', + describe: 'Run one row’s enrichment group', + }, + + // Transfers are a handshake: create, request part URLs, send the parts, then + // complete. Unlike `files upload` there is no single command driving this yet + // — the import body carries source/target/mapping choices a one-liner cannot + // express — so each step stays reachable under a name that says what it is. + createTableImport: { command: 'tables imports create' }, + createTableImportPartUrls: { + command: 'tables imports parts', + describe: 'Sign upload URLs for a batch of parts', + }, + completeTableImport: { + command: 'tables imports complete', + describe: 'Finish an import once every part is uploaded', + }, + cancelTableImport: { command: 'tables imports cancel' }, + cancelTableExport: { command: 'tables exports cancel' }, + tableExportDownload: { + // GET, but it returns a signed URL rather than a listing. + command: 'tables exports download', + describe: 'Get the download URL for a finished export', + }, + // ─── Documents, not records ─────────────────────────────────────────────── // The payload is the artifact: `sim workflows export > wf.json` has to // produce something `sim workflows import` accepts back. @@ -280,8 +321,18 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim files upload ` needs its own file-reading - // command rather than a generated flag surface. - uploadFile: { hidden: true }, + // Multipart upload; `sim knowledge documents upload ` would need its own + // file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, + + // ─── Steps of a transfer, not commands ──────────────────────────────────── + // Uploading is now a presigned multipart handshake: create the upload, ask for + // part URLs in batches, PUT each part to storage, then complete with the + // ETags — and abort if any of it fails. Exposing the steps individually would + // advertise a protocol whose halfway states leak storage, so `sim files + // upload` drives the whole sequence and these stay out of the surface. + createFileUpload: { hidden: true }, + createFileUploadPartUrls: { hidden: true }, + completeFileUpload: { hidden: true }, + abortFileUpload: { hidden: true }, } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e31c6f1df5b..34c5aa8b163 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -10,6 +10,46 @@ * `packages/* must not import apps/*` boundary is preserved. */ +/** `DELETE /api/v2/files/uploads/[uploadId]` */ +export type AbortFileUploadParams = { + uploadId: string +} + +export type AbortFileUploadQuery = { + workspaceId: string +} + +export type AbortFileUploadHeaders = { + 'upload-token': string +} + +export type AbortFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + /** `POST /api/v2/tables/[tableId]/columns` */ export type AddTableColumnParams = { tableId: string @@ -52,6 +92,85 @@ export type AddTableColumnResponse = { } } +/** `POST /api/v2/tables/[tableId]/groups` */ +export type AddWorkflowGroupParams = { + tableId: string +} + +export type AddWorkflowGroupBody = { + workspaceId: string + group: { + id?: string + workflowId?: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId?: string + path?: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + outputColumns: Array<{ + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + }> + autoRun?: boolean +} + +export type AddWorkflowGroupResponse = { + data: { + group: { + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + /** `POST /api/v2/files/bulk-archive` */ export type BulkArchiveFileItemsBody = { workspaceId: string @@ -68,6 +187,104 @@ export type BulkArchiveFileItemsResponse = { } } +/** `DELETE /api/v2/tables/exports/[exportId]` */ +export type CancelTableExportParams = { + exportId: string +} + +export type CancelTableExportQuery = { + workspaceId: string +} + +export type CancelTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `DELETE /api/v2/tables/imports/[importId]` */ +export type CancelTableImportParams = { + importId: string +} + +export type CancelTableImportQuery = { + workspaceId: string +} + +export type CancelTableImportHeaders = { + 'upload-token'?: string +} + +export type CancelTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/[tableId]/cancel-runs` */ +export type CancelTableRunsParams = { + tableId: string +} + +export type CancelTableRunsBody = { + workspaceId: string + scope: 'all' | 'row' + rowId?: string + filter?: unknown + excludeRowIds?: Array +} + +export type CancelTableRunsResponse = { + data: { + cancelled: number + } +} + /** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ export type CancelWorkflowExecutionParams = { id: string @@ -91,6 +308,115 @@ export type CancelWorkflowExecutionResponse = { } } +/** `POST /api/v2/files/uploads/[uploadId]/complete` */ +export type CompleteFileUploadParams = { + uploadId: string +} + +export type CompleteFileUploadQuery = { + workspaceId: string +} + +export type CompleteFileUploadBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteFileUploadHeaders = { + 'upload-token': string +} + +export type CompleteFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/tables/imports/[importId]/complete` */ +export type CompleteTableImportParams = { + importId: string +} + +export type CompleteTableImportQuery = { + workspaceId: string +} + +export type CompleteTableImportBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteTableImportHeaders = { + 'upload-token': string +} + +export type CompleteTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + /** `POST /api/v2/credentials` */ export type CreateCredentialBody = { workspaceId: string @@ -170,6 +496,70 @@ export type CreateCustomToolResponse = { } } +/** `POST /api/v2/files/uploads` */ +export type CreateFileUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + folderId?: string +} + +export type CreateFileUploadResponse = { + data: { + id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + partSize: number + partCount: number + uploadToken: string + expiresAt: string + error: string | null + file: { + id: string + name: string + size: number + type: string + key: string + folderId: string | null + folderPath: string | null + uploadedBy: string + uploadedAt: string + updatedAt: string + } | null + } +} + +/** `POST /api/v2/files/uploads/[uploadId]/parts` */ +export type CreateFileUploadPartUrlsParams = { + uploadId: string +} + +export type CreateFileUploadPartUrlsQuery = { + workspaceId: string +} + +export type CreateFileUploadPartUrlsBody = { + partNumbers: Array +} + +export type CreateFileUploadPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateFileUploadPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + /** `POST /api/v2/folders` */ export type CreateFolderBody = { workspaceId: string @@ -349,12 +739,151 @@ export type CreateTableResponse = { } rowCount: number maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null createdAt: string updatedAt: string } } } +/** `POST /api/v2/tables/[tableId]/exports` */ +export type CreateTableExportParams = { + tableId: string +} + +export type CreateTableExportBody = { + workspaceId: string + format?: 'csv' | 'json' +} + +export type CreateTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/imports` */ +export type CreateTableImportBody = { + workspaceId: string + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + mapping?: unknown + createColumns?: unknown + timezone?: string +} + +export type CreateTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `POST /api/v2/tables/imports/[importId]/parts` */ +export type CreateTableImportPartUrlsParams = { + importId: string +} + +export type CreateTableImportPartUrlsQuery = { + workspaceId: string +} + +export type CreateTableImportPartUrlsBody = { + partNumbers: Array +} + +export type CreateTableImportPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateTableImportPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + /** `POST /api/v2/tables/[tableId]/rows` */ export type CreateTableRowsParams = { tableId: string @@ -395,6 +924,138 @@ export type CreateTableRowsResponse = } } +/** `POST /api/v2/tables/[tableId]/views` */ +export type CreateTableViewParams = { + tableId: string +} + +export type CreateTableViewBody = { + workspaceId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } +} + +type CreateTableViewResponseRef0 = + | { + all: Array< + | CreateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | CreateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type CreateTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: CreateTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `POST /api/v2/workflows` */ +export type CreateWorkflowBody = { + workspaceId: string + name: string + description?: string | null + folderId?: string | null +} + +export type CreateWorkflowResponse = { + data: { + id: string + name: string + description: string | null + folderId: string | null + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string + updatedAt: string + } +} + /** `DELETE /api/v2/credentials/[id]` */ export type DeleteCredentialParams = { id: string @@ -614,6 +1275,65 @@ export type DeleteTableRowsResponse = { } } +/** `DELETE /api/v2/tables/[tableId]/views/[viewId]` */ +export type DeleteTableViewParams = { + tableId: string + viewId: string +} + +export type DeleteTableViewQuery = { + workspaceId: string +} + +export type DeleteTableViewResponse = { + data: { + id: string + } +} + +/** `DELETE /api/v2/workflows/[id]` */ +export type DeleteWorkflowParams = { + id: string +} + +export type DeleteWorkflowResponse = { + data: { + id: string + deleted: true + } +} + +/** `DELETE /api/v2/tables/[tableId]/groups` */ +export type DeleteWorkflowGroupParams = { + tableId: string +} + +export type DeleteWorkflowGroupBody = { + workspaceId: string + groupId: string +} + +export type DeleteWorkflowGroupResponse = { + data: { + id: string + deleted: true + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + /** `POST /api/v2/workflows/[id]/deploy` */ export type DeployWorkflowParams = { id: string @@ -832,6 +1552,32 @@ export type ExportWorkflowResponse = { } } +/** `POST /api/v2/tables/[tableId]/rows/find` */ +export type FindTableRowsParams = { + tableId: string +} + +export type FindTableRowsBody = { + workspaceId: string + q: string + predicate?: unknown + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> +} + +export type FindTableRowsResponse = { + data: { + matches: Array<{ + ordinal: number + rowId: string + column: string + }> + truncated: boolean + } +} + /** `GET /api/v2/audit-logs/[id]` */ export type GetAuditLogParams = { id: string @@ -1186,12 +1932,101 @@ export type GetTableResponse = { } rowCount: number maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null createdAt: string updatedAt: string } } } +/** `GET /api/v2/tables/exports/[exportId]` */ +export type GetTableExportParams = { + exportId: string +} + +export type GetTableExportQuery = { + workspaceId: string +} + +export type GetTableExportResponse = { + data: { + id: string + tableId: string + workspaceId: string + format: 'csv' | 'json' + status: 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + +/** `GET /api/v2/tables/imports/[importId]` */ +export type GetTableImportParams = { + importId: string +} + +export type GetTableImportQuery = { + workspaceId: string +} + +export type GetTableImportResponse = { + data: { + id: string + workspaceId: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + source: + | { + type: 'upload' + name: string + contentType: string + size: number + } + | { + type: 'workspace_file' + fileId: string + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + upload: { + uploadToken: string + partSize: number + partCount: number + expiresAt: string + } | null + createdAt: string + updatedAt: string + completedAt: string | null + } +} + /** `GET /api/v2/tables/[tableId]/rows/[rowId]` */ export type GetTableRowParams = { tableId: string @@ -1213,6 +2048,103 @@ export type GetTableRowResponse = { } } +/** `GET /api/v2/tables/[tableId]/views/[viewId]` */ +export type GetTableViewParams = { + tableId: string + viewId: string +} + +export type GetTableViewQuery = { + workspaceId: string +} + +type GetTableViewResponseRef0 = + | { + all: Array< + | GetTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | GetTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type GetTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: GetTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + /** `GET /api/v2/billing/usage` */ export type GetUsageSummaryQuery = { workspaceId?: string @@ -1311,6 +2243,24 @@ export type GetWorkflowExecutionResponse = { } } +/** `GET /api/v2/workflows/[id]/versions/[version]` */ +export type GetWorkflowVersionParams = { + id: string + version: number +} + +export type GetWorkflowVersionResponse = { + data: { + id: string + version: number + name: string | null + description: string | null + isActive: boolean + createdAt: string + state: unknown + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -1369,6 +2319,9 @@ export type ListCredentialsQuery = { workspaceId: string type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' providerId?: string + search?: string + sortBy?: 'displayName' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListCredentialsResponse = { @@ -1391,6 +2344,9 @@ export type ListCredentialsResponse = { /** `GET /api/v2/custom-tools` */ export type ListCustomToolsQuery = { workspaceId: string + search?: string + sortBy?: 'title' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListCustomToolsResponse = { @@ -1420,6 +2376,10 @@ export type ListCustomToolsResponse = { export type ListFilesQuery = { workspaceId: string scope?: 'active' | 'archived' + folderId?: string + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' limit?: number cursor?: string } @@ -1445,6 +2405,9 @@ export type ListFoldersQuery = { workspaceId: string resourceType: 'workflow' | 'knowledge_base' | 'table' scope?: 'active' | 'archived' + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListFoldersResponse = { @@ -1465,6 +2428,10 @@ export type ListFoldersResponse = { /** `GET /api/v2/knowledge` */ export type ListKnowledgeBasesQuery = { workspaceId: string + folderId?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListKnowledgeBasesResponse = { @@ -1587,6 +2554,9 @@ export type ListLogsResponse = { /** `GET /api/v2/mcp-servers` */ export type ListMcpServersQuery = { workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListMcpServersResponse = { @@ -1618,6 +2588,9 @@ export type ListMcpServersResponse = { /** `GET /api/v2/skills` */ export type ListSkillsQuery = { workspaceId: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListSkillsResponse = { @@ -1656,6 +2629,10 @@ export type ListTableRowsResponse = { /** `GET /api/v2/tables` */ export type ListTablesQuery = { workspaceId: string + folderId?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' } export type ListTablesResponse = { @@ -1681,6 +2658,115 @@ export type ListTablesResponse = { } rowCount: number maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + +/** `GET /api/v2/tables/[tableId]/views` */ +export type ListTableViewsParams = { + tableId: string +} + +export type ListTableViewsQuery = { + workspaceId: string +} + +type ListTableViewsResponseRef0 = + | { + all: Array< + | ListTableViewsResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | ListTableViewsResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type ListTableViewsResponse = { + data: Array<{ + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: ListTableViewsResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null createdAt: string updatedAt: string }> @@ -1727,6 +2813,41 @@ export type ListUsageLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/tables/[tableId]/groups` */ +export type ListWorkflowGroupsParams = { + tableId: string +} + +export type ListWorkflowGroupsQuery = { + workspaceId: string +} + +export type ListWorkflowGroupsResponse = { + data: Array<{ + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/workflows` */ export type ListWorkflowsQuery = { workspaceId: string @@ -1734,6 +2855,9 @@ export type ListWorkflowsQuery = { deployedOnly?: boolean limit?: number cursor?: string + search?: string + sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' | 'runCount' + sortOrder?: 'asc' | 'desc' } export type ListWorkflowsResponse = { @@ -1753,6 +2877,30 @@ export type ListWorkflowsResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/[id]/versions` */ +export type ListWorkflowVersionsParams = { + id: string +} + +export type ListWorkflowVersionsQuery = { + limit?: number + cursor?: string +} + +export type ListWorkflowVersionsResponse = { + data: Array<{ + id: string + version: number + name?: string | null + description?: string | null + isActive: boolean + createdAt: string + deployedBy?: string | null + latestOperationStatus?: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' | null + }> + nextCursor: string | null +} + /** `POST /api/v2/files/move` */ export type MoveFileItemsBody = { workspaceId: string @@ -1837,6 +2985,59 @@ export type RestoreFileResponse = { } } +/** `POST /api/v2/tables/[tableId]/restore` */ +export type RestoreTableParams = { + tableId: string +} + +export type RestoreTableBody = { + workspaceId: string +} + +export type RestoreTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -1876,6 +3077,47 @@ export type RollbackWorkflowResponse = { } } +/** `POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ +export type RunRowEnrichmentParams = { + tableId: string + rowId: string + groupId: string +} + +export type RunRowEnrichmentBody = { + workspaceId: string +} + +export type RunRowEnrichmentResponse = { + data: { + dispatchId: string | null + } +} + +/** `POST /api/v2/tables/[tableId]/columns/run` */ +export type RunTableColumnParams = { + tableId: string +} + +export type RunTableColumnBody = { + workspaceId: string + groupIds: Array + runMode?: 'all' | 'incomplete' + rowIds?: Array + filter?: unknown + excludeRowIds?: Array + limit?: { + type: 'rows' + max: number + } +} + +export type RunTableColumnResponse = { + data: { + dispatchId: string | null + } +} + /** `POST /api/v2/knowledge/search` */ export type SearchKnowledgeBody = { workspaceId: string @@ -1910,6 +3152,23 @@ export type SearchKnowledgeResponse = { } } +/** `GET /api/v2/tables/exports/[exportId]/download` */ +export type TableExportDownloadParams = { + exportId: string +} + +export type TableExportDownloadQuery = { + workspaceId: string +} + +export type TableExportDownloadResponse = { + data: { + url: string + fileName: string + expiresAt: string + } +} + /** `DELETE /api/v2/workflows/[id]/deploy` */ export type UndeployWorkflowParams = { id: string @@ -2225,6 +3484,61 @@ export type UpdateSkillResponse = { } } +/** `PATCH /api/v2/tables/[tableId]` */ +export type UpdateTableParams = { + tableId: string +} + +export type UpdateTableBody = { + workspaceId: string + name?: string + folderId?: string | null +} + +export type UpdateTableResponse = { + data: { + table: { + id: string + name: string + description: string | null + schema: { + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } + rowCount: number + maxRows: number + folderId: string | null + locks: { + schemaLocked: boolean + insertLocked: boolean + updateLocked: boolean + deleteLocked: boolean + } + job: { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' + rowsProcessed: number + error: string | null + } | null + createdAt: string + updatedAt: string + } + } +} + /** `PATCH /api/v2/tables/[tableId]/columns` */ export type UpdateTableColumnParams = { tableId: string @@ -2288,27 +3602,234 @@ export type UpdateTableRowResponse = { } } -/** `POST /api/v2/files` */ -export type UploadFileQuery = { +/** `PATCH /api/v2/tables/[tableId]/views/[viewId]` */ +export type UpdateTableViewParams = { + tableId: string + viewId: string +} + +export type UpdateTableViewBody = { workspaceId: string - folderId?: string + name?: string + config?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + configPatch?: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: unknown | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault?: boolean +} + +type UpdateTableViewResponseRef0 = + | { + all: Array< + | UpdateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | UpdateTableViewResponseRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + +export type UpdateTableViewResponse = { + data: { + view: { + id: string + tableId: string + name: string + config: { + columnWidths?: Record + columnOrder?: Array + pinnedColumns?: Array + hiddenColumns?: Array + filter?: UpdateTableViewResponseRef0 | null + sort?: Array<{ + field: string + direction: 'asc' | 'desc' + }> | null + } + isDefault: boolean + createdBy: string | null + createdAt: string + updatedAt: string + } + } +} + +/** `PATCH /api/v2/workflows/[id]` */ +export type UpdateWorkflowParams = { + id: string +} + +export type UpdateWorkflowBody = { + name?: string + description?: string | null + folderId?: string | null } -export type UploadFileResponse = { +export type UpdateWorkflowResponse = { data: { id: string name: string - size: number - type: string - key: string + description: string | null folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string + workspaceId: string + isDeployed: boolean + deployedAt: string | null + runCount: number + lastRunAt: string | null + createdAt: string updatedAt: string } } +/** `PATCH /api/v2/tables/[tableId]/groups` */ +export type UpdateWorkflowGroupParams = { + tableId: string +} + +export type UpdateWorkflowGroupBody = { + workspaceId: string + groupId: string + workflowId?: string + name?: string + dependencies?: { + columns?: Array + } + outputs?: Array<{ + blockId?: string + path?: string + outputId?: string + columnName: string + }> + newOutputColumns?: Array<{ + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required?: boolean + unique?: boolean + }> + mappingUpdates?: Array<{ + columnName: string + blockId: string + path: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + type?: 'manual' | 'enrichment' + autoRun?: boolean +} + +export type UpdateWorkflowGroupResponse = { + data: { + group: { + id: string + workflowId: string + enrichmentId?: string + name?: string + type?: 'manual' | 'enrichment' + dependencies?: { + columns?: Array + } + outputs: Array<{ + blockId: string + path: string + outputId?: string + columnName: string + }> + inputMappings?: Array<{ + inputName: string + columnName: string + }> + deploymentMode?: 'live' | 'deployed' + autoRun?: boolean + } + columns: Array<{ + id?: string + name: string + type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'json' | 'select' + required: boolean + unique: boolean + workflowGroupId?: string + options?: Array<{ + id: string + name: string + }> + multiple?: boolean + currencyCode?: unknown + }> + } +} + /** `POST /api/v2/knowledge/[id]/documents` */ export type UploadKnowledgeDocumentParams = { id: string @@ -2401,6 +3922,16 @@ export type UpsertTableRowResponse = { * specs so `--help` reuses prose that is already written and already checked. */ export const V2_OPERATIONS = { + abortFileUpload: { + method: 'DELETE', + path: '/api/v2/files/uploads/[uploadId]', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Abort File Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', @@ -2412,16 +3943,63 @@ export const V2_OPERATIONS = { column: { kind: 'object', required: true }, }, }, + addWorkflowGroup: { + method: 'POST', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Add Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + group: { kind: 'object', required: true }, + outputColumns: { kind: 'array', required: true }, + autoRun: { kind: 'boolean', default: false }, + }, + }, bulkArchiveFileItems: { method: 'POST', path: '/api/v2/files/bulk-archive', pathParams: [] as const, responseMode: 'json', - summary: 'Archive Files and Folders', + summary: 'Archive Files and Folders', + body: { + workspaceId: { kind: 'string', required: true }, + fileIds: { kind: 'array', default: [] }, + folderIds: { kind: 'array', default: [] }, + }, + }, + cancelTableExport: { + method: 'DELETE', + path: '/api/v2/tables/exports/[exportId]', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Cancel Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + cancelTableImport: { + method: 'DELETE', + path: '/api/v2/tables/imports/[importId]', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Cancel Table Import', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + cancelTableRuns: { + method: 'POST', + path: '/api/v2/tables/[tableId]/cancel-runs', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Cancel Column Runs', body: { workspaceId: { kind: 'string', required: true }, - fileIds: { kind: 'array', default: [] }, - folderIds: { kind: 'array', default: [] }, + scope: { kind: 'enum', required: true, values: ['all', 'row'] as const }, + rowId: { kind: 'string' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, }, }, cancelWorkflowExecution: { @@ -2431,6 +4009,32 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel an execution', }, + completeFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/complete', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Complete File Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, + completeTableImport: { + method: 'POST', + path: '/api/v2/tables/imports/[importId]/complete', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Complete Table Import Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, createCredential: { method: 'POST', path: '/api/v2/credentials', @@ -2471,6 +4075,33 @@ export const V2_OPERATIONS = { code: { kind: 'string', required: true }, }, }, + createFileUpload: { + method: 'POST', + path: '/api/v2/files/uploads', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create File Upload', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string', required: true }, + size: { kind: 'integer', required: true }, + folderId: { kind: 'string' }, + }, + }, + createFileUploadPartUrls: { + method: 'POST', + path: '/api/v2/files/uploads/[uploadId]/parts', + pathParams: ['uploadId'] as const, + responseMode: 'json', + summary: 'Create File Upload Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, createFolder: { method: 'POST', path: '/api/v2/folders', @@ -2550,6 +4181,45 @@ export const V2_OPERATIONS = { folderId: { kind: 'string' }, }, }, + createTableExport: { + method: 'POST', + path: '/api/v2/tables/[tableId]/exports', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create Table Export', + body: { + workspaceId: { kind: 'string', required: true }, + format: { kind: 'enum', values: ['csv', 'json'] as const, default: 'csv' }, + }, + }, + createTableImport: { + method: 'POST', + path: '/api/v2/tables/imports', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Table Import', + body: { + workspaceId: { kind: 'string', required: true }, + source: { kind: 'unknown', required: true }, + target: { kind: 'unknown', required: true }, + mapping: { kind: 'unknown' }, + createColumns: { kind: 'unknown' }, + timezone: { kind: 'string' }, + }, + }, + createTableImportPartUrls: { + method: 'POST', + path: '/api/v2/tables/imports/[importId]/parts', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Create Table Import Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, createTableRows: { method: 'POST', path: '/api/v2/tables/[tableId]/rows', @@ -2557,6 +4227,31 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Create Rows', }, + createTableView: { + method: 'POST', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Create View', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + config: { kind: 'object', required: true }, + }, + }, + createWorkflow: { + method: 'POST', + path: '/api/v2/workflows', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Workflow', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + description: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, deleteCredential: { method: 'DELETE', path: '/api/v2/credentials/[id]', @@ -2686,6 +4381,34 @@ export const V2_OPERATIONS = { rowIds: { kind: 'array' }, }, }, + deleteTableView: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Delete View', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + deleteWorkflow: { + method: 'DELETE', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Delete Workflow', + }, + deleteWorkflowGroup: { + method: 'DELETE', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Delete Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + groupId: { kind: 'string', required: true }, + }, + }, deployWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/deploy', @@ -2727,6 +4450,19 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Export a workflow', }, + findTableRows: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/find', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Find Rows', + body: { + workspaceId: { kind: 'string', required: true }, + q: { kind: 'string', required: true }, + predicate: { kind: 'unknown' }, + sort: { kind: 'array' }, + }, + }, getAuditLog: { method: 'GET', path: '/api/v2/audit-logs/[id]', @@ -2843,6 +4579,26 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + getTableExport: { + method: 'GET', + path: '/api/v2/tables/exports/[exportId]', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Get Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, + getTableImport: { + method: 'GET', + path: '/api/v2/tables/imports/[importId]', + pathParams: ['importId'] as const, + responseMode: 'json', + summary: 'Get Table Import', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getTableRow: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]', @@ -2853,6 +4609,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + getTableView: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, + responseMode: 'json', + summary: 'Get View', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, getUsageSummary: { method: 'GET', path: '/api/v2/billing/usage', @@ -2881,6 +4647,13 @@ export const V2_OPERATIONS = { selectedOutputs: { kind: 'string' }, }, }, + getWorkflowVersion: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions/[version]', + pathParams: ['id', 'version'] as const, + responseMode: 'json', + summary: 'Get Workflow Version', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -2927,6 +4700,13 @@ export const V2_OPERATIONS = { values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, }, providerId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['displayName', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listCustomTools: { @@ -2937,6 +4717,13 @@ export const V2_OPERATIONS = { summary: 'List Custom Tools', query: { workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['title', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listFiles: { @@ -2948,6 +4735,14 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, + default: 'uploadedAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, }, @@ -2966,6 +4761,13 @@ export const V2_OPERATIONS = { values: ['workflow', 'knowledge_base', 'table'] as const, }, scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['position', 'name', 'createdAt', 'updatedAt'] as const, + default: 'position', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, }, }, listKnowledgeBases: { @@ -2976,6 +4778,14 @@ export const V2_OPERATIONS = { summary: 'List Knowledge Bases', query: { workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, }, }, listKnowledgeDocuments: { @@ -3046,6 +4856,13 @@ export const V2_OPERATIONS = { summary: 'List MCP Servers', query: { workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listSkills: { @@ -3056,6 +4873,13 @@ export const V2_OPERATIONS = { summary: 'List Skills', query: { workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listTableRows: { @@ -3076,6 +4900,24 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Tables', + query: { + workspaceId: { kind: 'string', required: true }, + folderId: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'createdAt', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listTableViews: { + method: 'GET', + path: '/api/v2/tables/[tableId]/views', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Views', query: { workspaceId: { kind: 'string', required: true }, }, @@ -3113,6 +4955,16 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listWorkflowGroups: { + method: 'GET', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'List Workflow Groups', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, listWorkflows: { method: 'GET', path: '/api/v2/workflows', @@ -3125,6 +4977,24 @@ export const V2_OPERATIONS = { deployedOnly: { kind: 'boolean' }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['position', 'name', 'createdAt', 'updatedAt', 'runCount'] as const, + default: 'position', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, + listWorkflowVersions: { + method: 'GET', + path: '/api/v2/workflows/[id]/versions', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List Workflow Versions', + query: { + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, }, }, moveFileItems: { @@ -3175,6 +5045,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + restoreTable: { + method: 'POST', + path: '/api/v2/tables/[tableId]/restore', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Restore Table', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -3182,6 +5062,32 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Rollback Workflow', }, + runRowEnrichment: { + method: 'POST', + path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', + pathParams: ['tableId', 'rowId', 'groupId'] as const, + responseMode: 'json', + summary: 'Run Enrichment For One Row', + body: { + workspaceId: { kind: 'string', required: true }, + }, + }, + runTableColumn: { + method: 'POST', + path: '/api/v2/tables/[tableId]/columns/run', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Run Column Groups', + body: { + workspaceId: { kind: 'string', required: true }, + groupIds: { kind: 'array', required: true }, + runMode: { kind: 'enum', values: ['all', 'incomplete'] as const, default: 'all' }, + rowIds: { kind: 'array' }, + filter: { kind: 'unknown' }, + excludeRowIds: { kind: 'array' }, + limit: { kind: 'object' }, + }, + }, searchKnowledge: { method: 'POST', path: '/api/v2/knowledge/search', @@ -3197,6 +5103,16 @@ export const V2_OPERATIONS = { searchMode: { kind: 'enum', default: 'vector' }, }, }, + tableExportDownload: { + method: 'GET', + path: '/api/v2/tables/exports/[exportId]/download', + pathParams: ['exportId'] as const, + responseMode: 'json', + summary: 'Download Table Export', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, undeployWorkflow: { method: 'DELETE', path: '/api/v2/workflows/[id]/deploy', @@ -3328,6 +5244,18 @@ export const V2_OPERATIONS = { content: { kind: 'string' }, }, }, + updateTable: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Table', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + folderId: { kind: 'string' }, + }, + }, updateTableColumn: { method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', @@ -3351,17 +5279,53 @@ export const V2_OPERATIONS = { data: { kind: 'unknown', required: true }, }, }, - uploadFile: { - method: 'POST', - path: '/api/v2/files', - pathParams: [] as const, + updateTableView: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/views/[viewId]', + pathParams: ['tableId', 'viewId'] as const, responseMode: 'json', - summary: 'Upload File', - query: { + summary: 'Update View', + body: { workspaceId: { kind: 'string', required: true }, + name: { kind: 'string' }, + config: { kind: 'object' }, + configPatch: { kind: 'object' }, + isDefault: { kind: 'boolean' }, + }, + }, + updateWorkflow: { + method: 'PATCH', + path: '/api/v2/workflows/[id]', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Update Workflow', + body: { + name: { kind: 'string' }, + description: { kind: 'string' }, folderId: { kind: 'string' }, }, }, + updateWorkflowGroup: { + method: 'PATCH', + path: '/api/v2/tables/[tableId]/groups', + pathParams: ['tableId'] as const, + responseMode: 'json', + summary: 'Update Workflow Group', + body: { + workspaceId: { kind: 'string', required: true }, + groupId: { kind: 'string', required: true }, + workflowId: { kind: 'string' }, + name: { kind: 'string' }, + dependencies: { kind: 'object' }, + outputs: { kind: 'array' }, + newOutputColumns: { kind: 'array' }, + mappingUpdates: { kind: 'array' }, + inputMappings: { kind: 'array' }, + deploymentMode: { kind: 'enum', values: ['live', 'deployed'] as const }, + type: { kind: 'enum', values: ['manual', 'enrichment'] as const }, + autoRun: { kind: 'boolean' }, + }, + }, uploadKnowledgeDocument: { method: 'POST', path: '/api/v2/knowledge/[id]/documents', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index af5189b9aab..9e36ea232db 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -97,7 +97,15 @@ describe('destructive operations are gated', () => { * and the contract renames it accordingly. Everything else that deletes is * gated behind `--yes`. */ - const NOT_DESTRUCTIVE = new Set(['undeployWorkflow']) + const NOT_DESTRUCTIVE = new Set([ + 'undeployWorkflow', + // Each of these stops something in flight rather than destroying something + // kept: an upload that has not been completed owns nothing but its own + // parts, and a cancelled import or export can simply be started again. + 'abortFileUpload', + 'cancelTableImport', + 'cancelTableExport', + ]) it('every DELETE carries a confirmation message', () => { // Without this, a new v2 domain arrives through generation with working diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 0c806c31db8..96b640a43ac 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -36,6 +36,8 @@ export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' query?: Record body?: unknown + /** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */ + headers?: Record } function buildUrl(endpoint: string, path: string, query?: Record): string { @@ -135,6 +137,7 @@ export class SimClient { 'x-api-key': apiKey, accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), + ...options.headers, }, body: hasBody ? JSON.stringify(options.body) : undefined, }) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 67d3c2c741d..bd85c450597 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -181,13 +181,24 @@ type JsonSchema = Record * produces from these contracts. * * Hand-rolled rather than pulled from `json-schema-to-typescript`: the input is - * a known, narrow subset (no `$ref`, no `patternProperties`, no draft-04 - * quirks), and the output is committed and read by humans, so controlling the - * formatting is worth more here than covering spec corners that never appear. - * An unhandled construct throws rather than degrading to `any` — silence is how - * a generated client drifts from its server. + * a known, narrow subset (no `patternProperties`, no draft-04 quirks), and the + * output is committed and read by humans, so controlling the formatting is + * worth more here than covering spec corners that never appear. An unhandled + * construct throws rather than degrading to `any` — silence is how a generated + * client drifts from its server. + * + * `refs` maps a `$defs` key to the TypeScript alias hoisted for it. Zod factors + * a schema out into `$defs` when it is recursive, which the table view's filter + * grammar is — a predicate holds predicates — so it cannot be inlined. */ -function toTypeScript(schema: JsonSchema, indent = 0): string { +function toTypeScript(schema: JsonSchema, indent = 0, refs?: Map): string { + if (typeof schema.$ref === 'string') { + const key = schema.$ref.replace('#/$defs/', '') + const name = refs?.get(key) + if (!name) throw new Error(`Unresolved $ref: ${schema.$ref}`) + return name + } + const pad = ' '.repeat(indent + 1) const closePad = ' '.repeat(indent) @@ -196,11 +207,11 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { const variants = schema.anyOf ?? schema.oneOf if (variants) { - return variants.map((v: JsonSchema) => toTypeScript(v, indent)).join(' | ') + return variants.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' | ') } if (schema.allOf) { - return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent)).join(' & ') + return schema.allOf.map((v: JsonSchema) => toTypeScript(v, indent, refs)).join(' & ') } switch (schema.type) { @@ -214,7 +225,7 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { case 'null': return 'null' case 'array': - return schema.items ? `Array<${toTypeScript(schema.items, indent)}>` : 'unknown[]' + return schema.items ? `Array<${toTypeScript(schema.items, indent, refs)}>` : 'unknown[]' case 'object': { const properties: Record = schema.properties ?? {} const required: string[] = schema.required ?? [] @@ -224,7 +235,7 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { // A bare object with only `additionalProperties` is a record. const value = schema.additionalProperties && typeof schema.additionalProperties === 'object' - ? toTypeScript(schema.additionalProperties, indent) + ? toTypeScript(schema.additionalProperties, indent, refs) : 'unknown' return `Record` } @@ -232,7 +243,7 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { const lines = keys.map((key) => { const optional = required.includes(key) ? '' : '?' const safeKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key) - return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1)}` + return `${pad}${safeKey}${optional}: ${toTypeScript(properties[key], indent + 1, refs)}` }) return `{\n${lines.join('\n')}\n${closePad}}` } @@ -244,9 +255,32 @@ function toTypeScript(schema: JsonSchema, indent = 0): string { throw new Error(`Unhandled JSON Schema construct: ${JSON.stringify(schema).slice(0, 200)}`) } -function schemaToType(schema: z.ZodType, io: 'input' | 'output'): string { +/** + * A type plus any aliases that must be declared before it. + * + * A recursive schema cannot be written inline, so Zod lifts it into `$defs` and + * points at it; those become real named types, which TypeScript resolves + * recursively without complaint. + */ +interface GeneratedType { + type: string + declarations: string[] +} + +function schemaToType(schema: z.ZodType, io: 'input' | 'output', name: string): GeneratedType { const json = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema - return toTypeScript(json) + const defs = json.$defs as Record | undefined + if (!defs) return { type: toTypeScript(json), declarations: [] } + + // Named after the type that owns them, so two operations lifting their own + // `__schema0` cannot collide in the single generated module. + const refs = new Map(Object.keys(defs).map((key, index) => [key, `${name}Ref${index}`])) + const declarations = Object.entries(defs).map( + ([key, def]) => `type ${refs.get(key)} = ${toTypeScript(def, 0, refs)}\n` + ) + + const { $defs, ...root } = json + return { type: toTypeScript(root, 0, refs), declarations } } /** Path params the CLI must substitute, e.g. `/api/v2/workflows/[id]` → `['id']`. */ @@ -361,12 +395,17 @@ function render(operations: Operation[]): string { for (const slot of ['params', 'query', 'body', 'headers'] as const) { const schema = contract[slot] if (!schema) continue - out.push(`export type ${Name}${pascal(slot)} = ${schemaToType(schema, 'input')}`) + const slotName = `${Name}${pascal(slot)}` + const generated = schemaToType(schema, 'input', slotName) + out.push(...generated.declarations) + out.push(`export type ${slotName} = ${generated.type}`) out.push('') } if (contract.response.mode === 'json' && contract.response.schema) { - out.push(`export type ${Name}Response = ${schemaToType(contract.response.schema, 'output')}`) + const generated = schemaToType(contract.response.schema, 'output', `${Name}Response`) + out.push(...generated.declarations) + out.push(`export type ${Name}Response = ${generated.type}`) } else { out.push(`/** Non-JSON response (\`${contract.response.mode}\`). */`) out.push(`export type ${Name}Response = never`) From 39a4a4fcdebe300c6fd23f38e7f45e08b03b059f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 18:25:26 -0700 Subject: [PATCH 25/28] feat(cli): sim tables import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports a CSV into a new or existing table, driving the same presigned handshake `files upload` uses — the two are the same protocol against different paths, so they now share one implementation. What made this more than a wrapper is that the import carries decisions the handshake does not: the source is a local file or one already in the workspace, the target is a new table or an existing one to append to or replace, and mapping/createColumns are rejected unless the target is existing. Both choices are required rather than inferred — defaulting to a new table would turn a forgotten --to-table into a silent second copy of the data — and the conditional flags are checked here so the error names the flag instead of arriving as a complaint about the request body. The transfer only queues the work; rows are parsed afterwards, so returning at `complete` would report success for an import that goes on to fail on a bad row. It polls to a settled status and reports the rows written, with progress on a terminal only. --no-wait opts out. The handshake steps are hidden now that a command drives them; `imports get` and `imports cancel` stay, being useful against an import already running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- .../sim-cli/src/commands/hand-written.test.ts | 57 ++- packages/sim-cli/src/commands/hand-written.ts | 328 +++++++++++++++--- packages/sim-cli/src/contract/commands.ts | 20 +- 3 files changed, 333 insertions(+), 72 deletions(-) diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts index eb3cedc1f50..b2fa54e6c99 100644 --- a/packages/sim-cli/src/commands/hand-written.test.ts +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -1,8 +1,16 @@ import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { streamToFile } from './hand-written.js' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { attachHandWritten, streamToFile } from './hand-written.js' + +vi.mock('../context.js', () => ({ + clientFrom: () => ({ + client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) let dir: string @@ -59,3 +67,48 @@ describe('streamToFile', () => { } ) }) + +describe('tables import argument guards', () => { + function importCommand(): Command { + const root = new Command('sim').exitOverride() + attachHandWritten(root) + const walk = (command: Command) => { + command.exitOverride() + command.commands.forEach(walk) + } + walk(root) + return root + } + + async function run(argv: string[]) { + await importCommand().parseAsync(['node', 'sim', 'tables', 'import', ...argv]) + } + + it('refuses to guess the target', async () => { + // Defaulting to a new table would turn a forgotten `--to-table` into a + // silent second copy of the data. + await expect(run(['f.csv'])).rejects.toThrow(/exactly one of --new-table/) + await expect(run(['f.csv', '--new-table', 'a', '--to-table', 't'])).rejects.toThrow( + /exactly one of --new-table/ + ) + }) + + it('refuses to guess the source', async () => { + await expect(run(['--new-table', 'a'])).rejects.toThrow(/exactly one of /) + await expect(run(['f.csv', '--new-table', 'a', '--file-id', 'w_1'])).rejects.toThrow( + /exactly one of / + ) + }) + + it('names the flag when mapping is paired with a new table', async () => { + // The server rejects this too, but as a message about the request body. + await expect(run(['f.csv', '--new-table', 'a', '--mapping', '{}'])).rejects.toThrow( + /--to-table only/ + ) + }) + + it('checks all of that before touching the filesystem', async () => { + // `f.csv` does not exist; a "cannot read" error would mean a guard ran late. + await expect(run(['f.csv'])).rejects.toThrow(/exactly one of/) + }) +}) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index af16b39e2dc..4979868bac4 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -8,6 +8,7 @@ import { clientFrom } from '../context.js' import type { QueryRowsResponse } from '../generated/v2-api.js' import { SimApiError, type SimClient } from '../http/client.js' import { type Column, printList, sanitize, text } from '../output/render.js' +import { coerce } from '../runtime/request.js' /** * Commands the generated runtime cannot produce. @@ -155,12 +156,27 @@ interface UploadPartUrl { headers: Record } +/** + * What a transfer needs to send its bytes, however it was started. + * + * File uploads and table imports are the same handshake against different + * paths — identical part-URL and complete bodies, the same `upload-token` + * header — so one implementation drives both. `basePath` is the transfer's own + * resource; `/parts` and `/complete` hang off it and DELETE aborts it. + */ +interface Transfer { + basePath: string + uploadToken: string + partSize: number + partCount: number + size: number +} + interface FileUpload { id: string - size: number + uploadToken: string partSize: number partCount: number - uploadToken: string file: { id: string } | null } @@ -176,38 +192,38 @@ const PART_URL_BATCH = 100 * slow connection reached them. * * Parts go out one at a time. Concurrency would be faster, but a failure - * mid-flight has to abort the whole upload anyway, and a sequential loop makes - * "which part failed" unambiguous. + * mid-flight has to abort the whole transfer anyway, and a sequential loop + * makes "which part failed" unambiguous. */ async function uploadParts( client: SimClient, workspaceId: string, - upload: FileUpload, + transfer: Transfer, blob: Blob ): Promise> { const completed: Array<{ partNumber: number; etag?: string }> = [] - for (let first = 1; first <= upload.partCount; first += PART_URL_BATCH) { + for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { const partNumbers = [] - for (let n = first; n < first + PART_URL_BATCH && n <= upload.partCount; n++) { + for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { partNumbers.push(n) } const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/parts`, + `${transfer.basePath}/parts`, { method: 'POST', query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, + headers: { 'upload-token': transfer.uploadToken }, body: { partNumbers }, } ) for (const part of signed.data.parts) { - const start = (part.partNumber - 1) * upload.partSize + const start = (part.partNumber - 1) * transfer.partSize // `Blob.slice` is a view over the file on disk, so only the part being // sent is ever read — the point of not buffering the upload. - const chunk = blob.slice(start, Math.min(start + upload.partSize, upload.size)) + const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) // boundary-raw-fetch: storage-signed URL on another origin, not the API const response = await fetch(part.url, { @@ -232,6 +248,129 @@ async function uploadParts( return completed } +/** + * Runs a started transfer to completion: send the parts, then complete it. + * + * Anything that fails in between aborts the transfer, because a half-finished + * one holds storage the server would otherwise keep until it expires. A failed + * abort is swallowed — the original failure is what the caller needs to see. + */ +async function finishTransfer( + client: SimClient, + workspaceId: string, + transfer: Transfer, + path: string +): Promise { + try { + const blob = await openAsBlob(path) + const parts = await uploadParts(client, workspaceId, transfer, blob) + + const completed = await client.request<{ data: T }>(`${transfer.basePath}/complete`, { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { parts }, + }) + return completed.data + } catch (error) { + await client + .request(transfer.basePath, { + method: 'DELETE', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + }) + .catch(() => undefined) + throw error + } +} + +interface TableImport { + id: string + status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired' + tableId: string | null + rowsProcessed: number + error: string | null + upload: { uploadToken: string; partSize: number; partCount: number } | null +} + +interface ImportOptions { + newTable?: string + toTable?: string + mode: string + folderId?: string + fileId?: string + mapping?: string + createColumns?: string + timezone?: string + /** commander sets this false for `--no-wait`. */ + wait: boolean +} + +/** How often to ask an in-progress import where it got to. */ +const IMPORT_POLL_MS = 1500 + +/** Statuses the server will not move away from. */ +const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) + +/** + * Parses a JSON flag through the same path the generated commands use, so + * `@file` and `@-` work here too rather than only on generated flags. + */ +function jsonFlag(raw: string, flagName: string): unknown { + return coerce(raw, { kind: 'object' }, { json: true }, flagName) +} + +/** + * Polls an import until it settles. + * + * The transfer only queues the work: rows are parsed server-side afterwards, so + * a command that returned at `complete` would report success for an import that + * goes on to fail on a malformed row. + */ +async function watchImport( + client: SimClient, + workspaceId: string, + job: TableImport +): Promise { + let current = job + let reported = -1 + + while (!IMPORT_SETTLED.has(current.status)) { + await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS)) + const next = await client.request<{ data: TableImport }>( + `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, + { query: { workspaceId } } + ) + current = next.data + + // Only on a terminal, and only when it moves: the line rewrites itself with + // a carriage return, which in a redirected log is just escape noise. + if (process.stderr.isTTY && current.rowsProcessed !== reported) { + reported = current.rowsProcessed + process.stderr.write(`\r${chalk.dim(`${current.status}… ${reported} rows`)}\u001b[K`) + } + } + + if (process.stderr.isTTY && reported >= 0) process.stderr.write('\r\u001b[K') + return current +} + +/** Size and name checks every local-file transfer needs before starting one. */ +async function localFile(path: string, override?: string): Promise<{ name: string; size: number }> { + let size: number + try { + const stats = await stat(path) + if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + size = stats.size + } catch (error) { + if (error instanceof SimApiError) throw error + throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) + } + // A zero-byte transfer has no parts to send; the server cannot accept one. + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(path), size } +} + export function attachHandWritten(program: Command): void { // ── files upload ── a presigned multipart handshake, not one request ────── group(program, 'files') @@ -243,22 +382,7 @@ export function attachHandWritten(program: Command): void { async (path: string, options: { folderId?: string; name?: string }, command: Command) => { const { client } = clientFrom(command) const workspaceId = client.requireWorkspace() - - let size: number - try { - const stats = await stat(path) - if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) - size = stats.size - } catch (error) { - if (error instanceof SimApiError) throw error - throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0) - } - - // The server sizes its own parts, but it cannot reject an empty file any - // more cheaply than we can: a zero-byte upload has no parts to send. - if (size === 0) throw new SimApiError(`${path} is empty`, 0) - - const name = options.name ?? basename(path) + const { name, size } = await localFile(path, options.name) const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { method: 'POST', @@ -272,39 +396,129 @@ export function attachHandWritten(program: Command): void { }) const upload = created.data - // Any failure past this point leaves an upload holding storage, so the - // rest runs under an abort that the server also uses to release it. - try { - const blob = await openAsBlob(path) - const parts = await uploadParts(client, workspaceId, upload, blob) - - const completed = await client.request<{ data: FileUpload }>( - `/api/v2/files/uploads/${encodeURIComponent(upload.id)}/complete`, - { - method: 'POST', - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - body: { parts }, - } - ) - console.log( - chalk.green(`✓ Uploaded ${name} (${completed.data.file?.id ?? completed.data.id})`) - ) - } catch (error) { - await client - .request(`/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, { - method: 'DELETE', - query: { workspaceId }, - headers: { 'upload-token': upload.uploadToken }, - }) - // The original failure is what the caller needs; a failed cleanup - // must not replace it with a message about the cleanup. - .catch(() => undefined) - throw error - } + const completed = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, + uploadToken: upload.uploadToken, + partSize: upload.partSize, + partCount: upload.partCount, + size, + }, + path + ) + + console.log(chalk.green(`✓ Uploaded ${name} (${completed.file?.id ?? completed.id})`)) } ) + // ── tables import ── a transfer, then an async job to watch ────────────── + const tablesGroup = group(program, 'tables') + tablesGroup + .command('import [path]') + .description('Import a CSV into a new or existing table') + .option('--new-table ', 'Create a table with this name') + .option('--to-table ', 'Import into an existing table') + .option('--mode ', 'How to write into an existing table', 'append') + .option('--folder-id ', 'Folder for a new table') + .option('--file-id ', 'Import a file already in the workspace instead of a local path') + .option('--mapping ', 'Column mapping (existing table only)') + .option('--create-columns ', 'Columns to create (existing table only)') + .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') + .option('--no-wait', 'Return once the import is queued instead of watching it') + .action(async (path: string | undefined, options: ImportOptions, command: Command) => { + const { client } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + // Both target choices are stated, never inferred. Defaulting to a new + // table would turn a forgotten `--to-table` into a second copy of the + // data, which is not something to discover afterwards. + if (Boolean(options.newTable) === Boolean(options.toTable)) { + throw new SimApiError('Pass exactly one of --new-table or --to-table ', 0) + } + if (Boolean(path) === Boolean(options.fileId)) { + throw new SimApiError('Pass exactly one of or --file-id ', 0) + } + // The server rejects these against a new table; saying so here names the + // flag rather than returning a validation error about the request body. + if (options.newTable && (options.mapping || options.createColumns)) { + throw new SimApiError( + '--mapping and --create-columns apply to --to-table only: a new table takes its columns from the CSV', + 0 + ) + } + + const local = path ? await localFile(path, undefined) : null + const source = local + ? { + type: 'upload', + name: local.name, + contentType: contentTypeFor(local.name), + size: local.size, + } + : { type: 'workspace_file', fileId: options.fileId } + + const target = options.toTable + ? { type: 'existing', tableId: options.toTable, mode: options.mode } + : { + type: 'new', + name: options.newTable, + ...(options.folderId ? { folderId: options.folderId } : {}), + } + + const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + }) + + let job = started.data + + // A workspace_file source has nothing to upload — the bytes are already + // there, and the server starts the job without a transfer. + if (path && job.upload) { + job = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + uploadToken: job.upload.uploadToken, + partSize: job.upload.partSize, + partCount: job.upload.partCount, + size: local?.size ?? 0, + }, + path + ) + } + + if (!options.wait) { + console.log(chalk.green(`✓ Import ${job.id} ${job.status}`)) + return + } + + const finished = await watchImport(client, workspaceId, job) + if (finished.status !== 'completed') { + throw new SimApiError( + `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, + 0 + ) + } + console.log( + chalk.green( + `✓ Imported ${finished.rowsProcessed} rows${finished.tableId ? ` into ${finished.tableId}` : ''}` + ) + ) + }) + // ── files download ── the response is binary, not the JSON envelope ──────── group(program, 'files') .command('download ') diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 92adecf48bc..c74c16c4132 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -263,19 +263,13 @@ export const CLI_CONTRACT: CliContract = { describe: 'Run one row’s enrichment group', }, - // Transfers are a handshake: create, request part URLs, send the parts, then - // complete. Unlike `files upload` there is no single command driving this yet - // — the import body carries source/target/mapping choices a one-liner cannot - // express — so each step stays reachable under a name that says what it is. - createTableImport: { command: 'tables imports create' }, - createTableImportPartUrls: { - command: 'tables imports parts', - describe: 'Sign upload URLs for a batch of parts', - }, - completeTableImport: { - command: 'tables imports complete', - describe: 'Finish an import once every part is uploaded', - }, + // The handshake behind `sim tables import`. Its halfway states hold storage + // and a half-sent import is not something to leave reachable, so the steps + // stay hidden — unlike `get` and `cancel`, which are useful on their own for + // an import already running. + createTableImport: { hidden: true }, + createTableImportPartUrls: { hidden: true }, + completeTableImport: { hidden: true }, cancelTableImport: { command: 'tables imports cancel' }, cancelTableExport: { command: 'tables exports cancel' }, tableExportDownload: { From 4ece5b7619f7e12b59911e01adadb4876850c97c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 18:41:58 -0700 Subject: [PATCH 26/28] feat(cli): default tables import to a new table named after the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sim tables import people.csv` now does the obvious thing rather than demanding a target. Requiring one guarded the wrong direction: a forgotten flag creating a new table is visible and easily undone, while the outcome worth protecting — writing into an existing table — is the one that now has to be asked for by name. --to-table becomes --table-id, and --mode/--mapping/--create-columns apply only alongside it. Passing one without it is an error rather than a no-op: silently ignoring `--mode replace` would let it read as honoured while a new table was created beside the one it was meant to overwrite. The reverse is also refused, since --table-id already names the destination. The derived name is sanitized, because table names are identifiers: the obvious basename would reject most real files, so `2026-quarterly sales.csv` imports as `_2026_quarterly_sales` instead of failing. --name overrides it, and is required for --file-id, where there is no file name to take one from. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- .../sim-cli/src/commands/hand-written.test.ts | 38 ++++---- packages/sim-cli/src/commands/hand-written.ts | 91 +++++++++++++------ 2 files changed, 83 insertions(+), 46 deletions(-) diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts index b2fa54e6c99..10f3d08b5fd 100644 --- a/packages/sim-cli/src/commands/hand-written.test.ts +++ b/packages/sim-cli/src/commands/hand-written.test.ts @@ -84,31 +84,35 @@ describe('tables import argument guards', () => { await importCommand().parseAsync(['node', 'sim', 'tables', 'import', ...argv]) } - it('refuses to guess the target', async () => { - // Defaulting to a new table would turn a forgotten `--to-table` into a - // silent second copy of the data. - await expect(run(['f.csv'])).rejects.toThrow(/exactly one of --new-table/) - await expect(run(['f.csv', '--new-table', 'a', '--to-table', 't'])).rejects.toThrow( - /exactly one of --new-table/ - ) + it('refuses to guess the source', async () => { + // A new table is a safe default; where the bytes are is not inferable. + await expect(run([])).rejects.toThrow(/exactly one of /) + await expect(run(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) }) - it('refuses to guess the source', async () => { - await expect(run(['--new-table', 'a'])).rejects.toThrow(/exactly one of /) - await expect(run(['f.csv', '--new-table', 'a', '--file-id', 'w_1'])).rejects.toThrow( - /exactly one of / - ) + it('rejects existing-table flags when creating one', async () => { + // Ignoring these would let `--mode replace` read as honoured while a new + // table is created beside the one it was meant to overwrite. + await expect(run(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) + await expect(run(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) + await expect(run(['f.csv', '--create-columns', '{}'])).rejects.toThrow(/applies to --table-id/) }) - it('names the flag when mapping is paired with a new table', async () => { - // The server rejects this too, but as a message about the request body. - await expect(run(['f.csv', '--new-table', 'a', '--mapping', '{}'])).rejects.toThrow( - /--to-table only/ + it('rejects new-table flags when importing into an existing one', async () => { + await expect(run(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( + /--table-id already names the destination/ ) + await expect(run(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( + /--table-id already names the destination/ + ) + }) + + it('asks for a name when there is no file name to take one from', async () => { + await expect(run(['--file-id', 'w_1'])).rejects.toThrow(/--name /) }) it('checks all of that before touching the filesystem', async () => { // `f.csv` does not exist; a "cannot read" error would mean a guard ran late. - await expect(run(['f.csv'])).rejects.toThrow(/exactly one of/) + await expect(run(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) }) }) diff --git a/packages/sim-cli/src/commands/hand-written.ts b/packages/sim-cli/src/commands/hand-written.ts index 4979868bac4..80142fba0a6 100644 --- a/packages/sim-cli/src/commands/hand-written.ts +++ b/packages/sim-cli/src/commands/hand-written.ts @@ -294,9 +294,9 @@ interface TableImport { } interface ImportOptions { - newTable?: string - toTable?: string - mode: string + name?: string + tableId?: string + mode?: string folderId?: string fileId?: string mapping?: string @@ -306,6 +306,22 @@ interface ImportOptions { wait: boolean } +/** + * Turns a file name into a legal table name. + * + * Table names are identifiers — `^[A-Za-z_][A-Za-z0-9_]*$`, 128 max — so the + * obvious `basename(path)` would reject most real files: `2026-sales.csv` and + * `customer data.csv` both fail. Runs of anything else collapse to a single + * underscore, and a leading digit gets one in front, so a default derived from + * the file is a name the server actually accepts. + */ +function tableNameFrom(fileName: string): string { + const stem = fileName.replace(/\.[^.]+$/, '') + const cleaned = stem.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + if (!cleaned) return 'imported_table' + return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) +} + /** How often to ask an in-progress import where it got to. */ const IMPORT_POLL_MS = 1500 @@ -414,37 +430,49 @@ export function attachHandWritten(program: Command): void { ) // ── tables import ── a transfer, then an async job to watch ────────────── - const tablesGroup = group(program, 'tables') - tablesGroup + group(program, 'tables') .command('import [path]') - .description('Import a CSV into a new or existing table') - .option('--new-table ', 'Create a table with this name') - .option('--to-table ', 'Import into an existing table') - .option('--mode ', 'How to write into an existing table', 'append') - .option('--folder-id ', 'Folder for a new table') + .description('Import a CSV, into a new table by default') + .option('--name ', 'Name for the new table (defaults to the file name)') + .option('--table-id ', 'Import into this existing table instead of creating one') + .option('--mode ', 'How to write into --table-id (default: append)') + .option('--folder-id ', 'Folder for the new table') .option('--file-id ', 'Import a file already in the workspace instead of a local path') - .option('--mapping ', 'Column mapping (existing table only)') - .option('--create-columns ', 'Columns to create (existing table only)') + .option('--mapping ', 'Column mapping (--table-id only)') + .option('--create-columns ', 'Columns to create (--table-id only)') .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') .option('--no-wait', 'Return once the import is queued instead of watching it') .action(async (path: string | undefined, options: ImportOptions, command: Command) => { const { client } = clientFrom(command) const workspaceId = client.requireWorkspace() - // Both target choices are stated, never inferred. Defaulting to a new - // table would turn a forgotten `--to-table` into a second copy of the - // data, which is not something to discover afterwards. - if (Boolean(options.newTable) === Boolean(options.toTable)) { - throw new SimApiError('Pass exactly one of --new-table or --to-table ', 0) - } + // The one thing that cannot be inferred: the bytes are either local or + // already in the workspace, and neither implies the other. if (Boolean(path) === Boolean(options.fileId)) { throw new SimApiError('Pass exactly one of or --file-id ', 0) } - // The server rejects these against a new table; saying so here names the - // flag rather than returning a validation error about the request body. - if (options.newTable && (options.mapping || options.createColumns)) { + + const intoExisting = Boolean(options.tableId) + + // Flags that only mean something for one target. Silently ignoring them + // would let `--mode replace` read as honoured while a new table is + // created beside the one it was meant to overwrite. + const misplaced = intoExisting + ? ([ + ['--name', options.name], + ['--folder-id', options.folderId], + ] as const) + : ([ + ['--mode', options.mode], + ['--mapping', options.mapping], + ['--create-columns', options.createColumns], + ] as const) + for (const [flag, value] of misplaced) { + if (value === undefined) continue throw new SimApiError( - '--mapping and --create-columns apply to --to-table only: a new table takes its columns from the CSV', + intoExisting + ? `${flag} applies to a new table; --table-id already names the destination` + : `${flag} applies to --table-id: a new table takes its name and columns from the CSV`, 0 ) } @@ -459,13 +487,18 @@ export function attachHandWritten(program: Command): void { } : { type: 'workspace_file', fileId: options.fileId } - const target = options.toTable - ? { type: 'existing', tableId: options.toTable, mode: options.mode } - : { - type: 'new', - name: options.newTable, - ...(options.folderId ? { folderId: options.folderId } : {}), - } + let target: Record + if (intoExisting) { + target = { type: 'existing', tableId: options.tableId, mode: options.mode ?? 'append' } + } else { + // A local file names the table; a workspace file id does not, and + // guessing one from an id would produce nonsense. + const name = options.name ?? (local ? tableNameFrom(local.name) : undefined) + if (!name) { + throw new SimApiError('Pass --name to say what the new table is called', 0) + } + target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } + } const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { method: 'POST', From 0c7b64a55a92b8527ac260ee562795a64161bd55 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 22:26:28 -0700 Subject: [PATCH 27/28] chore(cli): regenerate for the paginated table list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listTables` gained `limit` and `cursor`, so the CLI's auto-pager now drives it like every other paginated list — no CLI change, which is the point of generating this file. Also picks up `isCurrent` on workflow versions and a new `voice-output` enum member. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/generated/v2-api.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 34c5aa8b163..d57cd49c79b 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1356,6 +1356,7 @@ export type DeployWorkflowResponse = { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean readiness: { webhooks: 'pending' | 'ready' | 'not_applicable' schedules: 'pending' | 'ready' | 'not_applicable' @@ -2633,6 +2634,8 @@ export type ListTablesQuery = { search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string } export type ListTablesResponse = { @@ -2785,6 +2788,7 @@ export type ListUsageLogsQuery = { | 'knowledge-base' | 'voice-input' | 'enrichment' + | 'voice-output' workspaceId?: string period?: '1d' | '7d' | '30d' | 'all' | 'custom' startDate?: string @@ -2807,6 +2811,7 @@ export type ListUsageLogsResponse = { | 'knowledge-base' | 'voice-input' | 'enrichment' + | 'voice-output' workflowName: string | null creditCost: number }> @@ -3060,6 +3065,7 @@ export type RollbackWorkflowResponse = { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean readiness: { webhooks: 'pending' | 'ready' | 'not_applicable' schedules: 'pending' | 'ready' | 'not_applicable' @@ -3191,6 +3197,7 @@ export type UndeployWorkflowResponse = { version: number action: 'deploy' | 'activate' status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + isCurrent: boolean readiness: { webhooks: 'pending' | 'ready' | 'not_applicable' schedules: 'pending' | 'ready' | 'not_applicable' @@ -4910,6 +4917,8 @@ export const V2_OPERATIONS = { default: 'createdAt', }, sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + limit: { kind: 'number', default: 100 }, + cursor: { kind: 'string' }, }, }, listTableViews: { @@ -4941,6 +4950,7 @@ export const V2_OPERATIONS = { 'knowledge-base', 'voice-input', 'enrichment', + 'voice-output', ] as const, }, workspaceId: { kind: 'string' }, From e1810a6536bc4099fb1231805876e85ccc13b88a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 3 Aug 2026 23:06:29 -0700 Subject: [PATCH 28/28] fix(cli): make tables rows create and tables columns run usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were dead on arrival, for opposite reasons. `createTableRows` takes `z.union([batch, single])`. A union has no flat field list, so the generator emitted no body slot — and slot absence reads the same as "this operation has no body", so the command offered nothing and sent nothing. The generator's own comment claimed the runtime fell back to taking the body as JSON; nothing did. Unions are now marked, the fields every branch shares are still emitted (both require `workspaceId`, which comes from the profile), and `--body ` carries the rest, merged over them so the caller still wins on any key it sets. Dropping that merge was my first attempt and it failed on the missing workspace. `runTableColumn` takes `limit: { type, max }`. The pager claimed the *name* `limit` regardless of type, so it became `--limit ` with a default of 100 and sent a number the route rejected on every call, whether or not the flag was passed. The special case now applies only where `limit` is numeric; elsewhere it is an ordinary field and gets the JSON flag its type calls for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU --- packages/sim-cli/src/generated/v2-api.ts | 4 ++ packages/sim-cli/src/runtime/build.test.ts | 63 ++++++++++++++++++++++ packages/sim-cli/src/runtime/build.ts | 22 +++++++- packages/sim-cli/src/runtime/request.ts | 15 ++++++ scripts/generate-v2-cli-api.ts | 45 ++++++++++++++-- 5 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index d57cd49c79b..bec61da9beb 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4233,6 +4233,10 @@ export const V2_OPERATIONS = { pathParams: ['tableId'] as const, responseMode: 'json', summary: 'Create Rows', + body: { + workspaceId: { kind: 'string', required: true }, + }, + opaqueBody: true, }, createTableView: { method: 'POST', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 15677b76ed6..15cc616340e 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -328,3 +328,66 @@ describe('boolean flags', () => { ) }) }) + +describe('bodies and fields the generator cannot flatten', () => { + it('sends a union body whole, with the profile workspace merged in', async () => { + // `createTableRows` is `z.union([batch, single])`, so there is no field list + // to build flags from. The command exposed nothing at all and sent no body, + // and every call failed with "Request body must be valid JSON". + const [path, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--body', + '{"rows":[{"city":"Paris"}]}', + ]) + + expect(path).toBe('/api/v2/tables/tbl_1/rows') + // Both branches require `workspaceId`, and it comes from the profile. + expect(options.body).toEqual({ workspaceId: 'ws_local', rows: [{ city: 'Paris' }] }) + }) + + it('lets the caller override a shared field', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'create', + 'tbl_1', + '--body', + '{"workspaceId":"ws_other","rows":[]}', + ]) + expect(options.body).toMatchObject({ workspaceId: 'ws_other' }) + }) + + it('refuses a union body that is not an object', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1', '--body', '[1,2]'])).rejects.toThrow( + /--body must be a JSON object/ + ) + }) + + it('leaves a non-numeric `limit` alone', async () => { + // `runTableColumn` takes `limit: { type, max }`. The pager claimed the name + // regardless of type, turning it into `--limit ` that defaulted to 100, + // so every call failed with "expected object, received number". + const [, omitted] = await run(['tables', 'columns', 'run', 'tbl_1', '--group-ids', '["g1"]']) + expect(omitted.body).not.toHaveProperty('limit') + + const [, given] = await run([ + 'tables', + 'columns', + 'run', + 'tbl_1', + '--group-ids', + '["g1"]', + '--limit', + '{"type":"rows","max":5}', + ]) + expect(given.body).toMatchObject({ limit: { type: 'rows', max: 5 } }) + }) + + it('still gives paginated lists their numeric --limit', async () => { + const [, options] = await run(['files', 'list', '--limit', '7']) + expect(options.query).toMatchObject({ limit: 7 }) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3b76d0a4699..a1da31835c5 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -153,6 +153,11 @@ function unwrapResource(data: unknown): unknown { return value && typeof value === 'object' && !Array.isArray(value) ? value : data } +/** Whether the operation's body is one the generator could not describe field by field. */ +function opaqueBody(spec: object): boolean { + return (spec as { opaqueBody?: boolean }).opaqueBody === true +} + /** The operation's one-line help, taken from the OpenAPI summary at generation time. */ function summaryFor(operation: V2OperationName): string | undefined { return (V2_OPERATIONS[operation] as { summary?: string }).summary @@ -195,7 +200,11 @@ function addFieldOption( const name = flagNameFor(operation, field) const short = flag.short ? `-${flag.short}, ` : '' - if (field === 'limit') { + // The pager owns `--limit`, but only where `limit` means a page size. The + // name is not reserved: `runTableColumn` takes `limit: { type, max }`, and + // claiming it here turned that into a numeric flag that defaulted to 100 and + // made every invocation fail with "expected object, received number". + if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { command.option( `--limit `, 'Maximum items to return (0 for everything)', @@ -286,6 +295,17 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri } } + // A body the generator could not break into fields is offered whole. The + // union behind `tables rows create` (one row, or a batch) has no field list + // to build flags from, and without this the command sent no body at all and + // the server rejected the request as malformed JSON. + if (opaqueBody(operationSpec)) { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin)' + ) + } + if (spec.confirm) { command.option('-y, --yes', 'Skip the confirmation') } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index ccda57dbc89..fa7743bde31 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -213,6 +213,7 @@ export function buildRequest( pathParams: readonly string[] query?: Record body?: Record + opaqueBody?: boolean } let path = spec.path @@ -256,6 +257,20 @@ export function buildRequest( } } + // A union body comes in whole through `--body`, merged over the fields the + // branches share. Replacing outright dropped the profile's `workspaceId`, + // which both branches require, so every insert came back as invalid input. + // The caller's JSON still wins on any key it sets. + if (spec.opaqueBody) { + const raw = flags.body + if (typeof raw !== 'string') throw new SimApiError('--body is required', 0) + const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body') + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new SimApiError('--body must be a JSON object', 0) + } + return { path, query, body: { ...body, ...(parsed as Record) } } + } + return { path, query, diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index bd85c450597..6b93778c00f 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -341,16 +341,46 @@ function fieldKind(schema: JsonSchema): FieldKind { * Emitted as data rather than baked into types because the CLI has to *iterate* * these at startup to construct commands — a type alone cannot be walked. */ +/** + * Whether the slot is a union, whose branches the CLI cannot turn into flags. + * + * Distinct from "the map came out empty": the shared fields of a union are + * emitted as a map, so emptiness alone no longer identifies one, and the + * runtime still has to know the rest of the body must come in as JSON. + */ +function isUnionSlot(schema: z.ZodType): boolean { + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema + return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf) +} + function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null { if (!schema) return null const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema - const properties: Record = json.properties ?? {} - const required = new Set(json.required ?? []) + let properties: Record = json.properties ?? {} + let required = new Set(json.required ?? []) + + // A union has no properties of its own, but the fields every branch agrees on + // are still known and still have to be sent — `workspaceId` is required by + // both branches of the row-insert body and comes from the profile, so + // dropping it left `tables rows create` rejected as invalid input. + if (Object.keys(properties).length === 0) { + const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined + if (branches?.length) { + const shared = branches.reduce( + (keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined), + Object.keys(branches[0].properties ?? {}) + ) + properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]])) + required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key)))) + } + } + const keys = Object.keys(properties) - // A union body (e.g. single-row vs batch insert) has no flat field list; the - // runtime falls back to taking the whole body as JSON. + // A union body (e.g. single-row vs batch insert) has no flat field list. The + // caller marks it `opaqueBody` so the runtime can offer the whole body as one + // JSON flag instead. if (keys.length === 0) return null const lines = keys.map((key) => { @@ -441,6 +471,13 @@ function render(operations: Operation[]): string { for (const slot of ['query', 'body'] as const) { const map = renderSlotMap(op.contract[slot], ' ') if (map) out.push(` ${slot}: ${map},`) + // A declared slot with no flat field list still has to be sendable. + // Absence alone cannot say so: it means both "no body" and "a body the + // generator could not describe", and reading it as the former left + // `tables rows create` unable to send anything at all. + if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) { + out.push(` opaqueBody: true,`) + } } out.push(' },') }