From 52fdc4be16a81798f926dbc2391db71a7b195896 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 30 Jul 2026 10:59:29 -0700 Subject: [PATCH 01/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] =?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/46] =?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/46] =?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/46] =?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/46] =?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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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/46] 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(' },') } From 7612e8daab5c92b8b26f033cbecfa3161bdd5914 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 11:11:28 -0700 Subject: [PATCH 29/46] fix(cli): improve command usability and structure --- packages/sim-cli/README.md | 16 +- packages/sim-cli/src/commands/auth.test.ts | 8 + packages/sim-cli/src/commands/auth.ts | 1 + .../sim-cli/src/commands/hand-written.test.ts | 118 ---- packages/sim-cli/src/commands/hand-written.ts | 633 ------------------ .../commands/protocol/files-download.test.ts | 107 +++ .../src/commands/protocol/files-download.ts | 96 +++ .../src/commands/protocol/files-upload.ts | 59 ++ .../sim-cli/src/commands/protocol/index.ts | 20 + .../sim-cli/src/commands/protocol/result.ts | 7 + .../commands/protocol/tables-import.test.ts | 110 +++ .../src/commands/protocol/tables-import.ts | 201 ++++++ packages/sim-cli/src/contract/commands.ts | 104 ++- packages/sim-cli/src/contract/types.ts | 8 +- packages/sim-cli/src/http/client.test.ts | 83 ++- packages/sim-cli/src/http/client.ts | 41 +- packages/sim-cli/src/index.ts | 28 +- packages/sim-cli/src/output/render.test.ts | 7 + packages/sim-cli/src/output/render.ts | 9 +- packages/sim-cli/src/runtime/build.test.ts | 207 +++++- packages/sim-cli/src/runtime/build.ts | 467 ++----------- packages/sim-cli/src/runtime/execute.ts | 80 +++ packages/sim-cli/src/runtime/options.ts | 98 +++ packages/sim-cli/src/runtime/request.test.ts | 11 + packages/sim-cli/src/runtime/request.ts | 5 +- packages/sim-cli/src/runtime/result.ts | 159 +++++ packages/sim-cli/src/runtime/types.ts | 13 + packages/sim-cli/src/transfer/local-file.ts | 49 ++ packages/sim-cli/src/transfer/multipart.ts | 96 +++ 29 files changed, 1619 insertions(+), 1222 deletions(-) create mode 100644 packages/sim-cli/src/commands/auth.test.ts delete mode 100644 packages/sim-cli/src/commands/hand-written.test.ts delete mode 100644 packages/sim-cli/src/commands/hand-written.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-download.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-download.ts create mode 100644 packages/sim-cli/src/commands/protocol/files-upload.ts create mode 100644 packages/sim-cli/src/commands/protocol/index.ts create mode 100644 packages/sim-cli/src/commands/protocol/result.ts create mode 100644 packages/sim-cli/src/commands/protocol/tables-import.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/tables-import.ts create mode 100644 packages/sim-cli/src/runtime/execute.ts create mode 100644 packages/sim-cli/src/runtime/options.ts create mode 100644 packages/sim-cli/src/runtime/result.ts create mode 100644 packages/sim-cli/src/runtime/types.ts create mode 100644 packages/sim-cli/src/transfer/local-file.ts create mode 100644 packages/sim-cli/src/transfer/multipart.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index bdfe45410da..bf7c3f400c0 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -107,6 +107,11 @@ Settings → API keys. ## Commands +Plural resource names are canonical, but every plural top-level resource group +also accepts its singular form: for example, `sim table list`, +`sim file download`, and `sim workflow get` are equivalent to their plural +spellings. + ```bash sim workflows list [--folder ] [--deployed] [--limit ] sim workflows get @@ -119,9 +124,10 @@ 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 tables rows list [--limit ] +sim tables rows query [--filter ] [--sort ] [--limit ] +sim tables upsert --data +sim tables rows batch-delete (--row … | --filter ) --yes sim files list sim files download [-o ] @@ -130,7 +136,7 @@ sim files delete sim knowledge list sim knowledge get sim knowledge documents [--search ] -sim knowledge search --kb … +sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` ### Filtering table rows @@ -140,7 +146,7 @@ sim knowledge search --kb … grammar is a tree; there's no honest flag encoding for it. ```bash -sim tables rows tbl_123 \ +sim tables rows query tbl_123 \ --filter '{"all":[{"field":"status","op":"eq","value":"open"}, {"field":"score","op":"gt","value":10}]}' \ --sort score:desc --limit 50 diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts new file mode 100644 index 00000000000..59f41e578d4 --- /dev/null +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { profilesCommand } from './auth.js' + +describe('profiles command', () => { + it('accepts the singular profile alias', () => { + expect(profilesCommand().alias()).toBe('profile') + }) +}) diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index ded0de9440e..e54b58db17e 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -185,6 +185,7 @@ export function whoamiCommand(): Command { export function profilesCommand(): Command { return new Command('profiles') + .alias('profile') .description('List the profiles defined in the config and credentials files') .action((_options: unknown, command: Command) => { const profiles = listProfiles() diff --git a/packages/sim-cli/src/commands/hand-written.test.ts b/packages/sim-cli/src/commands/hand-written.test.ts deleted file mode 100644 index 10f3d08b5fd..00000000000 --- a/packages/sim-cli/src/commands/hand-written.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -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 - -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/) - } - ) -}) - -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 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('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('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', '--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 deleted file mode 100644 index 80142fba0a6..00000000000 --- a/packages/sim-cli/src/commands/hand-written.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { once } from 'node:events' -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' -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. - * - * 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. - */ -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 - // 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() - } - - // `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 { - 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 - ) - } - throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) - } -} - -/** - * 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) => ({ - // 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) - // User-defined cell data is remote content; strip terminal controls. - return sanitize(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 -} - -/** - * 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' -} - -interface UploadPartUrl { - partNumber: number - url: string - 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 - uploadToken: string - partSize: number - partCount: number - 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 transfer anyway, and a sequential loop - * makes "which part failed" unambiguous. - */ -async function uploadParts( - client: SimClient, - workspaceId: string, - transfer: Transfer, - blob: Blob -): Promise> { - const completed: Array<{ partNumber: number; etag?: string }> = [] - - for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { - const partNumbers = [] - for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { - partNumbers.push(n) - } - - const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `${transfer.basePath}/parts`, - { - method: 'POST', - query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, - body: { partNumbers }, - } - ) - - for (const part of signed.data.parts) { - 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 + transfer.partSize, transfer.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 -} - -/** - * 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 { - name?: string - tableId?: string - mode?: string - folderId?: string - fileId?: string - mapping?: string - createColumns?: string - timezone?: string - /** commander sets this false for `--no-wait`. */ - 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 - -/** 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') - .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 } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - - const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...(options.folderId ? { folderId: options.folderId } : {}), - }, - }) - const upload = created.data - - 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 ────────────── - group(program, 'tables') - .command('import [path]') - .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 (--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() - - // 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) - } - - 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( - 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 - ) - } - - 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 } - - 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', - 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 ') - .description('Download a file') - .option('-o, --output-file ', 'Where to write it (defaults to the file name)') - .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}`)) - } - ) - - // ── 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/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-download.test.ts new file mode 100644 index 00000000000..11b4f743bce --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-download.test.ts @@ -0,0 +1,107 @@ +import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { streamToFile } from './files-download.js' +import { attachProtocolCommands } from './index.js' + +const { output } = vi.hoisted(() => ({ + output: { format: 'json' }, +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ + client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) + output.format = 'json' +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + 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() + }, + }) +} + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +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') + 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 () => { + await expect( + streamToFile(bodyOf(['x'.repeat(64 * 1024)]), createWriteStream('/dev/full')) + ).rejects.toThrow(/Could not write/) + } + ) +}) + +describe('files download', () => { + it('prints a normalized machine-readable result', async () => { + const target = join(dir, 'download.txt') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'file', + 'download', + 'file_1', + '--output-file', + target, + ]) + + expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', path: target, status: 'saved' }) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-download.ts new file mode 100644 index 00000000000..a2683bf735a --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-download.ts @@ -0,0 +1,96 @@ +import { once } from 'node:events' +import { createWriteStream, type WriteStream } from 'node:fs' +import { basename } from 'node:path' +import type { Command } from 'commander' +import { clientFrom } from '../../context.js' +import { SimApiError } from '../../http/client.js' +import { printProtocolResult } from './result.js' + +/** Streams a fetch body to disk while honoring write-stream backpressure. */ +export async function streamToFile( + body: ReadableStream, + file: WriteStream +): Promise { + 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 + if (!file.write(value)) await once(file, 'drain') + } + } finally { + reader.releaseLock() + } + + await new Promise((resolve, reject) => { + file.end((error?: Error | null) => (error ? reject(error) : resolve())) + }) + })() + + try { + 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 + ) + } + throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0) + } +} + +export function attachFileDownload(files: Command): void { + files + .command('download ') + .description('Download a file') + .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .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) + + // boundary-raw-fetch: binary download cannot pass through the JSON client + 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, { flags: options.force ? 'w' : 'wx' }) + ) + printProtocolResult(profile.output, { id: fileId, path: target, status: 'saved' }) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts new file mode 100644 index 00000000000..a0221efffb7 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -0,0 +1,59 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context.js' +import { contentTypeFor, localFile } from '../../transfer/local-file.js' +import { finishTransfer } from '../../transfer/multipart.js' +import { printProtocolResult } from './result.js' + +interface FileUpload { + id: string + uploadToken: string + partSize: number + partCount: number + file: { id: string } | null +} + +export function attachFileUpload(files: Command): void { + 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() + const { name, size } = await localFile(path, options.name) + + const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folderId ? { folderId: options.folderId } : {}), + }, + }) + const upload = created.data + 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 + ) + + printProtocolResult(profile.output, { + id: completed.file?.id ?? completed.id, + name, + size, + status: 'uploaded', + }) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts new file mode 100644 index 00000000000..d159f35633b --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -0,0 +1,20 @@ +import { Command } from 'commander' +import { attachFileDownload } from './files-download.js' +import { attachFileUpload } from './files-upload.js' +import { attachTableImport } from './tables-import.js' + +function group(program: Command, name: string): Command { + const existing = program.commands.find((command) => command.name() === name) + if (existing) return existing + const created = new Command(name) + program.addCommand(created) + return created +} + +/** Attaches commands whose multi-request or binary protocols cannot be generated. */ +export function attachProtocolCommands(program: Command): void { + const files = group(program, 'files') + attachFileUpload(files) + attachFileDownload(files) + attachTableImport(group(program, 'tables')) +} diff --git a/packages/sim-cli/src/commands/protocol/result.ts b/packages/sim-cli/src/commands/protocol/result.ts new file mode 100644 index 00000000000..304f852b394 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/result.ts @@ -0,0 +1,7 @@ +import type { OutputFormat } from '../../config/index.js' +import { printRecord, text } from '../../output/render.js' + +export function printProtocolResult(format: OutputFormat, result: Record): void { + const fields = Object.entries(result).map<[string, string]>(([key, value]) => [key, text(value)]) + printRecord(format, fields, result) +} diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts new file mode 100644 index 00000000000..ae238855b34 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -0,0 +1,110 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +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: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +async function runImport(argv: string[]) { + await program().parseAsync(['node', 'sim', 'table', 'import', ...argv]) +} + +describe('tables import argument guards', () => { + it('refuses to guess the source', async () => { + await expect(runImport([])).rejects.toThrow(/exactly one of /) + await expect(runImport(['f.csv', '--file-id', 'w_1'])).rejects.toThrow(/exactly one of /) + }) + + it('rejects existing-table flags when creating one', async () => { + await expect(runImport(['f.csv', '--mode', 'replace'])).rejects.toThrow(/applies to --table-id/) + await expect(runImport(['f.csv', '--mapping', '{}'])).rejects.toThrow(/applies to --table-id/) + await expect(runImport(['f.csv', '--create-columns', '{}'])).rejects.toThrow( + /applies to --table-id/ + ) + }) + + it('rejects new-table flags when importing into an existing one', async () => { + await expect(runImport(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( + /--table-id already names the destination/ + ) + await expect(runImport(['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(runImport(['--file-id', 'w_1'])).rejects.toThrow(/--name /) + }) + + it('checks target options before touching the filesystem', async () => { + await expect(runImport(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) + }) + + it('rejects an invalid import mode before making a request', async () => { + await expect( + runImport(['--file-id', 'w_1', '--name', 'Customers', '--mode', 'merge']) + ).rejects.toThrow(/allowed choices are append, replace/i) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) + +describe('tables import output', () => { + it('prints a normalized result without transfer secrets', async () => { + mockRequest.mockResolvedValue({ + data: { + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + error: null, + upload: null, + }, + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await runImport(['--file-id', 'file_1', '--name', 'Customers', '--no-wait']) + + expect(JSON.parse(logged[0])).toEqual({ + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + }) + expect(logged[0]).not.toContain('uploadToken') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts new file mode 100644 index 00000000000..9c2ca5b0cc7 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -0,0 +1,201 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import chalk from 'chalk' +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context.js' +import { SimApiError, type SimClient } from '../../http/client.js' +import { coerce } from '../../runtime/request.js' +import { contentTypeFor, localFile } from '../../transfer/local-file.js' +import { finishTransfer } from '../../transfer/multipart.js' +import { printProtocolResult } from './result.js' + +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 { + name?: string + tableId?: string + mode?: string + folderId?: string + fileId?: string + mapping?: string + createColumns?: string + timezone?: string + wait: boolean +} + +const IMPORT_POLL_MS = 1500 +const IMPORT_SETTLED = new Set(['completed', 'failed', 'canceled', 'expired']) + +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) +} + +function jsonFlag(raw: string, flagName: string): unknown { + return coerce(raw, { kind: 'object' }, { json: true }, flagName) +} + +async function watchImport( + client: SimClient, + workspaceId: string, + job: TableImport +): Promise { + let current = job + let reported = -1 + + while (!IMPORT_SETTLED.has(current.status)) { + await sleep(IMPORT_POLL_MS) + const next = await client.request<{ data: TableImport }>( + `/api/v2/tables/imports/${encodeURIComponent(current.id)}`, + { query: { workspaceId } } + ) + current = next.data + 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 +} + +function validateTargetOptions(options: ImportOptions): boolean { + const intoExisting = Boolean(options.tableId) + 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( + 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 + ) + } + return intoExisting +} + +export function attachTableImport(tables: Command): void { + tables + .command('import [path]') + .description('Import a CSV, into a new table by default') + .option( + '--name ', + 'Identifier for the new table: letters, numbers, and underscores; defaults to the sanitized file name' + ) + .option('--table-id ', 'Import into this existing table instead of creating one') + .addOption( + new Option( + '--mode ', + 'How to write into --table-id (default: append)' + ).choices(['append', 'replace']) + ) + .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 (--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, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + + if (Boolean(path) === Boolean(options.fileId)) { + throw new SimApiError('Pass exactly one of or --file-id ', 0) + } + + const intoExisting = validateTargetOptions(options) + const local = path ? await localFile(path) : null + const source = local + ? { + type: 'upload', + name: local.name, + contentType: contentTypeFor(local.name), + size: local.size, + } + : { type: 'workspace_file', fileId: options.fileId } + + let target: Record + if (intoExisting) { + target = { type: 'existing', tableId: options.tableId, mode: options.mode ?? 'append' } + } else { + 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', + 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 + 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) { + printProtocolResult(profile.output, { + id: job.id, + status: job.status, + tableId: job.tableId, + rowsProcessed: job.rowsProcessed, + }) + return + } + + const finished = await watchImport(client, workspaceId, job) + if (finished.status !== 'completed') { + throw new SimApiError( + `Import ${finished.status}${finished.error ? `: ${finished.error}` : ''}`, + 0 + ) + } + printProtocolResult(profile.output, { + id: finished.id, + status: finished.status, + tableId: finished.tableId, + rowsProcessed: finished.rowsProcessed, + }) + }) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c74c16c4132..26a2ba3e57a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,5 +1,11 @@ import type { CliContract } from './types.js' +const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' +const TABLE_FILTER_HELP = + 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' +const CUSTOM_TOOL_SCHEMA_HELP = + 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' + /** * The CLI contract for the v2 surface. * @@ -20,13 +26,19 @@ export const CLI_CONTRACT: CliContract = { 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 } }, + flags: { + rowIds: { name: 'row', list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, 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 } }, + flags: { + filter: { json: true, describe: TABLE_FILTER_HELP }, + data: { json: true }, + }, confirm: 'This updates every matching row and cannot be undone.', }, // `DELETE /workflows/[id]/deploy` is an undeploy, not a delete. @@ -38,7 +50,10 @@ export const CLI_CONTRACT: CliContract = { // ─── 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.' }, + deleteTableColumn: { + confirm: 'This deletes the column and its values in every row.', + fields: [{ header: 'remaining columns', path: 'columns', format: 'count' }], + }, 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.' }, @@ -56,6 +71,11 @@ export const CLI_CONTRACT: CliContract = { // 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.', + fields: [ + { header: 'id' }, + { header: 'deleted', format: 'bool' }, + { header: 'remaining columns', path: 'columns', format: 'count' }, + ], }, deleteFolder: { // The route archives the folder *and cascades to its contents*, so this is @@ -82,9 +102,36 @@ export const CLI_CONTRACT: CliContract = { { header: 'execution', path: 'executionId' }, ], }, + getLog: { + describe: 'Show a log summary (execution data is available in JSON or YAML output)', + fields: [ + { header: 'id' }, + { header: 'execution', path: 'executionId' }, + { header: 'workflow', path: 'workflow.name' }, + { header: 'level' }, + { header: 'trigger' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'files', format: 'count' }, + ], + }, 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 } }, + flags: { + knowledgeBaseIds: { name: 'kb', list: true, describe: 'Knowledge base ID (repeatable)' }, + query: { describe: 'Text to search for' }, + tagFilters: { + json: true, + describe: 'Tag filters as [{"tagName":"...","operator":"...","value":"..."}]', + }, + searchMode: { + choices: ['vector', 'hybrid'], + describe: 'Search algorithm', + }, + }, + itemsPath: 'results', columns: [ { header: 'score', path: 'similarity' }, { header: 'document', path: 'documentName' }, @@ -104,11 +151,26 @@ export const CLI_CONTRACT: CliContract = { }, queryRows: { command: 'tables rows query', - flags: { predicate: { name: 'filter', json: true }, sort: { json: true } }, + flags: { + predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + 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', }, + createTable: { + flags: { + name: { describe: TABLE_NAME_HELP }, + schema: { + json: true, + describe: 'Table schema: {"columns":[{"name":"email","type":"string"}]}', + }, + }, + }, + updateTable: { flags: { name: { describe: TABLE_NAME_HELP } } }, + createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, + updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // ─── Output columns for list commands ───────────────────────────────────── listTables: { @@ -140,6 +202,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, ], }, + listTableRows: { expand: 'data' }, listKnowledgeBases: { columns: [ { header: 'id' }, @@ -176,14 +239,14 @@ export const CLI_CONTRACT: CliContract = { { header: 'id' }, { header: 'name' }, { header: 'description' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + { header: 'built-in', path: 'readOnly', format: 'bool' }, ], }, listCustomTools: { columns: [ { header: 'id' }, - { header: 'name' }, - { header: 'description' }, + { header: 'name', path: 'title' }, + { header: 'description', path: 'schema.function.description' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, @@ -198,8 +261,8 @@ export const CLI_CONTRACT: CliContract = { listCredentials: { columns: [ { header: 'id' }, - { header: 'name' }, - { header: 'provider' }, + { header: 'name', path: 'displayName' }, + { header: 'provider', path: 'providerId' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, @@ -240,6 +303,9 @@ export const CLI_CONTRACT: CliContract = { updateFileContent: { command: 'files set-content', describe: 'Replace a file’s contents', + flags: { + encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' }, + }, }, getFileShare: { command: 'files share get', @@ -255,9 +321,23 @@ export const CLI_CONTRACT: CliContract = { // 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' }, + findTableRows: { + command: 'tables rows find', + describe: 'Find rows matching a predicate', + flags: { + q: { describe: 'Value to find' }, + predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, + sort: { json: true }, + }, + itemsPath: 'matches', + columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], + }, restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, - runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow' }, + runTableColumn: { + command: 'tables columns run', + describe: 'Run a column’s workflow', + flags: { filter: { json: true, describe: TABLE_FILTER_HELP } }, + }, runRowEnrichment: { command: 'tables rows enrich', describe: 'Run one row’s enrichment group', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 2460a351b4d..1f585d15b98 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -46,6 +46,8 @@ export interface FlagSpec { json?: boolean /** Overrides the help text otherwise taken from the OpenAPI description. */ describe?: string + /** Accepted values when the generated descriptor cannot recover an enum. */ + choices?: readonly string[] /** * Never expose this field as a flag, and never send it. * @@ -64,7 +66,7 @@ export interface ColumnSpec { /** Dot path into the row. Defaults to `header`. */ path?: string /** Rendering hint; `auto` inspects the value. */ - format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' } export interface CommandSpec { @@ -79,6 +81,10 @@ export interface CommandSpec { flags?: Record /** Columns for table output. Omit on non-list commands to print a record. */ columns?: ColumnSpec[] + /** Fields shown for a single record in human formats. Machine output stays raw. */ + fields?: ColumnSpec[] + /** Dot path to a nested result array rendered as the command's human list. */ + itemsPath?: string /** * Require `--yes`. The message should say what is about to be destroyed — * the point is that the caller can tell whether they meant it. diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 9e36ea232db..f031dfc39a1 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,7 +1,86 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } 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' +import { formatApiErrorDetails, resolvePath, SimApiError, SimClient } from './client.js' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('API errors', () => { + it('keeps structured details and does not misdiagnose an ordinary 404', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: 'NOT_FOUND', + message: 'Workflow not found', + details: { id: 'missing' }, + }, + }), + { status: 404 } + ) + ) + ) + const client = new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: 'key', + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) + + const request = client.request('/api/v2/workflows/missing') + await expect(request).rejects.toMatchObject({ + message: 'Workflow not found', + code: 'NOT_FOUND', + details: { id: 'missing' }, + }) + await expect(request).rejects.not.toThrow(/v2 API may not be enabled/) + }) + + it('turns nested validation details into concise path-aware lines', () => { + const lines = formatApiErrorDetails([ + { + code: 'invalid_union', + path: ['predicate'], + message: 'Invalid input', + errors: [ + [ + { + code: 'invalid_union', + path: ['all', 0], + message: 'Invalid input', + errors: [ + [ + { + code: 'invalid_value', + path: ['op'], + message: 'Expected one of eq, ne', + }, + ], + ], + }, + ], + ], + }, + ]) + + expect(lines).toEqual([' details:', ' predicate.all.0.op: Expected one of eq, ne']) + }) + + it('keeps non-validation details as JSON', () => { + expect(formatApiErrorDetails({ id: 'missing' })).toEqual([' details: {"id":"missing"}']) + }) +}) describe('resolvePath', () => { it('substitutes a path parameter', () => { diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 96b640a43ac..af14b433da7 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -91,6 +91,40 @@ function truncate(value: string, max: number): string { return value.length <= max ? value : `${value.slice(0, max)}…` } +/** Formats nested validation issues as readable, path-aware lines. */ +export function formatApiErrorDetails(details: unknown): string[] { + const issues = new Set() + + const visit = (value: unknown, parentPath: string[] = []): void => { + if (Array.isArray(value)) { + value.forEach((item) => visit(item, parentPath)) + return + } + if (!value || typeof value !== 'object') return + + const issue = value as Record + const ownPath = Array.isArray(issue.path) ? issue.path.map(String) : [] + const path = [...parentPath, ...ownPath] + const nested = Array.isArray(issue.errors) ? issue.errors : [] + + if (nested.length > 0) { + visit(nested, path) + return + } + if (typeof issue.message !== 'string' || issue.message === 'Invalid input') return + + issues.add(`${path.length > 0 ? path.join('.') : 'request'}: ${issue.message}`) + } + + visit(details) + if (issues.size === 0) return [` details: ${truncate(JSON.stringify(details), 1000)}`] + + const visible = [...issues].slice(0, 8) + const lines = [' details:', ...visible.map((issue) => ` ${issue}`)] + if (issues.size > visible.length) lines.push(` … ${issues.size - visible.length} more issues`) + return lines +} + export class SimClient { constructor(private readonly profile: ResolvedProfile) {} @@ -155,13 +189,6 @@ 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/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 6d2185e70d8..85e67d3635c 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -4,8 +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 { attachHandWritten } from './commands/hand-written.js' -import { SimApiError } from './http/client.js' +import { attachProtocolCommands } from './commands/protocol/index.js' +import { formatApiErrorDetails, SimApiError } from './http/client.js' +import { sanitize } from './output/render.js' import { buildGeneratedCommands } from './runtime/build.js' const program = new Command() @@ -24,23 +25,11 @@ program.addCommand(whoamiCommand()) program.addCommand(profilesCommand()) program.addCommand(configureCommand()) -/** - * 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)) { +for (const command of buildGeneratedCommands()) { 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) +attachProtocolCommands(program) program.addHelpText( 'after', @@ -54,7 +43,7 @@ Examples: $ 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 knowledge search --query "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 @@ -73,6 +62,11 @@ async function main() { if (error instanceof SimApiError) { console.error(chalk.red(`Error: ${error.message}`)) if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + if (error.details !== undefined) { + for (const line of formatApiErrorDetails(error.details)) { + console.error(chalk.dim(sanitize(line))) + } + } process.exit(1) } throw error diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index 57e78bc8395..ce554b63b5c 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -94,6 +94,13 @@ describe('printList', () => { expect(JSON.parse(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) }) + it('can preserve a containing response for machine output', () => { + const rows = [{ name: 'alpha', status: 'error' }] + const response = { results: rows, totalResults: 1 } + printList('json', rows, COLUMNS, response) + expect(JSON.parse(logged[0])).toEqual(response) + }) + it('prints the raw rows for yaml too', () => { printList('yaml', [{ name: 'alpha', status: 'error' }], COLUMNS) expect(load(logged[0])).toEqual([{ name: 'alpha', status: 'error' }]) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 011c9a8155b..5235356e2e5 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -219,8 +219,13 @@ function renderMachine(format: OutputFormat, raw: unknown): string | null { * 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 { - const machine = renderMachine(format, rows) +export function printList( + format: OutputFormat, + rows: T[], + columns: Column[], + raw: unknown = rows +): void { + const machine = renderMachine(format, raw) if (machine !== null) { console.log(machine) return diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 15cc616340e..df782306a19 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -27,7 +27,7 @@ vi.mock('../context.js', () => ({ function program(): Command { const root = new Command('sim').exitOverride() - for (const group of buildGeneratedCommands(new Set())) root.addCommand(group) + for (const group of buildGeneratedCommands()) 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. @@ -39,9 +39,19 @@ function program(): Command { return root } -async function run(argv: string[]) { +function commandAt(...names: string[]): Command { + let current = program() + for (const name of names) { + const next = current.commands.find((command) => command.name() === name) + if (!next) throw new Error(`Missing command ${names.join(' ')}`) + current = next + } + return current +} + +async function run(argv: string[], response: unknown = { data: [], nextCursor: null }) { mockRequest.mockReset() - mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + mockRequest.mockResolvedValue(response) vi.spyOn(console, 'log').mockImplementation(() => {}) await program().parseAsync(['node', 'sim', ...argv]) return mockRequest.mock.calls[0] @@ -59,6 +69,37 @@ describe('commands parsed through commander', () => { expect(options.query).toMatchObject({ minDurationMs: 250 }) }) + it('registers singular aliases for every plural resource group', () => { + const aliases = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + folders: 'folder', + logs: 'log', + 'mcp-servers': 'mcp-server', + skills: 'skill', + tables: 'table', + workflows: 'workflow', + } + + for (const [name, alias] of Object.entries(aliases)) { + expect( + program() + .commands.find((command) => command.name() === name) + ?.alias() + ).toBe(alias) + } + }) + + it('dispatches generated commands through their singular resource alias', async () => { + const [tablePath] = await run(['table', 'list']) + expect(tablePath).toBe('/api/v2/tables') + + const [filePath] = await run(['file', 'list']) + expect(filePath).toBe('/api/v2/files') + }) + it('carries every multi-word flag on a command, not just the first', async () => { const [, options] = await run([ 'logs', @@ -118,6 +159,40 @@ describe('commands parsed through commander', () => { ) expect(mockRequest).not.toHaveBeenCalled() }) + + it('marks required flags in help and rejects omissions before a request', async () => { + const help = commandAt('tables', 'create').helpInformation() + expect(help).toMatch(/--name.*required/s) + expect(help).toMatch(/--schema.*required/s) + + await expect(run(['tables', 'create', '--name', 'Customers'])).rejects.toThrow( + /required option '--schema/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('shows repeated values and recovered enum choices accurately', async () => { + const help = commandAt('knowledge', 'search').helpInformation() + expect(help).toContain('--kb ') + expect(help).not.toMatch(/--kb[^\n]*JSON/) + expect(help).toMatch(/--search-mode.*vector.*hybrid/s) + + await expect( + run(['knowledge', 'search', '--kb', 'kb_1', '--search-mode', 'semantic']) + ).rejects.toThrow(/allowed choices are vector, hybrid/i) + + const [, options] = await run( + ['knowledge', 'search', '--kb', 'kb_1', '--search-mode', 'hybrid'], + { data: { results: [] } } + ) + expect(options.body).toMatchObject({ knowledgeBaseIds: ['kb_1'], searchMode: 'hybrid' }) + }) + + it('advertises the file-content encoding choices', () => { + expect(commandAt('files', 'set-content').helpInformation()).toMatch( + /--encoding.*utf-8.*base64/s + ) + }) }) describe('single-resource rendering', () => { @@ -214,6 +289,101 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) + + it('keeps sensitive execution data out of human log output', async () => { + const log = { + id: 'log_1', + executionId: 'exec_1', + workflow: { name: 'Billing' }, + level: 'info', + trigger: 'api', + startedAt: '2026-08-04T00:00:00.000Z', + endedAt: null, + totalDurationMs: 50, + cost: { total: 0.001 }, + files: [], + executionData: { env: { SECRET_TOKEN: 'encrypted-value' } }, + } + + const human = await lines(['logs', 'get', 'log_1'], log, 'text') + expect(human.join('\n')).not.toContain('executionData') + expect(human.join('\n')).not.toContain('SECRET_TOKEN') + + const machine = await lines(['logs', 'get', 'log_1'], log, 'json') + expect(JSON.parse(machine[0])).toMatchObject({ executionData: log.executionData }) + }) +}) + +describe('contract-selected list rendering', () => { + async function lines(argv: string[], data: unknown): Promise { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data }) + output.format = 'text' + const captured: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => captured.push(line)) + try { + await program().parseAsync(['node', 'sim', ...argv]) + } finally { + output.format = 'json' + } + return captured + } + + it('renders knowledge results as rows instead of a truncated JSON blob', async () => { + const printed = await lines(['knowledge', 'search', '--kb', 'kb_1', '--query', 'refund'], { + results: [ + { + similarity: 0.91, + documentName: 'policy.md', + chunkIndex: 2, + content: 'Refunds are available for 30 days.', + }, + ], + query: 'refund', + totalResults: 1, + }) + + expect(printed).toEqual(['0.91\tpolicy.md\t2\tRefunds are available for 30 days.']) + }) + + it('renders row matches as rows', async () => { + const printed = await lines(['tables', 'rows', 'find', 'tbl_1', '--q', 'alice'], { + matches: [{ ordinal: 3, rowId: 'row_1', column: 'email' }], + truncated: false, + }) + + expect(printed).toEqual(['3\trow_1\temail']) + }) + + it('maps custom-tool and credential fields to their actual response paths', async () => { + const tools = await lines( + ['custom-tools', 'list'], + [ + { + id: 'tool_1', + title: 'Lookup', + schema: { function: { description: 'Find a customer' } }, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(tools[0]).toContain('Lookup') + expect(tools[0]).toContain('Find a customer') + + const credentials = await lines( + ['credentials', 'list'], + [ + { + id: 'cred_1', + displayName: 'Production Stripe', + providerId: 'stripe', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(credentials[0]).toContain('Production Stripe') + expect(credentials[0]).toContain('stripe') + }) }) describe('pagination slot', () => { @@ -250,6 +420,18 @@ describe('pagination slot', () => { expect(mockRequest.mock.calls[1][1].query).toMatchObject({ cursor: 'c1' }) }) + + it('uses a valid per-page size for unlimited and large totals', async () => { + for (const requested of ['0', '250']) { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [], nextCursor: null }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', requested]) + + expect(mockRequest.mock.calls[0][1].query.limit).toBe(100) + } + }) }) describe('rows whose content sits in a wrapper', () => { @@ -277,6 +459,25 @@ describe('rows whose content sits in a wrapper', () => { expect(lines[0]).toContain('A') expect(lines[1]).toContain('E') }) + + it('uses the generated list command for table rows', async () => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: [{ id: 'r1', data: { email: 'a@example.com' } }], + nextCursor: null, + }) + const lines: string[] = [] + output.format = 'text' + vi.spyOn(console, 'log').mockImplementation((line: string) => lines.push(line)) + try { + await program().parseAsync(['node', 'sim', 'tables', 'rows', 'list', 'tbl_1']) + } finally { + output.format = 'json' + } + + expect(lines[0]).toContain('a@example.com') + expect(mockRequest.mock.calls[0][0]).toBe('/api/v2/tables/tbl_1/rows') + }) }) describe('boolean flags', () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index a1da31835c5..bbcf7ae6534 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -1,453 +1,80 @@ -import { Command, Option } from 'commander' -import { clientFrom } from '../context.js' +import { Command } from 'commander' import { CLI_CONTRACT } from '../contract/commands.js' -import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import type { 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, - printDocument, - printList, - printRecord, - sanitize, - 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) - // Server-supplied: strip terminal control sequences before it can reach a tty. - return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) - } -} - -/** - * 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, - 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 — - * unless the contract names one with `expand`, which is how a row's cells reach - * the table. - */ -function inferColumns(rows: unknown[], expand?: string): Column[] { - const paths: Array<{ path: string; header: 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) - paths.push({ path: key, header: 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(header), - value: (row: unknown) => renderCell(at(row, path), 'auto'), - })) -} - -/** - * 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 -} - -/** 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 -} - -/** - * 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. */ -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}, ` : '' - - // 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)', - String(DEFAULT_LIMIT) - ) - return - } - - 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 - } - - const takesList = flag.list === true - const wantsJson = takesJson(descriptor, flag) - const placeholder = takesList ? `` : wantsJson ? `` : `` - const describe = - (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]) - if (descriptor.default !== undefined && field !== 'limit') { - option.default(undefined, String(descriptor.default)) - } - command.addOption(option) +import { executeOperation } from './execute.js' +import { addOperationOptions } from './options.js' +import type { OperationSpec } from './types.js' + +const GROUP_ALIASES: Readonly> = { + 'audit-logs': 'audit-log', + credentials: 'credential', + 'custom-tools': 'custom-tool', + files: 'file', + folders: 'folder', + logs: 'log', + 'mcp-servers': 'mcp-server', + skills: 'skill', + tables: 'table', + workflows: 'workflow', } -/** - * 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 - } + const operationSpec = V2_OPERATIONS[operation] as OperationSpec + const command = new Command(leafName).allowExcessArguments(false) - // `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) - // 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}>`) } command.description( - spec.describe ?? - summaryFor(operation) ?? - `${operationSpec.method} ${V2_OPERATIONS[operation].path}` + spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` ) + addOperationOptions(command, operation, spec, operationSpec) + command.action((...invocation: unknown[]) => + executeOperation(operation, spec, operationSpec, invocation) + ) + return command +} - for (const slot of ['query', 'body'] as const) { - for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { - addFieldOption(command, operation, field, descriptor) - } - } - - // 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') - } - - 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) - // `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) { - 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 { - // 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: paging === 'query' ? { ...request.query, cursor } : request.query, - body: - paging === 'body' - ? { ...(request.body ?? {}), ...(cursor ? { cursor } : {}) } - : 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, spec.expand) - ) - return - } - - const result = await client.request<{ data?: unknown }>(request.path, { - method: operationSpec.method as 'GET' | 'POST', - query: request.query, - body: request.body, - }) - 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. - // `printRecord` would silently print nothing for an array. - printList( - profile.output, - data, - spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) - ) - return - } +function groupFor(groups: Map, name: string): Command { + const existing = groups.get(name) + if (existing) return existing - // 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' - ? Object.entries(data).map(([key, value]) => [key, recordCell(value)]) - : [] + const group = new Command(name) + const alias = GROUP_ALIASES[name] + if (alias) group.alias(alias) + groups.set(name, group) + return group +} - printRecord(profile.output, fields, data) - }) +function nestedGroup(parent: Command, name: string): Command { + const existing = parent.commands.find((candidate) => candidate.name() === name) + if (existing) return existing - return command + const created = new Command(name) + parent.addCommand(created) + return created } -/** - * 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[] { +/** Builds every JSON command described by the generated operation table. */ +export function buildGeneratedCommands(): 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 operationSpec = V2_OPERATIONS[operation] as OperationSpec + if (spec.hidden || operationSpec.responseMode !== 'json') continue const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) const [groupName, ...rest] = segments const leafName = rest.join(' ') || 'run' + const group = groupFor(groups, groupName) - 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(' '))) + nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) continue } diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts new file mode 100644 index 00000000000..0454c896319 --- /dev/null +++ b/packages/sim-cli/src/runtime/execute.ts @@ -0,0 +1,80 @@ +import type { Command } from 'commander' +import { clientFrom } from '../context.js' +import type { CommandSpec } from '../contract/types.js' +import type { V2OperationName } from '../generated/v2-api.js' +import { SimApiError, type V2Page } from '../http/client.js' +import { DEFAULT_LIMIT } from './options.js' +import { buildRequest, PROFILE_INJECTED_FIELD } from './request.js' +import { renderPage, renderResult } from './result.js' +import type { OperationSpec } from './types.js' + +function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { + if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' + if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' + return null +} + +/** Executes a parsed generated command, including cursor pagination. */ +export async function executeOperation( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec, + invocation: unknown[] +): Promise { + 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 (commandSpec.confirm && !flags.yes) { + throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) + } + + const { client, profile } = clientFrom(host) + 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(operationSpec) + + 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) + } + + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const pageSize = Math.min(Number.isFinite(limit) ? limit : DEFAULT_LIMIT, DEFAULT_LIMIT) + const pageLimit = 'limit' in (operationSpec[paging] ?? {}) ? { limit: pageSize } : {} + const rows: unknown[] = [] + let cursor: string | null = null + + do { + const page: V2Page = await client.request(request.path, { + method: operationSpec.method, + query: paging === 'query' ? { ...request.query, ...pageLimit, cursor } : request.query, + body: + paging === 'body' + ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } + : request.body, + }) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < limit) + + renderPage(profile.output, Number.isFinite(limit) ? rows.slice(0, limit) : rows, commandSpec) + return + } + + const result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method, + query: request.query, + body: request.body, + }) + renderResult(operation, profile.output, result?.data ?? result, commandSpec) +} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts new file mode 100644 index 00000000000..c3a393ec6af --- /dev/null +++ b/packages/sim-cli/src/runtime/options.ts @@ -0,0 +1,98 @@ +import { type Command, Option } from 'commander' +import type { CommandSpec } from '../contract/types.js' +import type { V2OperationName } from '../generated/v2-api.js' +import { + type FieldSpec, + flagNameFor, + flagSpecFor, + PROFILE_INJECTED_FIELD, + takesJson, +} from './request.js' +import type { OperationSpec } from './types.js' + +export const DEFAULT_LIMIT = 100 + +function addFieldOption( + command: Command, + operation: V2OperationName, + field: string, + descriptor: FieldSpec +): void { + 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' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { + command.option( + '--limit ', + 'Maximum items to return (0 for everything)', + String(DEFAULT_LIMIT) + ) + return + } + + if (descriptor.kind === 'boolean') { + if (descriptor.required) { + command.addOption( + new Option( + `${short}--${name} `, + `${flag.describe ?? `Set ${field}`} (required)` + ) + .choices(['true', 'false']) + .makeOptionMandatory() + ) + return + } + + command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) + command.option(`--no-${name}`, `Set ${field} to false`) + return + } + + const takesList = flag.list === true + const wantsJson = takesJson(descriptor, flag) + const placeholder = takesList ? '' : wantsJson ? '' : '' + const choices = flag.choices ?? descriptor.values + const describe = `${ + flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`) + }${wantsJson && !takesList ? ' (JSON, or @path / @- to read a file or stdin)' : ''}${ + descriptor.required ? ' (required)' : '' + }` + + const option = new Option(`${short}--${name} ${placeholder}`, describe) + if (choices && !takesList) option.choices([...choices]) + if (descriptor.default !== undefined && field !== 'limit') { + option.default(undefined, String(descriptor.default)) + } + if (descriptor.required) option.makeOptionMandatory() + command.addOption(option) +} + +/** Adds request-field and safety options for one generated operation. */ +export function addOperationOptions( + command: Command, + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): void { + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + addFieldOption(command, operation, field, descriptor) + } + } + + if (operationSpec.opaqueBody) { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin) (required)' + ) + } + + if (commandSpec.confirm) { + command.option('-y, --yes', 'Skip the confirmation') + } +} diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 286c7bd8d8b..7470db8bb79 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -144,6 +144,17 @@ describe('repeated flags encode per the field kind, not uniformly', () => { }) }) +describe('contract-provided choices', () => { + it('validates an enum the generator could not recover', () => { + const field: FieldSpec = { kind: 'enum' } + const flag = { choices: ['vector', 'hybrid'] } as const + expect(coerce('hybrid', field, flag, 'search-mode')).toBe('hybrid') + expect(() => coerce('semantic', field, flag, 'search-mode')).toThrow( + '--search-mode must be one of: vector, hybrid' + ) + }) +}) + describe('JSON flags that name a file', () => { const field: FieldSpec = { kind: 'object' } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index fa7743bde31..292d413bf10 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -169,8 +169,9 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: 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) + const choices = flag.choices ?? field.values + if (choices && !choices.includes(String(raw))) { + throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } return raw diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts new file mode 100644 index 00000000000..e9b990e6f2d --- /dev/null +++ b/packages/sim-cli/src/runtime/result.ts @@ -0,0 +1,159 @@ +import type { OutputFormat } from '../config/index.js' +import type { ColumnSpec, CommandSpec } from '../contract/types.js' +import type { V2OperationName } from '../generated/v2-api.js' +import { + bool, + bytes, + type Column, + duration, + printDocument, + printList, + printRecord, + sanitize, + text, + timestamp, +} from '../output/render.js' + +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 bool(value as boolean | null) + case 'cost': + return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) + case 'count': + return Array.isArray(value) ? String(value.length) : text(null) + default: + if (value === null || value === undefined || value === '') return text(null) + return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) + } +} + +const NESTED_CELL_WIDTH = 160 + +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, + value: (row: unknown) => renderCell(at(row, spec.path ?? spec.header), spec.format), + })) +} + +function fieldsFrom(data: unknown, specs: ColumnSpec[]): Array<[string, string]> { + return specs.map((spec) => [ + spec.header, + renderCell(at(data, spec.path ?? spec.header), spec.format), + ]) +} + +function inferColumns(rows: unknown[], expand?: string): Column[] { + const paths: Array<{ path: string; header: 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) + paths.push({ path: key, header: key }) + } + } + + 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) + paths.push({ path: `${expand}.${key}`, header: seen.has(key) ? `${expand}.${key}` : key }) + } + } + } + + return paths.map(({ path, header }) => ({ + header: sanitize(header), + value: (row: unknown) => renderCell(at(row, path), 'auto'), + })) +} + +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 +} + +export function renderPage(format: OutputFormat, rows: unknown[], spec: CommandSpec): void { + printList( + format, + rows, + spec.columns ? columnsFrom(spec.columns) : inferColumns(rows, spec.expand) + ) +} + +/** Renders one non-paginated operation result according to its CLI contract. */ +export function renderResult( + operation: V2OperationName, + format: OutputFormat, + raw: unknown, + spec: CommandSpec +): void { + if (spec.document) { + printDocument(format, raw) + return + } + + const data = unwrapResource(raw) + if (spec.itemsPath) { + const items = at(data, spec.itemsPath) + if (!Array.isArray(items)) { + throw new Error(`${operation} expected an array at response path ${spec.itemsPath}`) + } + printList( + format, + items, + spec.columns ? columnsFrom(spec.columns) : inferColumns(items, spec.expand), + data + ) + return + } + + if (Array.isArray(data)) { + printList( + format, + data, + spec.columns ? columnsFrom(spec.columns) : inferColumns(data, spec.expand) + ) + return + } + + const fields = spec.fields + ? fieldsFrom(data, spec.fields) + : data && typeof data === 'object' + ? Object.entries(data).map<[string, string]>(([key, value]) => [key, recordCell(value)]) + : [] + + printRecord(format, fields, data) +} diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts new file mode 100644 index 00000000000..c9d98db84fc --- /dev/null +++ b/packages/sim-cli/src/runtime/types.ts @@ -0,0 +1,13 @@ +import type { RequestOptions } from '../http/client.js' +import type { FieldSpec } from './request.js' + +export interface OperationSpec { + method: NonNullable + path: string + pathParams: readonly string[] + query?: Record + body?: Record + opaqueBody?: boolean + summary?: string + responseMode?: 'json' | 'binary' +} diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts new file mode 100644 index 00000000000..2d29d31c9a3 --- /dev/null +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -0,0 +1,49 @@ +import { stat } from 'node:fs/promises' +import { basename } from 'node:path' +import { SimApiError } from '../http/client.js' + +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', +} + +export 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' +} + +export interface LocalFile { + name: string + size: number +} + +/** Validates the size and name shared by every local-file transfer. */ +export async function localFile(path: string, override?: string): Promise { + 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) + } + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(path), size } +} diff --git a/packages/sim-cli/src/transfer/multipart.ts b/packages/sim-cli/src/transfer/multipart.ts new file mode 100644 index 00000000000..20a7cfa1b3e --- /dev/null +++ b/packages/sim-cli/src/transfer/multipart.ts @@ -0,0 +1,96 @@ +import { openAsBlob } from 'node:fs' +import { SimApiError, type SimClient } from '../http/client.js' + +interface UploadPartUrl { + partNumber: number + url: string + headers: Record +} + +export interface Transfer { + basePath: string + uploadToken: string + partSize: number + partCount: number + size: number +} + +const PART_URL_BATCH = 100 + +async function uploadParts( + client: SimClient, + workspaceId: string, + transfer: Transfer, + blob: Blob +): Promise> { + const completed: Array<{ partNumber: number; etag?: string }> = [] + + for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { + const partNumbers = [] + for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { + partNumbers.push(n) + } + + const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( + `${transfer.basePath}/parts`, + { + method: 'POST', + query: { workspaceId }, + headers: { 'upload-token': transfer.uploadToken }, + body: { partNumbers }, + } + ) + + for (const part of signed.data.parts) { + const start = (part.partNumber - 1) * transfer.partSize + 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, { + method: 'PUT', + headers: part.headers, + body: chunk, + }) + if (!response.ok) { + throw new SimApiError( + `Part ${part.partNumber} failed with status ${response.status}`, + response.status + ) + } + + const etag = response.headers.get('etag')?.replace(/"/g, '') + completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) + } + } + + return completed +} + +/** Uploads and completes a multipart transfer, aborting it if either step fails. */ +export 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 + } +} From 4e132f09d9439e5b1482a5d2aa625300d3a7b21f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 12:31:50 -0700 Subject: [PATCH 30/46] feat(cli): support knowledge document uploads --- packages/sim-cli/README.md | 1 + .../sim-cli/src/commands/protocol/index.ts | 2 + .../knowledge-document-upload.test.ts | 205 ++++++++++++++++ .../protocol/knowledge-document-upload.ts | 96 ++++++++ packages/sim-cli/src/contract/commands.ts | 8 +- packages/sim-cli/src/generated/v2-api.ts | 231 ++++++++++++++++++ packages/sim-cli/src/http/client.test.ts | 1 + packages/sim-cli/src/transfer/local-file.ts | 8 + 8 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index bf7c3f400c0..77f0f045f3c 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -136,6 +136,7 @@ sim files delete sim knowledge list sim knowledge get sim knowledge documents [--search ] +sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index d159f35633b..5ad2647d06a 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' +import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' import { attachTableImport } from './tables-import.js' function group(program: Command, name: string): Command { @@ -16,5 +17,6 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) + attachKnowledgeDocumentUpload(group(group(program, 'knowledge'), 'documents')) attachTableImport(group(program, 'tables')) } diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts new file mode 100644 index 00000000000..4dd4e68c865 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -0,0 +1,205 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +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', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-kb-upload-')) + mockRequest.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +function uploadSession() { + return { + id: 'upload_1', + knowledgeBaseId: 'kb_1', + status: 'uploading', + name: 'notes.doc', + contentType: 'application/msword', + size: 5, + partSize: 10, + partCount: 1, + uploadToken: 'secret-token', + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + document: null, + } +} + +describe('knowledge documents upload', () => { + it('owns the multipart protocol while hiding its low-level operations', () => { + const knowledge = program().commands.find((command) => command.name() === 'knowledge') + expect(knowledge?.commands.map((command) => command.name())).not.toEqual( + expect.arrayContaining(['uploads', 'parts', 'complete']) + ) + + const documents = knowledge?.commands.find((command) => command.name() === 'documents') + expect(documents?.commands.map((command) => command.name())).toContain('upload') + }) + + it('uploads a local document and prints the created document without transfer secrets', async () => { + const path = join(dir, 'notes.doc') + writeFileSync(path, 'hello') + const session = uploadSession() + mockRequest + .mockResolvedValueOnce({ data: session }) + .mockResolvedValueOnce({ + data: { + parts: [ + { + partNumber: 1, + url: 'https://storage.example/part', + headers: { 'content-type': 'application/octet-stream' }, + expiresAt: '2026-08-04T20:00:00.000Z', + }, + ], + }, + }) + .mockResolvedValueOnce({ + data: { + ...session, + status: 'completed', + document: { + id: 'doc_1', + knowledgeBaseId: 'kb_1', + filename: 'notes.doc', + fileSize: 5, + mimeType: 'application/msword', + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: true, + createdAt: '2026-08-04T19:00:00.000Z', + }, + }, + }) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(null, { + status: 200, + headers: { etag: '"etag-1"' }, + }) + ) + ) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'knowledge', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + 'customer', + 'priority', + '--recipe', + 'default', + '--lang', + 'en', + ]) + + expect(mockRequest.mock.calls[0]).toEqual([ + '/api/v2/knowledge/kb_1/documents/uploads', + { + method: 'POST', + body: { + workspaceId: 'ws_local', + name: 'notes.doc', + contentType: 'application/msword', + size: 5, + tag1: 'customer', + tag2: 'priority', + processingOptions: { recipe: 'default', lang: 'en' }, + }, + }, + ]) + expect(mockRequest.mock.calls[1][0]).toBe( + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/parts' + ) + expect(mockRequest.mock.calls[2][0]).toBe( + '/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete' + ) + expect(mockRequest.mock.calls[2][1].body).toEqual({ + parts: [{ partNumber: 1, etag: 'etag-1' }], + }) + expect(JSON.parse(logged[0])).toEqual({ + id: 'doc_1', + knowledgeBaseId: 'kb_1', + name: 'notes.doc', + size: 5, + status: 'pending', + }) + expect(logged[0]).not.toContain('secret-token') + }) + + it('rejects more tags than the protocol supports before making a request', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'knowledge', + 'documents', + 'upload', + 'kb_1', + path, + '--tag', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + ]) + ).rejects.toThrow(/at most seven/) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts new file mode 100644 index 00000000000..b9fd2d88c4f --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -0,0 +1,96 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context.js' +import type { CreateKnowledgeDocumentUploadResponse } from '../../generated/v2-api.js' +import { SimApiError } from '../../http/client.js' +import { contentTypeFor, localFile } from '../../transfer/local-file.js' +import { finishTransfer } from '../../transfer/multipart.js' +import { printProtocolResult } from './result.js' + +type KnowledgeDocumentUpload = CreateKnowledgeDocumentUploadResponse['data'] + +interface KnowledgeDocumentUploadOptions { + name?: string + tag?: string[] + recipe?: string + lang?: string +} + +function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record { + if (options.tag && options.tag.length > 7) { + throw new SimApiError('--tag accepts at most seven values', 0) + } + + const metadata: Record = {} + options.tag?.forEach((value, index) => { + metadata[`tag${index + 1}`] = value + }) + + if (options.recipe || options.lang) { + metadata.processingOptions = { + ...(options.recipe ? { recipe: options.recipe } : {}), + ...(options.lang ? { lang: options.lang } : {}), + } + } + return metadata +} + +export function attachKnowledgeDocumentUpload(documents: Command): void { + documents + .command('upload ') + .description('Upload a document to a knowledge base') + .option('--name ', 'Store it under a different name') + .option('--tag ', 'Document tags, in tag1 through tag7 order') + .option('--recipe ', 'Document processing recipe') + .option('--lang ', 'Document language code') + .action( + async ( + knowledgeBaseId: string, + path: string, + options: KnowledgeDocumentUploadOptions, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...uploadMetadata(options), + }, + } + ) + const upload = created.data + const completed = await finishTransfer( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + knowledgeBaseId + )}/documents/uploads/${encodeURIComponent(upload.id)}`, + uploadToken: upload.uploadToken, + partSize: upload.partSize, + partCount: upload.partCount, + size, + }, + path + ) + + if (!completed.document) { + throw new Error(`Knowledge upload ${completed.id} completed without a document`) + } + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) + } + ) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 26a2ba3e57a..fca6ef16a66 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -395,9 +395,13 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim knowledge documents upload ` would need its own - // file-reading command rather than a generated flag surface. + // Multipart upload; `sim knowledge documents upload ` needs its + // own file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, + createKnowledgeDocumentUpload: { hidden: true }, + createKnowledgeDocumentUploadPartUrls: { hidden: true }, + completeKnowledgeDocumentUpload: { hidden: true }, + abortKnowledgeDocumentUpload: { hidden: true }, // ─── Steps of a transfer, not commands ──────────────────────────────────── // Uploading is now a presigned multipart handshake: create the upload, ask for diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index bec61da9beb..116f7b5f368 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -50,6 +50,49 @@ export type AbortFileUploadResponse = { } } +/** `DELETE /api/v2/knowledge/[id]/documents/uploads/[uploadId]` */ +export type AbortKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type AbortKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type AbortKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +export type AbortKnowledgeDocumentUploadResponse = { + data: { + id: string + knowledgeBaseId: 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 + 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 + } | null + } +} + /** `POST /api/v2/tables/[tableId]/columns` */ export type AddTableColumnParams = { tableId: string @@ -355,6 +398,56 @@ export type CompleteFileUploadResponse = { } } +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete` */ +export type CompleteKnowledgeDocumentUploadParams = { + id: string + uploadId: string +} + +export type CompleteKnowledgeDocumentUploadQuery = { + workspaceId: string +} + +export type CompleteKnowledgeDocumentUploadBody = { + parts: Array<{ + partNumber: number + etag?: string + }> +} + +export type CompleteKnowledgeDocumentUploadHeaders = { + 'upload-token': string +} + +export type CompleteKnowledgeDocumentUploadResponse = { + data: { + id: string + knowledgeBaseId: 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 + 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 + } | null + } +} + /** `POST /api/v2/tables/imports/[importId]/complete` */ export type CompleteTableImportParams = { importId: string @@ -626,6 +719,87 @@ export type CreateKnowledgeBaseResponse = { } } +/** `POST /api/v2/knowledge/[id]/documents/uploads` */ +export type CreateKnowledgeDocumentUploadParams = { + id: string +} + +export type CreateKnowledgeDocumentUploadBody = { + workspaceId: string + name: string + contentType: string + size: number + tag1?: string + tag2?: string + tag3?: string + tag4?: string + tag5?: string + tag6?: string + tag7?: string + processingOptions?: { + recipe?: string + lang?: string + } +} + +export type CreateKnowledgeDocumentUploadResponse = { + data: { + id: string + knowledgeBaseId: 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 + 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 + } | null + } +} + +/** `POST /api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts` */ +export type CreateKnowledgeDocumentUploadPartUrlsParams = { + id: string + uploadId: string +} + +export type CreateKnowledgeDocumentUploadPartUrlsQuery = { + workspaceId: string +} + +export type CreateKnowledgeDocumentUploadPartUrlsBody = { + partNumbers: Array +} + +export type CreateKnowledgeDocumentUploadPartUrlsHeaders = { + 'upload-token': string +} + +export type CreateKnowledgeDocumentUploadPartUrlsResponse = { + data: { + parts: Array<{ + partNumber: number + url: string + headers: Record + expiresAt: string + }> + } +} + /** `POST /api/v2/mcp-servers` */ export type CreateMcpServerBody = { workspaceId: string @@ -3939,6 +4113,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + abortKnowledgeDocumentUpload: { + method: 'DELETE', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Abort Document Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + }, addTableColumn: { method: 'POST', path: '/api/v2/tables/[tableId]/columns', @@ -4029,6 +4213,19 @@ export const V2_OPERATIONS = { parts: { kind: 'array', required: true }, }, }, + completeKnowledgeDocumentUpload: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Complete Document Upload', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + parts: { kind: 'array', required: true }, + }, + }, completeTableImport: { method: 'POST', path: '/api/v2/tables/imports/[importId]/complete', @@ -4140,6 +4337,40 @@ export const V2_OPERATIONS = { chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, }, }, + createKnowledgeDocumentUpload: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'Create Document Upload', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string', required: true }, + size: { kind: 'integer', required: true }, + tag1: { kind: 'string' }, + tag2: { kind: 'string' }, + tag3: { kind: 'string' }, + tag4: { kind: 'string' }, + tag5: { kind: 'string' }, + tag6: { kind: 'string' }, + tag7: { kind: 'string' }, + processingOptions: { kind: 'object' }, + }, + }, + createKnowledgeDocumentUploadPartUrls: { + method: 'POST', + path: '/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts', + pathParams: ['id', 'uploadId'] as const, + responseMode: 'json', + summary: 'Create Document Upload Part URLs', + query: { + workspaceId: { kind: 'string', required: true }, + }, + body: { + partNumbers: { kind: 'array', required: true }, + }, + }, createMcpServer: { method: 'POST', path: '/api/v2/mcp-servers', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index f031dfc39a1..ebbc07f5d1e 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -182,6 +182,7 @@ describe('destructive operations are gated', () => { // 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', + 'abortKnowledgeDocumentUpload', 'cancelTableImport', 'cancelTableExport', ]) diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index 2d29d31c9a3..b9056243cb0 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -5,20 +5,28 @@ import { SimApiError } from '../http/client.js' const CONTENT_TYPES: Record = { css: 'text/css', csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', gif: 'image/gif', html: 'text/html', + htm: 'text/html', jpeg: 'image/jpeg', jpg: 'image/jpeg', js: 'text/javascript', json: 'application/json', + jsonl: 'application/jsonl', md: 'text/markdown', pdf: 'application/pdf', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', png: 'image/png', svg: 'image/svg+xml', txt: 'text/plain', webp: 'image/webp', yaml: 'application/yaml', yml: 'application/yaml', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', zip: 'application/zip', } From 383f0f8786642a8778d85faa2ca051d20da85d98 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 17:53:25 -0700 Subject: [PATCH 31/46] fix(cli): support unified upload sessions --- packages/sim-cli/README.md | 2 + .../commands/protocol/files-upload.test.ts | 126 ++++++ .../src/commands/protocol/files-upload.ts | 29 +- .../knowledge-document-upload.test.ts | 11 +- .../protocol/knowledge-document-upload.ts | 24 +- .../commands/protocol/tables-import.test.ts | 15 +- .../src/commands/protocol/tables-import.ts | 44 +-- packages/sim-cli/src/generated/v2-api.ts | 362 +++++++++++------- packages/sim-cli/src/runtime/build.test.ts | 34 ++ .../{multipart.ts => upload-session.ts} | 74 +++- 10 files changed, 503 insertions(+), 218 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/files-upload.test.ts rename packages/sim-cli/src/transfer/{multipart.ts => upload-session.ts} (50%) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 77f0f045f3c..64745473ca5 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -130,6 +130,8 @@ sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes sim files list +sim files create --name [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder-id ] sim files download [-o ] sim files delete diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts new file mode 100644 index 00000000000..9c09fb66207 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +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', + endpoint: 'https://sim.example', + }, + }), +})) + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-file-upload-')) + mockRequest.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +describe('files upload', () => { + it('uses a signed PUT transfer and completes with an empty body', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + mockRequest + .mockResolvedValueOnce({ + data: { + session: { + id: 'upload_1', + status: 'uploading', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + file: null, + }, + uploadToken: 'secret-token', + transfer: { + method: 'put', + url: 'https://storage.example/file', + headers: { 'content-type': 'text/plain' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + id: 'upload_1', + status: 'completed', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + expiresAt: '2026-08-04T20:00:00.000Z', + error: null, + file: { + id: 'file_1', + name: 'notes.txt', + size: 5, + type: 'text/plain', + key: 'workspace/ws_local/notes.txt', + folderId: null, + folderPath: null, + uploadedBy: 'user_1', + uploadedAt: '2026-08-04T19:00:00.000Z', + updatedAt: '2026-08-04T19:00:00.000Z', + }, + }, + }) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync(['node', 'sim', 'file', 'upload', path]) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://storage.example/file', + expect.objectContaining({ + method: 'PUT', + headers: { 'content-type': 'text/plain' }, + body: expect.any(Blob), + }) + ) + expect(mockRequest.mock.calls[1]).toEqual([ + '/api/v2/files/uploads/upload_1/complete', + { + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, + body: {}, + }, + ]) + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + name: 'notes.txt', + size: 5, + status: 'uploaded', + }) + expect(logged[0]).not.toContain('secret-token') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index a0221efffb7..1a2e5a9b337 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -1,17 +1,13 @@ import type { Command } from 'commander' import { clientFrom } from '../../context.js' +import type { + CompleteFileUploadResponse, + CreateFileUploadResponse, +} from '../../generated/v2-api.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishTransfer } from '../../transfer/multipart.js' +import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' -interface FileUpload { - id: string - uploadToken: string - partSize: number - partCount: number - file: { id: string } | null -} - export function attachFileUpload(files: Command): void { files .command('upload ') @@ -24,7 +20,7 @@ export function attachFileUpload(files: Command): void { const workspaceId = client.requireWorkspace() const { name, size } = await localFile(path, options.name) - const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', { + const created = await client.request('/api/v2/files/uploads', { method: 'POST', body: { workspaceId, @@ -34,22 +30,21 @@ export function attachFileUpload(files: Command): void { ...(options.folderId ? { folderId: options.folderId } : {}), }, }) - const upload = created.data - const completed = await finishTransfer( + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( client, workspaceId, { - basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, - uploadToken: upload.uploadToken, - partSize: upload.partSize, - partCount: upload.partCount, + basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, }, path ) printProtocolResult(profile.output, { - id: completed.file?.id ?? completed.id, + id: completed.file?.id ?? session.id, name, size, status: 'uploaded', diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 4dd4e68c865..525751c58d9 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -56,9 +56,6 @@ function uploadSession() { name: 'notes.doc', contentType: 'application/msword', size: 5, - partSize: 10, - partCount: 1, - uploadToken: 'secret-token', expiresAt: '2026-08-04T20:00:00.000Z', error: null, document: null, @@ -81,7 +78,13 @@ describe('knowledge documents upload', () => { writeFileSync(path, 'hello') const session = uploadSession() mockRequest - .mockResolvedValueOnce({ data: session }) + .mockResolvedValueOnce({ + data: { + session, + uploadToken: 'secret-token', + transfer: { method: 'multipart', partSize: 10, partCount: 1 }, + }, + }) .mockResolvedValueOnce({ data: { parts: [ diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index b9fd2d88c4f..1a459930628 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -1,13 +1,14 @@ import type { Command } from 'commander' import { clientFrom } from '../../context.js' -import type { CreateKnowledgeDocumentUploadResponse } from '../../generated/v2-api.js' +import type { + CompleteKnowledgeDocumentUploadResponse, + CreateKnowledgeDocumentUploadResponse, +} from '../../generated/v2-api.js' import { SimApiError } from '../../http/client.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishTransfer } from '../../transfer/multipart.js' +import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' -type KnowledgeDocumentUpload = CreateKnowledgeDocumentUploadResponse['data'] - interface KnowledgeDocumentUploadOptions { name?: string tag?: string[] @@ -65,24 +66,25 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { }, } ) - const upload = created.data - const completed = await finishTransfer( + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession< + CompleteKnowledgeDocumentUploadResponse['data'] + >( client, workspaceId, { basePath: `/api/v2/knowledge/${encodeURIComponent( knowledgeBaseId - )}/documents/uploads/${encodeURIComponent(upload.id)}`, - uploadToken: upload.uploadToken, - partSize: upload.partSize, - partCount: upload.partCount, + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, }, path ) if (!completed.document) { - throw new Error(`Knowledge upload ${completed.id} completed without a document`) + throw new Error(`Knowledge upload ${session.id} completed without a document`) } printProtocolResult(profile.output, { id: completed.document.id, diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index ae238855b34..ba907338272 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -86,12 +86,15 @@ describe('tables import output', () => { it('prints a normalized result without transfer secrets', async () => { mockRequest.mockResolvedValue({ data: { - id: 'import_1', - status: 'queued', - tableId: 'table_1', - rowsProcessed: 0, - error: null, - upload: null, + session: { + id: 'import_1', + status: 'queued', + tableId: 'table_1', + rowsProcessed: 0, + error: null, + }, + uploadToken: null, + transfer: null, }, }) const logged: string[] = [] diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 9c2ca5b0cc7..4ef2e33b31d 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -2,20 +2,18 @@ import { setTimeout as sleep } from 'node:timers/promises' import chalk from 'chalk' import { type Command, Option } from 'commander' import { clientFrom } from '../../context.js' +import type { + CompleteTableImportResponse, + CreateTableImportResponse, + GetTableImportResponse, +} from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' -import { coerce } from '../../runtime/request.js' +import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' -import { finishTransfer } from '../../transfer/multipart.js' +import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' -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 -} +type TableImport = GetTableImportResponse['data'] interface ImportOptions { name?: string @@ -39,8 +37,8 @@ function tableNameFrom(fileName: string): string { return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128) } -function jsonFlag(raw: string, flagName: string): unknown { - return coerce(raw, { kind: 'object' }, { json: true }, flagName) +function jsonFlag(raw: string, flagName: string, kind: FieldSpec['kind']): unknown { + return coerce(raw, { kind }, { json: true }, flagName) } async function watchImport( @@ -144,31 +142,33 @@ export function attachTableImport(tables: Command): void { target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) } } - const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', { + const started = await client.request('/api/v2/tables/imports', { method: 'POST', body: { workspaceId, source, target, - ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}), + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), ...(options.createColumns - ? { createColumns: jsonFlag(options.createColumns, 'create-columns') } + ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } : {}), ...(options.timezone ? { timezone: options.timezone } : {}), }, }) - let job = started.data - if (path && job.upload) { - job = await finishTransfer( + let job: TableImport = started.data.session + if (path) { + if (!local || !started.data.uploadToken || !started.data.transfer) { + throw new Error('Local table import did not return an upload transfer') + } + job = await finishUploadSession( 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, + uploadToken: started.data.uploadToken, + transfer: started.data.transfer, + size: local.size, }, path ) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 116f7b5f368..dc9cd5ad51e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -30,9 +30,6 @@ export type AbortFileUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null file: { @@ -72,9 +69,6 @@ export type AbortKnowledgeDocumentUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null document: { @@ -297,12 +291,6 @@ export type CancelTableImportResponse = { 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 @@ -360,12 +348,14 @@ export type CompleteFileUploadQuery = { workspaceId: string } -export type CompleteFileUploadBody = { - parts: Array<{ - partNumber: number - etag?: string - }> -} +export type CompleteFileUploadBody = + | { + parts: Array<{ + partNumber: number + etag?: string + }> + } + | Record export type CompleteFileUploadHeaders = { 'upload-token': string @@ -378,9 +368,6 @@ export type CompleteFileUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null file: { @@ -408,12 +395,14 @@ export type CompleteKnowledgeDocumentUploadQuery = { workspaceId: string } -export type CompleteKnowledgeDocumentUploadBody = { - parts: Array<{ - partNumber: number - etag?: string - }> -} +export type CompleteKnowledgeDocumentUploadBody = + | { + parts: Array<{ + partNumber: number + etag?: string + }> + } + | Record export type CompleteKnowledgeDocumentUploadHeaders = { 'upload-token': string @@ -427,9 +416,6 @@ export type CompleteKnowledgeDocumentUploadResponse = { name: string contentType: string size: number - partSize: number - partCount: number - uploadToken: string expiresAt: string error: string | null document: { @@ -457,12 +443,14 @@ export type CompleteTableImportQuery = { workspaceId: string } -export type CompleteTableImportBody = { - parts: Array<{ - partNumber: number - etag?: string - }> -} +export type CompleteTableImportBody = + | { + parts: Array<{ + partNumber: number + etag?: string + }> + } + | Record export type CompleteTableImportHeaders = { 'upload-token': string @@ -498,12 +486,6 @@ export type CompleteTableImportResponse = { 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 @@ -526,6 +508,7 @@ export type CreateCredentialBody = { clientId?: string clientSecret?: string orgId?: string + dataCenter?: string } export type CreateCredentialResponse = { @@ -589,6 +572,31 @@ export type CreateCustomToolResponse = { } } +/** `POST /api/v2/files` */ +export type CreateFileBody = { + workspaceId: string + name: string + contentType?: string + folderId?: string + content?: string + encoding?: 'utf-8' | 'base64' +} + +export type CreateFileResponse = { + 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/uploads` */ export type CreateFileUploadBody = { workspaceId: string @@ -600,28 +608,39 @@ export type CreateFileUploadBody = { 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: { + session: { id: string + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' name: string + contentType: string size: number - type: string - key: string - folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string - updatedAt: string - } | null + 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 + } + uploadToken: string + transfer: + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } } } @@ -744,30 +763,41 @@ export type CreateKnowledgeDocumentUploadBody = { export type CreateKnowledgeDocumentUploadResponse = { data: { - id: string - knowledgeBaseId: 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 - document: { + session: { 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 - } | null + status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + 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 + } | null + } + uploadToken: string + transfer: + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } } } @@ -983,51 +1013,99 @@ export type CreateTableImportBody = { tableId: string mode: 'append' | 'replace' } - mapping?: unknown - createColumns?: unknown + mapping?: Record + createColumns?: Array 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 + data: + | { + session: { + id: string + workspaceId: string + status: + | 'uploading' + | 'queued' + | 'processing' + | 'completed' + | 'failed' + | 'canceled' + | 'expired' + source: { + type: 'upload' + name: string + contentType: string + size: number + } + target: + | { + type: 'new' + name: string + folderId?: string + } + | { + type: 'existing' + tableId: string + mode: 'append' | 'replace' + } + tableId: string | null + rowsProcessed: number + error: string | null + createdAt: string + updatedAt: string + completedAt: string | null } - | { - type: 'existing' - tableId: string - mode: 'append' | 'replace' + uploadToken: string + transfer: + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } + } + | { + session: { + id: string + workspaceId: string + status: + | 'uploading' + | 'queued' + | 'processing' + | 'completed' + | 'failed' + | 'canceled' + | 'expired' + source: { + 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 + createdAt: string + updatedAt: string + completedAt: string | null } - 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 - } + uploadToken: null + transfer: null + } } /** `POST /api/v2/tables/imports/[importId]/parts` */ @@ -2190,12 +2268,6 @@ export type GetTableImportResponse = { 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 @@ -3405,6 +3477,7 @@ export type UpdateCredentialBody = { clientId?: string clientSecret?: string orgId?: string + dataCenter?: string } export type UpdateCredentialResponse = { @@ -4209,9 +4282,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - body: { - parts: { kind: 'array', required: true }, - }, + opaqueBody: true, }, completeKnowledgeDocumentUpload: { method: 'POST', @@ -4222,9 +4293,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - body: { - parts: { kind: 'array', required: true }, - }, + opaqueBody: true, }, completeTableImport: { method: 'POST', @@ -4235,9 +4304,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - body: { - parts: { kind: 'array', required: true }, - }, + opaqueBody: true, }, createCredential: { method: 'POST', @@ -4264,6 +4331,7 @@ export const V2_OPERATIONS = { clientId: { kind: 'string' }, clientSecret: { kind: 'string' }, orgId: { kind: 'string' }, + dataCenter: { kind: 'string' }, }, }, createCustomTool: { @@ -4279,6 +4347,21 @@ export const V2_OPERATIONS = { code: { kind: 'string', required: true }, }, }, + createFile: { + method: 'POST', + path: '/api/v2/files', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create File', + body: { + workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + contentType: { kind: 'string' }, + folderId: { kind: 'string' }, + content: { kind: 'string', default: '' }, + encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, + }, + }, createFileUpload: { method: 'POST', path: '/api/v2/files/uploads', @@ -4440,8 +4523,8 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, source: { kind: 'unknown', required: true }, target: { kind: 'unknown', required: true }, - mapping: { kind: 'unknown' }, - createColumns: { kind: 'unknown' }, + mapping: { kind: 'object' }, + createColumns: { kind: 'array' }, timezone: { kind: 'string' }, }, }, @@ -5383,6 +5466,7 @@ export const V2_OPERATIONS = { clientId: { kind: 'string' }, clientSecret: { kind: 'string' }, orgId: { kind: 'string' }, + dataCenter: { kind: 'string' }, }, }, updateCustomTool: { diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index df782306a19..2ce5121b80e 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -135,6 +135,40 @@ describe('commands parsed through commander', () => { expect(options.body).toMatchObject({ conflictTarget: 'email', data: { a: 1 } }) }) + it('exposes inline file creation added by the v2 files contract', async () => { + const [path, options] = await run([ + 'file', + 'create', + '--name', + 'notes.txt', + '--content', + 'hello', + '--encoding', + 'utf-8', + ]) + expect(path).toBe('/api/v2/files') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + name: 'notes.txt', + content: 'hello', + encoding: 'utf-8', + }) + }) + + it('exposes credential data centers added by the v2 credential contract', async () => { + const [, options] = await run([ + 'credential', + 'create', + '--type', + 'service_account', + '--display-name', + 'Zoho', + '--data-center', + 'eu', + ]) + expect(options.body).toMatchObject({ dataCenter: 'eu' }) + }) + 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' }) diff --git a/packages/sim-cli/src/transfer/multipart.ts b/packages/sim-cli/src/transfer/upload-session.ts similarity index 50% rename from packages/sim-cli/src/transfer/multipart.ts rename to packages/sim-cli/src/transfer/upload-session.ts index 20a7cfa1b3e..959643095f6 100644 --- a/packages/sim-cli/src/transfer/multipart.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -7,24 +7,54 @@ interface UploadPartUrl { headers: Record } -export interface Transfer { +export type UploadTransfer = + | { + method: 'put' + url: string + headers: Record + } + | { + method: 'multipart' + partSize: number + partCount: number + } + +export interface UploadSession { basePath: string uploadToken: string - partSize: number - partCount: number + transfer: UploadTransfer size: number } const PART_URL_BATCH = 100 +async function uploadPut(transfer: Extract, blob: Blob) { + // boundary-raw-fetch: signed upload data-plane URL may target cloud storage or local Sim + const response = await fetch(transfer.url, { + method: 'PUT', + headers: transfer.headers, + body: blob, + }) + if (!response.ok) { + throw new SimApiError(`Upload failed with status ${response.status}`, response.status) + } +} + async function uploadParts( client: SimClient, workspaceId: string, - transfer: Transfer, + session: UploadSession, + transfer: Extract, blob: Blob ): Promise> { - const completed: Array<{ partNumber: number; etag?: string }> = [] + const expectedPartCount = Math.ceil(session.size / transfer.partSize) + if (expectedPartCount !== transfer.partCount) { + throw new Error( + `Upload session expected ${transfer.partCount} parts, but file requires ${expectedPartCount}` + ) + } + const completed: Array<{ partNumber: number; etag?: string }> = [] for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { const partNumbers = [] for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { @@ -32,20 +62,20 @@ async function uploadParts( } const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>( - `${transfer.basePath}/parts`, + `${session.basePath}/parts`, { method: 'POST', query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, + headers: { 'upload-token': session.uploadToken }, body: { partNumbers }, } ) for (const part of signed.data.parts) { const start = (part.partNumber - 1) * transfer.partSize - const chunk = blob.slice(start, Math.min(start + transfer.partSize, transfer.size)) + const chunk = blob.slice(start, Math.min(start + transfer.partSize, session.size)) - // boundary-raw-fetch: storage-signed URL on another origin, not the API + // boundary-raw-fetch: signed upload data-plane URL may target cloud storage or local Sim const response = await fetch(part.url, { method: 'PUT', headers: part.headers, @@ -62,33 +92,39 @@ async function uploadParts( completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) } } - return completed } -/** Uploads and completes a multipart transfer, aborting it if either step fails. */ -export async function finishTransfer( +/** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ +export async function finishUploadSession( client: SimClient, workspaceId: string, - transfer: Transfer, + session: UploadSession, 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`, { + let body: Record + if (session.transfer.method === 'put') { + await uploadPut(session.transfer, blob) + body = {} + } else { + body = { parts: await uploadParts(client, workspaceId, session, session.transfer, blob) } + } + + const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { method: 'POST', query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, - body: { parts }, + headers: { 'upload-token': session.uploadToken }, + body, }) return completed.data } catch (error) { await client - .request(transfer.basePath, { + .request(session.basePath, { method: 'DELETE', query: { workspaceId }, - headers: { 'upload-token': transfer.uploadToken }, + headers: { 'upload-token': session.uploadToken }, }) .catch(() => undefined) throw error From 9c3df54b68ae0cc08b3722d8ba4c6bd005b24a40 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 18:33:08 -0700 Subject: [PATCH 32/46] feat(cli): accept simple list inputs --- packages/sim-cli/README.md | 16 +++++++ packages/sim-cli/src/contract/commands.ts | 27 ++++++++++- packages/sim-cli/src/contract/types.ts | 3 +- packages/sim-cli/src/runtime/build.test.ts | 27 +++++++++++ packages/sim-cli/src/runtime/options.ts | 10 ++-- packages/sim-cli/src/runtime/request.test.ts | 19 ++++++++ packages/sim-cli/src/runtime/request.ts | 50 +++++++++++++++++--- 7 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 64745473ca5..bfe618954ed 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -133,6 +133,8 @@ sim files list sim files create --name [--content ] [--encoding utf-8|base64] sim files upload [--name ] [--folder-id ] sim files download [-o ] +sim files move [--file-ids …] [--folder-ids …] [--target-folder-id ] +sim files batch-archive [--file-ids …] [--folder-ids …] --yes sim files delete sim knowledge list @@ -142,6 +144,20 @@ sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` +### List inputs + +Primitive lists take space-separated values. Prefix a path with `@` to read +one value per line, or use `@-` to read the list from stdin. + +```bash +sim files move --file-ids file_1 file_2 --target-folder-id folder_1 +sim files move --file-ids @file-ids.txt --target-folder-id folder_1 +printf 'file_1\nfile_2\n' | sim files move --file-ids @- --target-folder-id folder_1 +``` + +Arrays of objects remain JSON inputs because they cannot be represented as a +flat list without losing structure. + ### Filtering table rows `--filter` takes the same predicate tree the API uses — `all` (AND) or `any` diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index fca6ef16a66..c739043a1bf 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -285,11 +285,19 @@ export const CLI_CONTRACT: CliContract = { // `batch-` for the bulk form, matching `tables rows batch-delete`. command: 'files batch-archive', describe: 'Archive several files and folders at once', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + }, 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', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + }, }, renameFile: { // Derived to `files update`, which contradicted its own summary. @@ -314,13 +322,23 @@ export const CLI_CONTRACT: CliContract = { upsertFileShare: { command: 'files share set', describe: 'Enable or disable sharing for a file', + flags: { + allowedEmails: { list: true }, + }, }, // ─── 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' }, + cancelTableRuns: { + command: 'tables cancel-runs', + describe: 'Stop every running column job', + flags: { + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, + }, findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate', @@ -336,7 +354,12 @@ export const CLI_CONTRACT: CliContract = { runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow', - flags: { filter: { json: true, describe: TABLE_FILTER_HELP } }, + flags: { + groupIds: { list: true }, + rowIds: { list: true }, + excludeRowIds: { list: true }, + filter: { json: true, describe: TABLE_FILTER_HELP }, + }, }, runRowEnrichment: { command: 'tables rows enrich', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 1f585d15b98..43dbc7cba80 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -30,7 +30,8 @@ export interface FlagSpec { /** Short alias, e.g. `w` for `--workspace`. */ short?: string /** - * Accept the flag more than once. + * Accept one or more space-separated values, or `@path` / `@-` with one + * value per line. * * 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 diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 2ce5121b80e..0b76fd3e1a9 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -155,6 +155,27 @@ describe('commands parsed through commander', () => { }) }) + it('accepts space-separated file and folder ids', async () => { + const [path, options] = await run([ + 'file', + 'move', + '--file-ids', + 'file_1', + 'file_2', + '--folder-ids', + 'folder_1', + '--target-folder-id', + 'folder_2', + ]) + expect(path).toBe('/api/v2/files/move') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + fileIds: ['file_1', 'file_2'], + folderIds: ['folder_1'], + targetFolderId: 'folder_2', + }) + }) + it('exposes credential data centers added by the v2 credential contract', async () => { const [, options] = await run([ 'credential', @@ -222,6 +243,12 @@ describe('commands parsed through commander', () => { expect(options.body).toMatchObject({ knowledgeBaseIds: ['kb_1'], searchMode: 'hybrid' }) }) + it('documents space-separated and file-backed lists', () => { + const help = commandAt('files', 'move').helpInformation() + expect(help).toContain('--file-ids ') + expect(help).toMatch(/space-separated.*@path.*one value per line/s) + }) + it('advertises the file-content encoding choices', () => { expect(commandAt('files', 'set-content').helpInformation()).toMatch( /--encoding.*utf-8.*base64/s diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index c3a393ec6af..9c5ab175618 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -59,9 +59,13 @@ function addFieldOption( const choices = flag.choices ?? descriptor.values const describe = `${ flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`) - }${wantsJson && !takesList ? ' (JSON, or @path / @- to read a file or stdin)' : ''}${ - descriptor.required ? ' (required)' : '' - }` + }${ + takesList + ? ' (space-separated, or @path / @- with one value per line)' + : wantsJson + ? ' (JSON, or @path / @- to read a file or stdin)' + : '' + }${descriptor.required ? ' (required)' : ''}` const option = new Option(`${short}--${name} ${placeholder}`, describe) if (choices && !takesList) option.choices([...choices]) diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 7470db8bb79..88dfa4e3d25 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -142,6 +142,25 @@ describe('repeated flags encode per the field kind, not uniformly', () => { ) expect(built.body?.knowledgeBaseIds).toEqual(['kb_1', 'kb_2']) }) + + it('reads one list value per line from @path', () => { + const path = join(tmpdir(), 'sim-cli-list-values.txt') + writeFileSync(path, 'file_1\nfile_2\n') + expect(coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toEqual([ + 'file_1', + 'file_2', + ]) + rmSync(path) + }) + + it('rejects empty lines in a list file', () => { + const path = join(tmpdir(), 'sim-cli-list-empty-line.txt') + writeFileSync(path, 'file_1\n\nfile_2') + expect(() => coerce(`@${path}`, { kind: 'array' }, { list: true }, 'file-ids')).toThrow( + /empty value on line 2/ + ) + rmSync(path) + }) }) describe('contract-provided choices', () => { diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 292d413bf10..7883cebd5f7 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -77,16 +77,16 @@ function readStdin(): string { } /** - * Resolves a JSON flag's argument, which may name a file instead of carrying - * the document inline. + * Resolves a flag argument that may name a file instead of carrying its value + * 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. + * quoted form is easy to get wrong. JSON never starts with `@`; primitive list + * flags reserve it for this explicit file-input form. */ -function readJsonArgument(raw: string, flagName: string): { text: string; from: string } { +function readArgumentSource(raw: string, flagName: string): { text: string; from: string } { if (!raw.startsWith('@')) return { text: raw, from: '' } const path = raw.slice(1) @@ -108,6 +108,42 @@ function readJsonArgument(raw: string, flagName: string): { text: string; from: } } +/** Reads a primitive list from argv or a newline-delimited file. */ +function readListValues(raw: unknown, flagName: string): string[] { + const arguments_ = Array.isArray(raw) ? raw : [raw] + const values = arguments_.flatMap((argument) => { + if (typeof argument !== 'string') { + throw new SimApiError(`--${flagName} values must be strings`, 0) + } + + if (!argument.startsWith('@')) return [argument] + + const source = readArgumentSource(argument, flagName) + const lines = source.text.split(/\r?\n/) + if (lines.at(-1) === '') lines.pop() + if (lines.length === 0) { + throw new SimApiError(`--${flagName}${source.from} contains no values`, 0) + } + + return lines.map((line, index) => { + const value = line.trim() + if (!value) { + throw new SimApiError( + `--${flagName}${source.from} has an empty value on line ${index + 1}`, + 0 + ) + } + return value + }) + }) + + return values.map((value) => { + const trimmed = value.trim() + if (!trimmed) throw new SimApiError(`--${flagName} values cannot be empty`, 0) + return trimmed + }) +} + /** * Points at `@` when a value that failed to parse looks like a filename. * @@ -144,13 +180,13 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: * or failed validation outright. */ if (flag.list) { - const values = Array.isArray(raw) ? raw : [raw] + const values = readListValues(raw, flagName) return field.kind === 'string' ? values.join(',') : values } if (takesJson(field, flag)) { if (typeof raw !== 'string') return raw - const source = readJsonArgument(raw, flagName) + const source = readArgumentSource(raw, flagName) try { return JSON.parse(source.text) } catch (error) { From 03187ab9768c625651db2b564b8888436240649b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:37:55 -0700 Subject: [PATCH 33/46] feat(cli): add path-based resource directories --- packages/sim-cli/README.md | 48 +- .../commands/protocol/files-upload.test.ts | 18 +- .../src/commands/protocol/files-upload.ts | 66 +- .../sim-cli/src/commands/protocol/index.ts | 30 +- .../src/commands/protocol/resource-ls.test.ts | 127 +++ .../src/commands/protocol/resource-ls.ts | 171 ++++ .../commands/protocol/tables-import.test.ts | 21 +- .../src/commands/protocol/tables-import.ts | 8 +- packages/sim-cli/src/contract/commands.ts | 160 ++- packages/sim-cli/src/contract/types.ts | 4 + packages/sim-cli/src/generated/v2-api.ts | 948 +++++++++++------- packages/sim-cli/src/runtime/build.test.ts | 69 +- packages/sim-cli/src/runtime/build.ts | 11 +- packages/sim-cli/src/runtime/execute.ts | 14 +- packages/sim-cli/src/runtime/options.ts | 1 + packages/sim-cli/src/runtime/request.ts | 2 +- 16 files changed, 1235 insertions(+), 463 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/resource-ls.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/resource-ls.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index bfe618954ed..7f1cc5316cf 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -113,46 +113,72 @@ also accepts its singular form: for example, `sim table list`, spellings. ```bash -sim workflows list [--folder ] [--deployed] [--limit ] +sim workflows ls [--folder ] [--search ] [--limit ] +sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get +sim workflows mv --folder sim workflows deploy|undeploy|rollback sim logs list [--level error] [--workflow …] [--trigger …] [--start ] sim logs get sim logs execution -sim tables list +sim tables ls [--folder ] [--search ] [--limit ] +sim tables list [--folder ] sim tables get +sim tables mv --folder sim tables columns sim tables rows list [--limit ] sim tables rows query [--filter ] [--sort ] [--limit ] sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes -sim files list -sim files create --name [--content ] [--encoding utf-8|base64] -sim files upload [--name ] [--folder-id ] +sim files ls [--folder ] [--search ] [--limit ] +sim files list [--folder ] +sim files get +sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder ] sim files download [-o ] -sim files move [--file-ids …] [--folder-ids …] [--target-folder-id ] -sim files batch-archive [--file-ids …] [--folder-ids …] --yes +sim files mv --file-ids … [--to ] +sim files batch-delete --file-ids … --yes sim files delete -sim knowledge list +sim knowledge ls [--folder ] [--search ] [--limit ] +sim knowledge list [--folder ] sim knowledge get +sim knowledge mv --folder sim knowledge documents [--search ] sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` +`ls` is a directory view: it combines the resources at `--folder` with that +folder's direct child folders. Its `ref` column is the resource ID or canonical +folder path to pass to the next command. Use `list` when you want resources only, +or `folders ls` when you want folders only. + +Each folder-backed resource has the same path commands: + +```bash +sim tables folders ls --parent /Reports +sim tables folders create /Reports/Quarterly +sim tables folders mv /Reports/Quarterly /Archive/Quarterly +sim tables folders delete /Archive/Quarterly --recursive false --yes +``` + +Replace `tables` with `files`, `workflows`, or `knowledge`. Paths are canonical, +start with `/`, and use `/` for root. A slash that belongs to a folder name is +percent-encoded as `%2F` rather than treated as a separator. + ### List inputs Primitive lists take space-separated values. Prefix a path with `@` to read one value per line, or use `@-` to read the list from stdin. ```bash -sim files move --file-ids file_1 file_2 --target-folder-id folder_1 -sim files move --file-ids @file-ids.txt --target-folder-id folder_1 -printf 'file_1\nfile_2\n' | sim files move --file-ids @- --target-folder-id folder_1 +sim files mv --file-ids file_1 file_2 --to /Archive +sim files mv --file-ids @file-ids.txt --to /Archive +printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to /Archive ``` Arrays of objects remain JSON inputs because they cannot be represented as a diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 9c09fb66207..53ec1f7cbb3 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -83,8 +83,7 @@ describe('files upload', () => { size: 5, type: 'text/plain', key: 'workspace/ws_local/notes.txt', - folderId: null, - folderPath: null, + folderPath: '/', uploadedBy: 'user_1', uploadedAt: '2026-08-04T19:00:00.000Z', updatedAt: '2026-08-04T19:00:00.000Z', @@ -96,7 +95,7 @@ describe('files upload', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync(['node', 'sim', 'file', 'upload', path]) + await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', '/Reports']) expect(fetchMock).toHaveBeenCalledWith( 'https://storage.example/file', @@ -106,6 +105,19 @@ describe('files upload', () => { body: expect.any(Blob), }) ) + expect(mockRequest.mock.calls[0]).toEqual([ + '/api/v2/files/uploads', + { + method: 'POST', + body: { + workspaceId: 'ws_local', + name: 'notes.txt', + contentType: 'text/plain', + size: 5, + folderPath: '/Reports', + }, + }, + ]) expect(mockRequest.mock.calls[1]).toEqual([ '/api/v2/files/uploads/upload_1/complete', { diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 1a2e5a9b337..627e35b12a5 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -12,43 +12,41 @@ export function attachFileUpload(files: Command): void { files .command('upload ') .description('Upload a file to the workspace') - .option('--folder-id ', 'Target folder (defaults to the workspace root)') + .option('--folder ', 'Canonical destination folder path (defaults to /)') .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() - const { name, size } = await localFile(path, options.name) + .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) - const created = await client.request('/api/v2/files/uploads', { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...(options.folderId ? { folderId: options.folderId } : {}), - }, - }) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession( - client, + const created = await client.request('/api/v2/files/uploads', { + method: 'POST', + body: { workspaceId, - { - basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, - size, - }, - path - ) - - printProtocolResult(profile.output, { - id: completed.file?.id ?? session.id, name, + contentType: contentTypeFor(name), + size, + ...(options.folder ? { folderPath: options.folder } : {}), + }, + }) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( + client, + workspaceId, + { + basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, - status: 'uploaded', - }) - } - ) + }, + path + ) + + printProtocolResult(profile.output, { + id: completed.file?.id ?? session.id, + name, + size, + status: 'uploaded', + }) + }) } diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 5ad2647d06a..b939f0a6f74 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' +import { attachResourceList } from './resource-ls.js' import { attachTableImport } from './tables-import.js' function group(program: Command, name: string): Command { @@ -17,6 +18,31 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) - attachKnowledgeDocumentUpload(group(group(program, 'knowledge'), 'documents')) - attachTableImport(group(program, 'tables')) + attachResourceList(files, { + kind: 'file', + resources: 'listFiles', + folders: 'listFileFolders', + }) + + const knowledge = group(program, 'knowledge') + attachKnowledgeDocumentUpload(group(knowledge, 'documents')) + attachResourceList(knowledge, { + kind: 'knowledge', + resources: 'listKnowledgeBases', + folders: 'listKnowledgeFolders', + }) + + const tables = group(program, 'tables') + attachTableImport(tables) + attachResourceList(tables, { + kind: 'table', + resources: 'listTables', + folders: 'listTableFolders', + }) + + attachResourceList(group(program, 'workflows'), { + kind: 'workflow', + resources: 'listWorkflows', + folders: 'listWorkflowFolders', + }) } diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.test.ts b/packages/sim-cli/src/commands/protocol/resource-ls.test.ts new file mode 100644 index 00000000000..41bdc81d4cf --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-ls.test.ts @@ -0,0 +1,127 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildGeneratedCommands } from '../../runtime/build.js' +import { attachProtocolCommands } from './index.js' + +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: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +function program(): Command { + const root = new Command('sim').exitOverride() + for (const group of buildGeneratedCommands()) root.addCommand(group) + attachProtocolCommands(root) + return root +} + +beforeEach(() => { + vi.restoreAllMocks() + mockRequest.mockReset() + output.format = 'json' +}) + +describe('resource ls', () => { + it('is available for every folder-backed resource', () => { + for (const resource of ['files', 'knowledge', 'tables', 'workflows']) { + const group = program().commands.find((command) => command.name() === resource) + expect(group?.commands.some((command) => command.name() === 'ls')).toBe(true) + } + }) + + it('combines child folders and resources in one directory listing', async () => { + mockRequest.mockImplementation(async (path: string) => { + if (path === '/api/v2/tables/folders') { + return { + data: [ + { + name: 'Archive', + path: '/Reports/Archive', + parentPath: '/Reports', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + if (path === '/api/v2/tables') { + return { + data: [ + { + id: 'tbl_1', + name: 'Revenue', + folderPath: '/Reports', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ], + nextCursor: null, + } + } + throw new Error(`Unexpected path: ${path}`) + }) + const logged: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) + + await program().parseAsync([ + 'node', + 'sim', + 'table', + 'ls', + '--folder', + '/Reports', + '--search', + 'r', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + query: { + workspaceId: 'ws_local', + parentPath: '/Reports', + search: 'r', + sortBy: 'name', + sortOrder: 'asc', + }, + }) + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', { + query: { + workspaceId: 'ws_local', + folderPath: '/Reports', + search: 'r', + sortBy: 'name', + sortOrder: 'asc', + limit: 100, + cursor: null, + }, + }) + expect(JSON.parse(logged[0])).toEqual([ + { + kind: 'folder', + name: 'Archive', + ref: '/Reports/Archive', + folderPath: '/Reports', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + { + kind: 'table', + name: 'Revenue', + ref: 'tbl_1', + folderPath: '/Reports', + updatedAt: '2026-08-03T00:00:00.000Z', + }, + ]) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.ts b/packages/sim-cli/src/commands/protocol/resource-ls.ts new file mode 100644 index 00000000000..1bae5dd23e5 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/resource-ls.ts @@ -0,0 +1,171 @@ +import { type Command, Option } from 'commander' +import { clientFrom } from '../../context.js' +import { + type ListFileFoldersResponse, + type ListFilesResponse, + type ListKnowledgeBasesResponse, + type ListKnowledgeFoldersResponse, + type ListTableFoldersResponse, + type ListTablesResponse, + type ListWorkflowFoldersResponse, + type ListWorkflowsResponse, + V2_OPERATIONS, + type V2OperationName, +} from '../../generated/v2-api.js' +import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' +import { type Column, printList, text, timestamp } from '../../output/render.js' +import { DEFAULT_LIMIT } from '../../runtime/options.js' + +type FolderListOperation = + | 'listFileFolders' + | 'listKnowledgeFolders' + | 'listTableFolders' + | 'listWorkflowFolders' + +type DirectoryResource = + | ListFilesResponse['data'][number] + | ListKnowledgeBasesResponse['data'][number] + | ListTablesResponse['data'][number] + | ListWorkflowsResponse['data'][number] + +type DirectoryFolder = + | ListFileFoldersResponse['data'][number] + | ListKnowledgeFoldersResponse['data'][number] + | ListTableFoldersResponse['data'][number] + | ListWorkflowFoldersResponse['data'][number] + +interface DirectoryEntry { + kind: string + name: string + ref: string + folderPath: string + updatedAt: string +} + +type ResourceDirectoryConfig = + | { kind: 'file'; resources: 'listFiles'; folders: 'listFileFolders' } + | { + kind: 'knowledge' + resources: 'listKnowledgeBases' + folders: 'listKnowledgeFolders' + } + | { kind: 'table'; resources: 'listTables'; folders: 'listTableFolders' } + | { kind: 'workflow'; resources: 'listWorkflows'; folders: 'listWorkflowFolders' } + +interface ListOptions { + folder: string + search?: string + limit: string +} + +const COLUMNS: Column[] = [ + { header: 'kind', value: (entry) => text(entry.kind) }, + { header: 'name', value: (entry) => text(entry.name) }, + { header: 'ref', value: (entry) => text(entry.ref) }, + { header: 'folder', value: (entry) => text(entry.folderPath) }, + { header: 'updated', value: (entry) => timestamp(entry.updatedAt) }, +] + +function operationPath(operation: V2OperationName): string { + return V2_OPERATIONS[operation].path +} + +async function listResources( + client: SimClient, + config: ResourceDirectoryConfig, + workspaceId: string, + folderPath: string, + search: string | undefined, + limit: number +): Promise { + const query = { workspaceId, folderPath, search, sortBy: 'name', sortOrder: 'asc' } + const path = operationPath(config.resources) + const paginated = 'cursor' in V2_OPERATIONS[config.resources].query + + if (!paginated) { + const page = await client.request>(path, { query }) + return page.data.slice(0, limit) + } + + const resources: DirectoryResource[] = [] + let cursor: string | null = null + + do { + const remaining = limit - resources.length + const pageSize = Math.min(remaining, DEFAULT_LIMIT) + const page: V2Page = await client.request(path, { + query: { ...query, limit: pageSize, cursor }, + }) + resources.push(...page.data) + cursor = page.nextCursor + } while (cursor && resources.length < limit) + + return resources.slice(0, limit) +} + +async function listFolders( + client: SimClient, + operation: FolderListOperation, + workspaceId: string, + parentPath: string, + search: string | undefined +): Promise { + const page = await client.request>(operationPath(operation), { + query: { workspaceId, parentPath, search, sortBy: 'name', sortOrder: 'asc' }, + }) + return page.data +} + +function entriesFor( + config: ResourceDirectoryConfig, + folders: DirectoryFolder[], + resources: DirectoryResource[] +): DirectoryEntry[] { + return [ + ...folders.map((folder) => ({ + kind: 'folder', + name: folder.name, + ref: folder.path, + folderPath: folder.parentPath, + updatedAt: folder.updatedAt, + })), + ...resources.map((resource) => ({ + kind: config.kind, + name: resource.name, + ref: resource.id, + folderPath: resource.folderPath, + updatedAt: resource.updatedAt, + })), + ].sort( + (left, right) => left.name.localeCompare(right.name) || left.kind.localeCompare(right.kind) + ) +} + +export function attachResourceList(group: Command, config: ResourceDirectoryConfig): void { + group + .command('ls') + .description(`List ${config.kind} resources and child folders together`) + .option('--folder ', 'Canonical folder path to list', '/') + .option('--search ', 'Filter folders and resources by name') + .addOption( + new Option('--limit ', 'Maximum combined items to return (0 for everything)').default( + String(DEFAULT_LIMIT) + ) + ) + .action(async (options: ListOptions, command: Command) => { + const rawLimit = Number(options.limit) + if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a non-negative integer', 0) + } + + const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const [folders, resources] = await Promise.all([ + listFolders(client, config.folders, workspaceId, options.folder, options.search), + listResources(client, config, workspaceId, options.folder, options.search, limit), + ]) + const entries = entriesFor(config, folders, resources) + printList(profile.output, entries.slice(0, limit), COLUMNS) + }) +} diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index ba907338272..7dcde00bfd7 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -61,7 +61,7 @@ describe('tables import argument guards', () => { await expect(runImport(['f.csv', '--table-id', 't', '--name', 'x'])).rejects.toThrow( /--table-id already names the destination/ ) - await expect(runImport(['f.csv', '--table-id', 't', '--folder-id', 'f'])).rejects.toThrow( + await expect(runImport(['f.csv', '--table-id', 't', '--folder', '/Reports'])).rejects.toThrow( /--table-id already names the destination/ ) }) @@ -100,7 +100,24 @@ describe('tables import output', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await runImport(['--file-id', 'file_1', '--name', 'Customers', '--no-wait']) + await runImport([ + '--file-id', + 'file_1', + '--name', + 'Customers', + '--folder', + '/Reports', + '--no-wait', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/imports', { + method: 'POST', + body: { + workspaceId: 'ws_local', + source: { type: 'workspace_file', fileId: 'file_1' }, + target: { type: 'new', name: 'Customers', folderPath: '/Reports' }, + }, + }) expect(JSON.parse(logged[0])).toEqual({ id: 'import_1', diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 4ef2e33b31d..4fa72ec4300 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -19,7 +19,7 @@ interface ImportOptions { name?: string tableId?: string mode?: string - folderId?: string + folder?: string fileId?: string mapping?: string createColumns?: string @@ -71,7 +71,7 @@ function validateTargetOptions(options: ImportOptions): boolean { const misplaced = intoExisting ? ([ ['--name', options.name], - ['--folder-id', options.folderId], + ['--folder', options.folder], ] as const) : ([ ['--mode', options.mode], @@ -106,7 +106,7 @@ export function attachTableImport(tables: Command): void { 'How to write into --table-id (default: append)' ).choices(['append', 'replace']) ) - .option('--folder-id ', 'Folder for the new table') + .option('--folder ', 'Canonical folder path for the new table') .option('--file-id ', 'Import a file already in the workspace instead of a local path') .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') @@ -139,7 +139,7 @@ export function attachTableImport(tables: Command): void { 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 } : {}) } + target = { type: 'new', name, ...(options.folder ? { folderPath: options.folder } : {}) } } const started = await client.request('/api/v2/tables/imports', { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c739043a1bf..dc75015f278 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,10 +1,20 @@ -import type { CliContract } from './types.js' +import type { CliContract, ColumnSpec } from './types.js' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' +const FOLDER_PATH_FLAG = { + name: 'folder', + describe: 'Canonical folder path, starting with /', +} as const +const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ + { header: 'path' }, + { header: 'name' }, + { header: 'parent', path: 'parentPath' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, +] /** * The CLI contract for the v2 surface. @@ -77,19 +87,12 @@ export const CLI_CONTRACT: CliContract = { { header: 'remaining columns', path: 'columns', format: 'count' }, ], }, - 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. listLogs: { flags: { workflowIds: { name: 'workflow', list: true }, - folderIds: { name: 'folder', list: true }, + folderPaths: { name: 'folder', list: true }, triggers: { name: 'trigger', list: true }, }, columns: [ @@ -162,35 +165,53 @@ export const CLI_CONTRACT: CliContract = { createTable: { flags: { name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, schema: { json: true, describe: 'Table schema: {"columns":[{"name":"email","type":"string"}]}', }, }, }, - updateTable: { flags: { name: { describe: TABLE_NAME_HELP } } }, + updateTable: { + aliases: ['mv'], + flags: { + name: { describe: TABLE_NAME_HELP }, + folderPath: FOLDER_PATH_FLAG, + }, + }, + createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, + createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateKnowledgeBase: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + createWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, + updateWorkflow: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // ─── Output columns for list commands ───────────────────────────────────── listTables: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, + { header: 'folder', path: 'folderPath' }, { header: 'rows', path: 'rowCount' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, listWorkflows: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, + { header: 'folder', path: 'folderPath' }, { header: 'deployed', path: 'isDeployed', format: 'bool' }, { header: 'runs', path: 'runCount' }, { header: 'last run', path: 'lastRunAt', format: 'timestamp' }, ], }, listFiles: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, @@ -204,9 +225,11 @@ export const CLI_CONTRACT: CliContract = { }, listTableRows: { expand: 'data' }, listKnowledgeBases: { + flags: { folderPath: FOLDER_PATH_FLAG }, columns: [ { header: 'id' }, { header: 'name' }, + { header: 'folder', path: 'folderPath' }, { header: 'docs', path: 'docCount' }, { header: 'tokens', path: 'tokenCount' }, { header: 'model', path: 'embeddingModel' }, @@ -250,14 +273,6 @@ export const CLI_CONTRACT: CliContract = { { 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' }, @@ -277,26 +292,30 @@ export const CLI_CONTRACT: CliContract = { }, // ─── The expanded files surface ─────────────────────────────────────────── - // Every one of these derives badly. `/files/move` and `/files/bulk-archive` + // Every one of these derives badly. `/files/move` and `/files/bulk-delete` // 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: { + bulkDeleteFiles: { // `batch-` for the bulk form, matching `tables rows batch-delete`. - command: 'files batch-archive', - describe: 'Archive several files and folders at once', + command: 'files batch-delete', + describe: 'Delete several files at once', flags: { fileIds: { list: true }, - folderIds: { list: true }, }, - confirm: 'This archives every listed file and folder, and everything inside those folders.', + confirm: 'This deletes every listed file.', + }, + getFile: { + command: 'files get', + describe: 'Show file metadata', }, moveFileItems: { command: 'files move', - describe: 'Move files and folders into another folder', + aliases: ['mv'], + describe: 'Move files into another folder', flags: { fileIds: { list: true }, - folderIds: { list: true }, + targetFolderPath: { name: 'to', describe: 'Destination folder path; omit for root' }, }, }, renameFile: { @@ -304,10 +323,6 @@ export const CLI_CONTRACT: CliContract = { 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', @@ -327,9 +342,89 @@ export const CLI_CONTRACT: CliContract = { }, }, + // ─── Resource-scoped, path-addressed folders ────────────────────────────── + listFileFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + listKnowledgeFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + listTableFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + listWorkflowFolders: { + aliases: ['ls'], + flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + columns: FOLDER_LIST_COLUMNS, + }, + createFileFolder: { positionals: ['path'], describe: 'Create a file folder at a path' }, + createKnowledgeFolder: { + positionals: ['path'], + describe: 'Create a knowledge folder at a path', + }, + createTableFolder: { positionals: ['path'], describe: 'Create a table folder at a path' }, + createWorkflowFolder: { + positionals: ['path'], + describe: 'Create a workflow folder at a path', + }, + relocateFileFolder: { + command: 'files folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a file folder', + }, + relocateKnowledgeFolder: { + command: 'knowledge folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a knowledge folder', + }, + relocateTableFolder: { + command: 'tables folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a table folder', + }, + relocateWorkflowFolder: { + command: 'workflows folders move', + aliases: ['mv'], + positionals: ['path', 'destinationPath'], + flags: { destinationPath: { name: 'destination' } }, + describe: 'Rename or move a workflow folder', + }, + deleteFileFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the file folder and, when recursive, everything inside it.', + }, + deleteKnowledgeFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', + }, + deleteTableFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the table folder and, when recursive, everything inside it.', + }, + deleteWorkflowFolder: { + positionals: ['path'], + flags: { recursive: { choices: ['true', 'false'] } }, + confirm: 'This archives the workflow folder and, when recursive, everything inside it.', + }, + // ─── 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 + // `/cancel-runs`, `/rows/find`, `/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', @@ -350,7 +445,6 @@ export const CLI_CONTRACT: CliContract = { itemsPath: 'matches', columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], }, - restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' }, runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 43dbc7cba80..6b0c857b0a0 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -76,6 +76,10 @@ export interface CommandSpec { * ` [sub-resource] ` name. */ command?: string + /** Alternate leaf command names, such as `ls` for `list`. */ + aliases?: readonly string[] + /** Required query/body fields exposed as positional arguments, in order. */ + positionals?: readonly 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. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index dc9cd5ad51e..51f82deec62 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -38,8 +38,7 @@ export type AbortFileUploadResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string @@ -208,18 +207,16 @@ export type AddWorkflowGroupResponse = { } } -/** `POST /api/v2/files/bulk-archive` */ -export type BulkArchiveFileItemsBody = { +/** `POST /api/v2/files/bulk-delete` */ +export type BulkDeleteFilesBody = { workspaceId: string - fileIds?: Array - folderIds?: Array + fileIds: Array } -export type BulkArchiveFileItemsResponse = { +export type BulkDeleteFilesResponse = { data: { deletedItems: { files: number - folders: number } } } @@ -281,7 +278,7 @@ export type CancelTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -376,8 +373,7 @@ export type CompleteFileUploadResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string @@ -476,7 +472,7 @@ export type CompleteTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -577,7 +573,7 @@ export type CreateFileBody = { workspaceId: string name: string contentType?: string - folderId?: string + folderPath?: string content?: string encoding?: 'utf-8' | 'base64' } @@ -589,21 +585,38 @@ export type CreateFileResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string } } +/** `POST /api/v2/files/folders` */ +export type CreateFileFolderBody = { + workspaceId: string + path: string +} + +export type CreateFileFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/files/uploads` */ export type CreateFileUploadBody = { workspaceId: string name: string contentType: string size: number - folderId?: string + folderPath?: string } export type CreateFileUploadResponse = { @@ -622,8 +635,7 @@ export type CreateFileUploadResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: string updatedAt: string @@ -672,31 +684,6 @@ export type CreateFileUploadPartUrlsResponse = { } } -/** `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 @@ -707,6 +694,7 @@ export type CreateKnowledgeBaseBody = { minSize?: number overlap?: number } + folderPath?: string } export type CreateKnowledgeBaseResponse = { @@ -734,6 +722,7 @@ export type CreateKnowledgeBaseResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string } } } @@ -830,6 +819,24 @@ export type CreateKnowledgeDocumentUploadPartUrlsResponse = { } } +/** `POST /api/v2/knowledge/folders` */ +export type CreateKnowledgeFolderBody = { + workspaceId: string + path: string +} + +export type CreateKnowledgeFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/mcp-servers` */ export type CreateMcpServerBody = { workspaceId: string @@ -916,7 +923,7 @@ export type CreateTableBody = { }> } workspaceId: string - folderId?: string | null + folderPath?: string } export type CreateTableResponse = { @@ -943,7 +950,7 @@ export type CreateTableResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -988,6 +995,24 @@ export type CreateTableExportResponse = { } } +/** `POST /api/v2/tables/folders` */ +export type CreateTableFolderBody = { + workspaceId: string + path: string +} + +export type CreateTableFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/tables/imports` */ export type CreateTableImportBody = { workspaceId: string @@ -1006,7 +1031,7 @@ export type CreateTableImportBody = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -1042,7 +1067,7 @@ export type CreateTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -1089,7 +1114,7 @@ export type CreateTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -1289,7 +1314,7 @@ export type CreateWorkflowBody = { workspaceId: string name: string description?: string | null - folderId?: string | null + folderPath?: string } export type CreateWorkflowResponse = { @@ -1297,7 +1322,7 @@ export type CreateWorkflowResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -1308,6 +1333,25 @@ export type CreateWorkflowResponse = { } } +/** `POST /api/v2/workflows/folders` */ +export type CreateWorkflowFolderBody = { + workspaceId: string + path: string +} + +export type CreateWorkflowFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean + } + } +} + /** `DELETE /api/v2/credentials/[id]` */ export type DeleteCredentialParams = { id: string @@ -1356,26 +1400,20 @@ export type DeleteFileResponse = { } } -/** `DELETE /api/v2/folders/[id]` */ -export type DeleteFolderParams = { - id: string -} - -export type DeleteFolderQuery = { +/** `DELETE /api/v2/files/folders` */ +export type DeleteFileFolderQuery = { workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' + path: string + recursive: string } -export type DeleteFolderResponse = { +export type DeleteFileFolderResponse = { data: { - id: string + path: string deleted: true - deletedItems?: { + deletedItems: { folders: number - workflows?: number - files?: number - knowledgeBases?: number - tables?: number + files: number } } } @@ -1413,6 +1451,24 @@ export type DeleteKnowledgeDocumentResponse = { } } +/** `DELETE /api/v2/knowledge/folders` */ +export type DeleteKnowledgeFolderQuery = { + workspaceId: string + path: string + recursive: string +} + +export type DeleteKnowledgeFolderResponse = { + data: { + path: string + deleted: true + deletedItems: { + folders: number + knowledgeBases: number + } + } +} + /** `DELETE /api/v2/mcp-servers/[id]` */ export type DeleteMcpServerParams = { id: string @@ -1489,6 +1545,24 @@ export type DeleteTableColumnResponse = { } } +/** `DELETE /api/v2/tables/folders` */ +export type DeleteTableFolderQuery = { + workspaceId: string + path: string + recursive: string +} + +export type DeleteTableFolderResponse = { + data: { + path: string + deleted: true + deletedItems: { + folders: number + tables: number + } + } +} + /** `DELETE /api/v2/tables/[tableId]/rows/[rowId]` */ export type DeleteTableRowParams = { tableId: string @@ -1555,6 +1629,24 @@ export type DeleteWorkflowResponse = { } } +/** `DELETE /api/v2/workflows/folders` */ +export type DeleteWorkflowFolderQuery = { + workspaceId: string + path: string + recursive: string +} + +export type DeleteWorkflowFolderResponse = { + data: { + path: string + deleted: true + deletedItems: { + folders: number + workflows: number + } + } +} + /** `DELETE /api/v2/tables/[tableId]/groups` */ export type DeleteWorkflowGroupParams = { tableId: string @@ -1695,7 +1787,7 @@ export type ExportWorkflowResponse = { name: string description: string | null workspaceId: string | null - folderId: string | null + folderPath: string } state: { blocks: Record< @@ -1935,6 +2027,29 @@ export type GetExecutionResponse = { } } +/** `GET /api/v2/files/[fileId]/metadata` */ +export type GetFileParams = { + fileId: string +} + +export type GetFileQuery = { + workspaceId: string +} + +export type GetFileResponse = { + data: { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `GET /api/v2/files/[fileId]/share` */ export type GetFileShareParams = { fileId: string @@ -1960,32 +2075,6 @@ export type GetFileShareResponse = { } } -/** `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 @@ -2020,6 +2109,7 @@ export type GetKnowledgeBaseResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string } } } @@ -2078,7 +2168,7 @@ export type GetLogResponse = { id: string | null name: string description: string | null - folderId: string | null + folderPath: string | null userId: string | null workspaceId: string | null createdAt: string | null @@ -2185,7 +2275,7 @@ export type GetTableResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -2258,7 +2348,7 @@ export type GetTableImportResponse = { | { type: 'new' name: string - folderId?: string + folderPath?: string } | { type: 'existing' @@ -2420,7 +2510,7 @@ export type GetWorkflowResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -2511,10 +2601,10 @@ export type GetWorkflowVersionResponse = { /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string - folderId?: string + workflow: string | Record + folderPath?: string name?: string description?: string - workflow: string | Record } export type ImportWorkflowResponse = { @@ -2523,7 +2613,7 @@ export type ImportWorkflowResponse = { name: string description: string | null workspaceId: string - folderId: string | null + folderPath: string createdAt: string updatedAt: string } @@ -2619,55 +2709,48 @@ export type ListCustomToolsResponse = { nextCursor: string | null } -/** `GET /api/v2/files` */ -export type ListFilesQuery = { +/** `GET /api/v2/files/folders` */ +export type ListFileFoldersQuery = { workspaceId: string - scope?: 'active' | 'archived' - folderId?: string + parentPath?: string search?: string - sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' - limit?: number - cursor?: string } -export type ListFilesResponse = { +export type ListFileFoldersResponse = { data: Array<{ - id: string name: string - size: number - type: string - key: string - folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string + path: string + parentPath: string + createdAt: string updatedAt: string }> nextCursor: string | null } -/** `GET /api/v2/folders` */ -export type ListFoldersQuery = { +/** `GET /api/v2/files` */ +export type ListFilesQuery = { workspaceId: string - resourceType: 'workflow' | 'knowledge_base' | 'table' - scope?: 'active' | 'archived' + folderPath?: string search?: string - sortBy?: 'position' | 'name' | 'createdAt' | 'updatedAt' + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string } -export type ListFoldersResponse = { +export type ListFilesResponse = { data: Array<{ id: string - resourceType: 'workflow' | 'file' | 'knowledge_base' | 'table' name: string - parentId: string | null - locked: boolean - sortOrder: number - createdAt: string + size: number + type: string + key: string + folderPath: string + uploadedBy: string + uploadedAt: string updatedAt: string - deletedAt: string | null }> nextCursor: string | null } @@ -2675,7 +2758,7 @@ export type ListFoldersResponse = { /** `GET /api/v2/knowledge` */ export type ListKnowledgeBasesQuery = { workspaceId: string - folderId?: string + folderPath?: string search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' @@ -2705,6 +2788,7 @@ export type ListKnowledgeBasesResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string }> nextCursor: string | null } @@ -2748,11 +2832,30 @@ export type ListKnowledgeDocumentsResponse = { nextCursor: string | null } +/** `GET /api/v2/knowledge/folders` */ +export type ListKnowledgeFoldersQuery = { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListKnowledgeFoldersResponse = { + data: Array<{ + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/logs` */ export type ListLogsQuery = { workspaceId: string workflowIds?: string - folderIds?: string triggers?: string level?: 'info' | 'error' startDate?: string @@ -2769,6 +2872,7 @@ export type ListLogsQuery = { limit?: number cursor?: string order?: 'desc' | 'asc' + folderPaths?: string } export type ListLogsResponse = { @@ -2852,6 +2956,26 @@ export type ListSkillsResponse = { nextCursor: string | null } +/** `GET /api/v2/tables/folders` */ +export type ListTableFoldersQuery = { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListTableFoldersResponse = { + data: Array<{ + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/rows` */ export type ListTableRowsParams = { tableId: string @@ -2876,7 +3000,7 @@ export type ListTableRowsResponse = { /** `GET /api/v2/tables` */ export type ListTablesQuery = { workspaceId: string - folderId?: string + folderPath?: string search?: string sortBy?: 'name' | 'createdAt' | 'updatedAt' sortOrder?: 'asc' | 'desc' @@ -2907,7 +3031,7 @@ export type ListTablesResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -3064,6 +3188,27 @@ export type ListUsageLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/folders` */ +export type ListWorkflowFoldersQuery = { + workspaceId: string + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListWorkflowFoldersResponse = { + data: Array<{ + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/tables/[tableId]/groups` */ export type ListWorkflowGroupsParams = { tableId: string @@ -3102,7 +3247,7 @@ export type ListWorkflowGroupsResponse = { /** `GET /api/v2/workflows` */ export type ListWorkflowsQuery = { workspaceId: string - folderId?: string + folderPath?: string deployedOnly?: boolean limit?: number cursor?: string @@ -3116,7 +3261,7 @@ export type ListWorkflowsResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -3155,16 +3300,14 @@ export type ListWorkflowVersionsResponse = { /** `POST /api/v2/files/move` */ export type MoveFileItemsBody = { workspaceId: string - fileIds?: Array - folderIds?: Array - targetFolderId?: string | null + fileIds: Array + targetFolderPath?: string } export type MoveFileItemsResponse = { data: { movedItems: { files: number - folders: number } } } @@ -3195,100 +3338,107 @@ export type QueryRowsResponse = { nextCursor: string | null } -/** `PATCH /api/v2/files/[fileId]` */ -export type RenameFileParams = { - fileId: string -} - -export type RenameFileBody = { +/** `PATCH /api/v2/files/folders` */ +export type RelocateFileFolderBody = { workspaceId: string - name: string + path: string + destinationPath: string } -export type RenameFileResponse = { +export type RelocateFileFolderResponse = { data: { - id: string - name: string - size: number - type: string - key: string - folderId: string | null - folderPath: string | null - uploadedBy: string - uploadedAt: string - updatedAt: string + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } } } -/** `POST /api/v2/files/[fileId]/restore` */ -export type RestoreFileParams = { - fileId: string -} - -export type RestoreFileBody = { +/** `PATCH /api/v2/knowledge/folders` */ +export type RelocateKnowledgeFolderBody = { workspaceId: string + path: string + destinationPath: string } -export type RestoreFileResponse = { +export type RelocateKnowledgeFolderResponse = { data: { - id: string - restored: true - } -} - -/** `POST /api/v2/tables/[tableId]/restore` */ -export type RestoreTableParams = { - tableId: string + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + } + } } -export type RestoreTableBody = { +/** `PATCH /api/v2/tables/folders` */ +export type RelocateTableFolderBody = { workspaceId: string + path: string + destinationPath: string } -export type RestoreTableResponse = { +export type RelocateTableFolderResponse = { data: { - table: { - id: string + folder: { 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 + path: string + parentPath: string createdAt: string updatedAt: string } } } +/** `PATCH /api/v2/workflows/folders` */ +export type RelocateWorkflowFolderBody = { + workspaceId: string + path: string + destinationPath: string +} + +export type RelocateWorkflowFolderResponse = { + data: { + folder: { + name: string + path: string + parentPath: string + createdAt: string + updatedAt: string + locked: boolean + } + } +} + +/** `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 + folderPath: string + uploadedBy: string + uploadedAt: string + updatedAt: string + } +} + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -3563,44 +3713,13 @@ export type UpdateFileContentResponse = { size: number type: string key: string - folderId: string | null - folderPath: string | null + folderPath: string uploadedBy: string uploadedAt: 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 @@ -3611,10 +3730,11 @@ export type UpdateKnowledgeBaseBody = { name?: string description?: string chunkingConfig?: { - maxSize: number - minSize: number - overlap: number + maxSize?: number + minSize?: number + overlap?: number } + folderPath?: string } export type UpdateKnowledgeBaseResponse = { @@ -3642,6 +3762,7 @@ export type UpdateKnowledgeBaseResponse = { connectorTypes?: Array createdAt: string updatedAt: string + folderPath: string } } } @@ -3746,7 +3867,7 @@ export type UpdateTableParams = { export type UpdateTableBody = { workspaceId: string name?: string - folderId?: string | null + folderPath?: string } export type UpdateTableResponse = { @@ -3773,7 +3894,7 @@ export type UpdateTableResponse = { } rowCount: number maxRows: number - folderId: string | null + folderPath: string locks: { schemaLocked: boolean insertLocked: boolean @@ -3985,7 +4106,7 @@ export type UpdateWorkflowParams = { export type UpdateWorkflowBody = { name?: string description?: string | null - folderId?: string | null + folderPath?: string } export type UpdateWorkflowResponse = { @@ -3993,7 +4114,7 @@ export type UpdateWorkflowResponse = { id: string name: string description: string | null - folderId: string | null + folderPath: string workspaceId: string isDeployed: boolean deployedAt: string | null @@ -4220,16 +4341,15 @@ export const V2_OPERATIONS = { autoRun: { kind: 'boolean', default: false }, }, }, - bulkArchiveFileItems: { + bulkDeleteFiles: { method: 'POST', - path: '/api/v2/files/bulk-archive', + path: '/api/v2/files/bulk-delete', pathParams: [] as const, responseMode: 'json', - summary: 'Archive Files and Folders', + summary: 'Delete Files', body: { workspaceId: { kind: 'string', required: true }, - fileIds: { kind: 'array', default: [] }, - folderIds: { kind: 'array', default: [] }, + fileIds: { kind: 'array', required: true }, }, }, cancelTableExport: { @@ -4357,11 +4477,22 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, name: { kind: 'string', required: true }, contentType: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, content: { kind: 'string', default: '' }, encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, }, }, + createFileFolder: { + method: 'POST', + path: '/api/v2/files/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, createFileUpload: { method: 'POST', path: '/api/v2/files/uploads', @@ -4373,7 +4504,7 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true }, contentType: { kind: 'string', required: true }, size: { kind: 'integer', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, createFileUploadPartUrls: { @@ -4389,24 +4520,6 @@ export const V2_OPERATIONS = { partNumbers: { kind: 'array', 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', @@ -4418,6 +4531,7 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true }, description: { kind: 'string' }, chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + folderPath: { kind: 'string' }, }, }, createKnowledgeDocumentUpload: { @@ -4454,6 +4568,17 @@ export const V2_OPERATIONS = { partNumbers: { kind: 'array', required: true }, }, }, + createKnowledgeFolder: { + method: 'POST', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, createMcpServer: { method: 'POST', path: '/api/v2/mcp-servers', @@ -4499,7 +4624,7 @@ export const V2_OPERATIONS = { description: { kind: 'string' }, schema: { kind: 'object', required: true }, workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, createTableExport: { @@ -4513,6 +4638,17 @@ export const V2_OPERATIONS = { format: { kind: 'enum', values: ['csv', 'json'] as const, default: 'csv' }, }, }, + createTableFolder: { + method: 'POST', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + }, + }, createTableImport: { method: 'POST', path: '/api/v2/tables/imports', @@ -4574,7 +4710,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, name: { kind: 'string', required: true }, description: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, + }, + }, + createWorkflowFolder: { + method: 'POST', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, }, }, deleteCredential: { @@ -4607,19 +4754,16 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - deleteFolder: { + deleteFileFolder: { method: 'DELETE', - path: '/api/v2/folders/[id]', - pathParams: ['id'] as const, + path: '/api/v2/files/folders', + pathParams: [] 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, - }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, }, }, deleteKnowledgeBase: { @@ -4642,6 +4786,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteKnowledgeFolder: { + method: 'DELETE', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, + }, + }, deleteMcpServer: { method: 'DELETE', path: '/api/v2/mcp-servers/[id]', @@ -4683,6 +4839,18 @@ export const V2_OPERATIONS = { columnName: { kind: 'string', required: true }, }, }, + deleteTableFolder: { + method: 'DELETE', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, + }, + }, deleteTableRow: { method: 'DELETE', path: '/api/v2/tables/[tableId]/rows/[rowId]', @@ -4723,6 +4891,18 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Delete Workflow', }, + deleteWorkflowFolder: { + method: 'DELETE', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Delete Folder', + query: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + recursive: { kind: 'string', required: true }, + }, + }, deleteWorkflowGroup: { method: 'DELETE', path: '/api/v2/tables/[tableId]/groups', @@ -4822,29 +5002,24 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Execution', }, - getFileShare: { + getFile: { method: 'GET', - path: '/api/v2/files/[fileId]/share', + path: '/api/v2/files/[fileId]/metadata', pathParams: ['fileId'] as const, responseMode: 'json', - summary: 'Get File Share', + summary: 'Get File Metadata', query: { workspaceId: { kind: 'string', required: true }, }, }, - getFolder: { + getFileShare: { method: 'GET', - path: '/api/v2/folders/[id]', - pathParams: ['id'] as const, + path: '/api/v2/files/[fileId]/share', + pathParams: ['fileId'] as const, responseMode: 'json', - summary: 'Get Folder', + summary: 'Get File Share', query: { workspaceId: { kind: 'string', required: true }, - resourceType: { - kind: 'enum', - required: true, - values: ['workflow', 'knowledge_base', 'table'] as const, - }, }, }, getKnowledgeBase: { @@ -4987,10 +5162,10 @@ export const V2_OPERATIONS = { summary: 'Import a workflow', body: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + workflow: { kind: 'unknown', required: true }, + folderPath: { kind: 'string' }, name: { kind: 'string' }, description: { kind: 'string' }, - workflow: { kind: 'unknown', required: true }, }, }, listAuditLogs: { @@ -5051,48 +5226,42 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, - listFiles: { + listFileFolders: { method: 'GET', - path: '/api/v2/files', + path: '/api/v2/files/folders', pathParams: [] as const, responseMode: 'json', - summary: 'List Files', + summary: 'List Folders', query: { workspaceId: { kind: 'string', required: true }, - scope: { kind: 'enum', values: ['active', 'archived'] as const, default: 'active' }, - folderId: { kind: 'string' }, + parentPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', - values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, - default: 'uploadedAt', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', }, sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, - limit: { kind: 'number', default: 100 }, - cursor: { kind: 'string' }, }, }, - listFolders: { + listFiles: { method: 'GET', - path: '/api/v2/folders', + path: '/api/v2/files', pathParams: [] as const, responseMode: 'json', - summary: 'List Folders', + summary: 'List Files', 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' }, + folderPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', - values: ['position', 'name', 'createdAt', 'updatedAt'] as const, - default: 'position', + 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' }, }, }, listKnowledgeBases: { @@ -5103,7 +5272,7 @@ export const V2_OPERATIONS = { summary: 'List Knowledge Bases', query: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', @@ -5145,6 +5314,24 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listKnowledgeFolders: { + method: 'GET', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listLogs: { method: 'GET', path: '/api/v2/logs', @@ -5154,7 +5341,6 @@ export const V2_OPERATIONS = { 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' }, @@ -5171,6 +5357,7 @@ export const V2_OPERATIONS = { limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + folderPaths: { kind: 'string' }, }, }, listMcpServers: { @@ -5207,6 +5394,24 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, + listTableFolders: { + method: 'GET', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listTableRows: { method: 'GET', path: '/api/v2/tables/[tableId]/rows', @@ -5227,7 +5432,7 @@ export const V2_OPERATIONS = { summary: 'List Tables', query: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, search: { kind: 'string' }, sortBy: { kind: 'enum', @@ -5283,6 +5488,24 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listWorkflowFolders: { + method: 'GET', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Folders', + query: { + workspaceId: { kind: 'string', required: true }, + parentPath: { kind: 'string' }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listWorkflowGroups: { method: 'GET', path: '/api/v2/tables/[tableId]/groups', @@ -5301,7 +5524,7 @@ export const V2_OPERATIONS = { summary: 'List Workflows', query: { workspaceId: { kind: 'string', required: true }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, deployedOnly: { kind: 'boolean' }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, @@ -5333,9 +5556,8 @@ export const V2_OPERATIONS = { summary: 'Move Files and Folders', body: { workspaceId: { kind: 'string', required: true }, - fileIds: { kind: 'array', default: [] }, - folderIds: { kind: 'array', default: [] }, - targetFolderId: { kind: 'string' }, + fileIds: { kind: 'array', required: true }, + targetFolderPath: { kind: 'string' }, }, }, queryRows: { @@ -5352,35 +5574,63 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, - renameFile: { + relocateFileFolder: { method: 'PATCH', - path: '/api/v2/files/[fileId]', - pathParams: ['fileId'] as const, + path: '/api/v2/files/folders', + pathParams: [] as const, responseMode: 'json', - summary: 'Rename File', + summary: 'Rename or Move Folder', body: { workspaceId: { kind: 'string', required: true }, - name: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, }, }, - restoreFile: { - method: 'POST', - path: '/api/v2/files/[fileId]/restore', - pathParams: ['fileId'] as const, + relocateKnowledgeFolder: { + method: 'PATCH', + path: '/api/v2/knowledge/folders', + pathParams: [] as const, responseMode: 'json', - summary: 'Restore File', + summary: 'Rename or Move Folder', body: { workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, }, }, - restoreTable: { - method: 'POST', - path: '/api/v2/tables/[tableId]/restore', - pathParams: ['tableId'] as const, + relocateTableFolder: { + method: 'PATCH', + path: '/api/v2/tables/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + relocateWorkflowFolder: { + method: 'PATCH', + path: '/api/v2/workflows/folders', + pathParams: [] as const, + responseMode: 'json', + summary: 'Rename or Move Folder', + body: { + workspaceId: { kind: 'string', required: true }, + path: { kind: 'string', required: true }, + destinationPath: { kind: 'string', required: true }, + }, + }, + renameFile: { + method: 'PATCH', + path: '/api/v2/files/[fileId]', + pathParams: ['fileId'] as const, responseMode: 'json', - summary: 'Restore Table', + summary: 'Rename File', body: { workspaceId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, }, }, rollbackWorkflow: { @@ -5494,25 +5744,6 @@ export const V2_OPERATIONS = { encoding: { kind: 'enum', values: ['utf-8', 'base64'] as const, default: 'utf-8' }, }, }, - 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]', @@ -5523,7 +5754,8 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, name: { kind: 'string' }, description: { kind: 'string' }, - chunkingConfig: { kind: 'object' }, + chunkingConfig: { kind: 'object', default: { maxSize: 1024, minSize: 100, overlap: 200 } }, + folderPath: { kind: 'string' }, }, }, updateMcpServer: { @@ -5582,7 +5814,7 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, name: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, updateTableColumn: { @@ -5631,7 +5863,7 @@ export const V2_OPERATIONS = { body: { name: { kind: 'string' }, description: { kind: 'string' }, - folderId: { kind: 'string' }, + folderPath: { kind: 'string' }, }, }, updateWorkflowGroup: { diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 0b76fd3e1a9..fb3ee4dc17e 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -75,7 +75,6 @@ describe('commands parsed through commander', () => { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', - folders: 'folder', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -90,6 +89,7 @@ describe('commands parsed through commander', () => { ?.alias() ).toBe(alias) } + expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) it('dispatches generated commands through their singular resource alias', async () => { @@ -155,24 +155,73 @@ describe('commands parsed through commander', () => { }) }) - it('accepts space-separated file and folder ids', async () => { + it('exposes the v2 file metadata route as files get', async () => { + const [path, options] = await run(['file', 'get', 'file_1'], { data: { id: 'file_1' } }) + expect(path).toBe('/api/v2/files/file_1/metadata') + expect(options.query).toEqual({ workspaceId: 'ws_local' }) + }) + + it('moves space-separated file ids to a folder path', async () => { const [path, options] = await run([ 'file', - 'move', + 'mv', '--file-ids', 'file_1', 'file_2', - '--folder-ids', - 'folder_1', - '--target-folder-id', - 'folder_2', + '--to', + '/Archive', ]) expect(path).toBe('/api/v2/files/move') expect(options.body).toEqual({ workspaceId: 'ws_local', fileIds: ['file_1', 'file_2'], - folderIds: ['folder_1'], - targetFolderId: 'folder_2', + targetFolderPath: '/Archive', + }) + }) + + it('uses mv as the resource move alias', async () => { + const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', '/Archive']) + expect(path).toBe('/api/v2/tables/tbl_1') + expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: '/Archive' }) + }) + + it('exposes path-addressed folder commands under each resource', async () => { + const [createPath, createOptions] = await run(['table', 'folders', 'create', '/Reports']) + expect(createPath).toBe('/api/v2/tables/folders') + expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: '/Reports' }) + + const [movePath, moveOptions] = await run([ + 'table', + 'folders', + 'mv', + '/Reports', + '/Archive/Reports', + ]) + expect(movePath).toBe('/api/v2/tables/folders') + expect(moveOptions.body).toEqual({ + workspaceId: 'ws_local', + path: '/Reports', + destinationPath: '/Archive/Reports', + }) + + const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', '/']) + expect(listPath).toBe('/api/v2/tables/folders') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/' }) + + const [deletePath, deleteOptions] = await run([ + 'table', + 'folders', + 'delete', + '/Archive/Reports', + '--recursive', + 'false', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/tables/folders') + expect(deleteOptions.query).toEqual({ + workspaceId: 'ws_local', + path: '/Archive/Reports', + recursive: 'false', }) }) @@ -246,7 +295,7 @@ describe('commands parsed through commander', () => { it('documents space-separated and file-backed lists', () => { const help = commandAt('files', 'move').helpInformation() expect(help).toContain('--file-ids ') - expect(help).toMatch(/space-separated.*@path.*one value per line/s) + expect(help).toMatch(/space-separated.*@path.*one\s+value\s+per\s+line/s) }) it('advertises the file-content encoding choices', () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index bbcf7ae6534..6bb6210cd43 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -5,6 +5,7 @@ import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { deriveCommandPath } from './derive.js' import { executeOperation } from './execute.js' import { addOperationOptions } from './options.js' +import { flagNameFor } from './request.js' import type { OperationSpec } from './types.js' const GROUP_ALIASES: Readonly> = { @@ -12,7 +13,6 @@ const GROUP_ALIASES: Readonly> = { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', - folders: 'folder', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -24,10 +24,19 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri const operationSpec = V2_OPERATIONS[operation] as OperationSpec const command = new Command(leafName).allowExcessArguments(false) + for (const alias of spec.aliases ?? []) command.alias(alias) + for (const param of operationSpec.pathParams) { command.argument(`<${param}>`) } + for (const field of spec.positionals ?? []) { + const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] + if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) + if (!descriptor.required) throw new Error(`${operation}.${field} is not required`) + command.argument(`<${flagNameFor(operation, field)}>`) + } + command.description( spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` ) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 0454c896319..1d03ed4f64b 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -3,8 +3,9 @@ import { clientFrom } from '../context.js' import type { CommandSpec } from '../contract/types.js' import type { V2OperationName } from '../generated/v2-api.js' import { SimApiError, type V2Page } from '../http/client.js' +import { camel } from './derive.js' import { DEFAULT_LIMIT } from './options.js' -import { buildRequest, PROFILE_INJECTED_FIELD } from './request.js' +import { buildRequest, flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' import { renderPage, renderResult } from './result.js' import type { OperationSpec } from './types.js' @@ -24,8 +25,13 @@ export async function executeOperation( 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[] + const requestFlags = { ...flags } + for (const [index, field] of (commandSpec.positionals ?? []).entries()) { + requestFlags[camel(flagNameFor(operation, field))] = + invocation[operationSpec.pathParams.length + index] + } - if (commandSpec.confirm && !flags.yes) { + if (commandSpec.confirm && !requestFlags.yes) { throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) } @@ -37,13 +43,13 @@ export async function executeOperation( const request = buildRequest( operation, positional, - flags, + requestFlags, needsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) if (paging) { - const rawLimit = Number.parseInt(String(flags.limit ?? DEFAULT_LIMIT), 10) + const rawLimit = Number.parseInt(String(requestFlags.limit ?? DEFAULT_LIMIT), 10) if (Number.isNaN(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a non-negative number', 0) } diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 9c5ab175618..f085b0ee468 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -85,6 +85,7 @@ export function addOperationOptions( ): void { for (const slot of ['query', 'body'] as const) { for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if (commandSpec.positionals?.includes(field)) continue addFieldOption(command, operation, field, descriptor) } } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 7883cebd5f7..6c805040f93 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -172,7 +172,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: * encoding follows the field's own kind, because the two are not the same * question: * - * - `string` — the route splits on commas (`workflowIds`, `folderIds`, + * - `string` — the route splits on commas (`workflowIds`, `folderPaths`, * `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 From d23349157a806af07063f1fd588b488114ec5f7a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:49:07 -0700 Subject: [PATCH 34/46] feat(cli): add resource mkdir commands --- packages/sim-cli/README.md | 8 ++-- .../sim-cli/src/commands/protocol/index.ts | 14 ++++--- ...-ls.test.ts => resource-directory.test.ts} | 27 +++++++++++- .../{resource-ls.ts => resource-directory.ts} | 41 +++++++++++++++++-- 4 files changed, 76 insertions(+), 14 deletions(-) rename packages/sim-cli/src/commands/protocol/{resource-ls.test.ts => resource-directory.test.ts} (78%) rename packages/sim-cli/src/commands/protocol/{resource-ls.ts => resource-directory.ts} (81%) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 7f1cc5316cf..6798ff6a0a4 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -161,14 +161,16 @@ Each folder-backed resource has the same path commands: ```bash sim tables folders ls --parent /Reports +sim tables mkdir /Reports/Quarterly sim tables folders create /Reports/Quarterly sim tables folders mv /Reports/Quarterly /Archive/Quarterly sim tables folders delete /Archive/Quarterly --recursive false --yes ``` -Replace `tables` with `files`, `workflows`, or `knowledge`. Paths are canonical, -start with `/`, and use `/` for root. A slash that belongs to a folder name is -percent-encoded as `%2F` rather than treated as a separator. +`mkdir` is the concise form of `folders create`. Replace `tables` with `files`, +`workflows`, or `knowledge`. Paths are canonical, start with `/`, and use `/` for +root. A slash that belongs to a folder name is percent-encoded as `%2F` rather +than treated as a separator. ### List inputs diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index b939f0a6f74..67df42a865b 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' -import { attachResourceList } from './resource-ls.js' +import { attachResourceDirectoryCommands } from './resource-directory.js' import { attachTableImport } from './tables-import.js' function group(program: Command, name: string): Command { @@ -18,31 +18,35 @@ export function attachProtocolCommands(program: Command): void { const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) - attachResourceList(files, { + attachResourceDirectoryCommands(files, { kind: 'file', resources: 'listFiles', folders: 'listFileFolders', + createFolder: 'createFileFolder', }) const knowledge = group(program, 'knowledge') attachKnowledgeDocumentUpload(group(knowledge, 'documents')) - attachResourceList(knowledge, { + attachResourceDirectoryCommands(knowledge, { kind: 'knowledge', resources: 'listKnowledgeBases', folders: 'listKnowledgeFolders', + createFolder: 'createKnowledgeFolder', }) const tables = group(program, 'tables') attachTableImport(tables) - attachResourceList(tables, { + attachResourceDirectoryCommands(tables, { kind: 'table', resources: 'listTables', folders: 'listTableFolders', + createFolder: 'createTableFolder', }) - attachResourceList(group(program, 'workflows'), { + attachResourceDirectoryCommands(group(program, 'workflows'), { kind: 'workflow', resources: 'listWorkflows', folders: 'listWorkflowFolders', + createFolder: 'createWorkflowFolder', }) } diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts similarity index 78% rename from packages/sim-cli/src/commands/protocol/resource-ls.test.ts rename to packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 41bdc81d4cf..18a62787ef4 100644 --- a/packages/sim-cli/src/commands/protocol/resource-ls.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -34,11 +34,12 @@ beforeEach(() => { output.format = 'json' }) -describe('resource ls', () => { - it('is available for every folder-backed resource', () => { +describe('resource directory', () => { + it('makes ls and mkdir available for every folder-backed resource', () => { for (const resource of ['files', 'knowledge', 'tables', 'workflows']) { const group = program().commands.find((command) => command.name() === resource) expect(group?.commands.some((command) => command.name() === 'ls')).toBe(true) + expect(group?.commands.some((command) => command.name() === 'mkdir')).toBe(true) } }) @@ -124,4 +125,26 @@ describe('resource ls', () => { }, ]) }) + + it('creates a folder through the generated resource operation', async () => { + mockRequest.mockResolvedValue({ + data: { + folder: { + name: 'Quarterly', + path: '/Reports/Quarterly', + parentPath: '/Reports', + createdAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'table', 'mkdir', '/Reports/Quarterly']) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { + method: 'POST', + body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' }, + }) + }) }) diff --git a/packages/sim-cli/src/commands/protocol/resource-ls.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts similarity index 81% rename from packages/sim-cli/src/commands/protocol/resource-ls.ts rename to packages/sim-cli/src/commands/protocol/resource-directory.ts index 1bae5dd23e5..c842d88d460 100644 --- a/packages/sim-cli/src/commands/protocol/resource-ls.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -15,6 +15,7 @@ import { import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' +import { renderResult } from '../../runtime/result.js' type FolderListOperation = | 'listFileFolders' @@ -43,14 +44,30 @@ interface DirectoryEntry { } type ResourceDirectoryConfig = - | { kind: 'file'; resources: 'listFiles'; folders: 'listFileFolders' } + | { + kind: 'file' + resources: 'listFiles' + folders: 'listFileFolders' + createFolder: 'createFileFolder' + } | { kind: 'knowledge' resources: 'listKnowledgeBases' folders: 'listKnowledgeFolders' + createFolder: 'createKnowledgeFolder' + } + | { + kind: 'table' + resources: 'listTables' + folders: 'listTableFolders' + createFolder: 'createTableFolder' + } + | { + kind: 'workflow' + resources: 'listWorkflows' + folders: 'listWorkflowFolders' + createFolder: 'createWorkflowFolder' } - | { kind: 'table'; resources: 'listTables'; folders: 'listTableFolders' } - | { kind: 'workflow'; resources: 'listWorkflows'; folders: 'listWorkflowFolders' } interface ListOptions { folder: string @@ -141,7 +158,10 @@ function entriesFor( ) } -export function attachResourceList(group: Command, config: ResourceDirectoryConfig): void { +export function attachResourceDirectoryCommands( + group: Command, + config: ResourceDirectoryConfig +): void { group .command('ls') .description(`List ${config.kind} resources and child folders together`) @@ -168,4 +188,17 @@ export function attachResourceList(group: Command, config: ResourceDirectoryConf const entries = entriesFor(config, folders, resources) printList(profile.output, entries.slice(0, limit), COLUMNS) }) + + group + .command('mkdir ') + .description(`Create a ${config.kind} directory at a canonical path`) + .action(async (path: string, _options: Record, command: Command) => { + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS[config.createFolder] + const result = await client.request<{ data?: unknown }>(operation.path, { + method: operation.method, + body: { workspaceId: client.requireWorkspace(), path }, + }) + renderResult(config.createFolder, profile.output, result.data ?? result, {}) + }) } From 936ac603bcd9b5ff9ff543d8e33f1be462384f3e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 23:54:30 -0700 Subject: [PATCH 35/46] fix(cli): accept positional folder paths --- packages/sim-cli/README.md | 61 ++++++++-------- .../commands/protocol/files-upload.test.ts | 2 +- .../src/commands/protocol/files-upload.ts | 5 +- .../protocol/resource-directory.test.ts | 25 ++++--- .../commands/protocol/resource-directory.ts | 16 +++-- .../commands/protocol/tables-import.test.ts | 2 +- .../src/commands/protocol/tables-import.ts | 9 ++- packages/sim-cli/src/contract/commands.ts | 72 ++++++++++++++----- packages/sim-cli/src/contract/types.ts | 2 + packages/sim-cli/src/runtime/build.test.ts | 16 ++--- packages/sim-cli/src/runtime/folder-path.ts | 7 ++ packages/sim-cli/src/runtime/request.ts | 6 +- 12 files changed, 145 insertions(+), 78 deletions(-) create mode 100644 packages/sim-cli/src/runtime/folder-path.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 6798ff6a0a4..d5fbcc96b0f 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -113,64 +113,67 @@ also accepts its singular form: for example, `sim table list`, spellings. ```bash -sim workflows ls [--folder ] [--search ] [--limit ] -sim workflows list [--folder ] [--deployed-only] [--limit ] +sim workflows ls [path] [--search ] [--limit ] +sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get -sim workflows mv --folder +sim workflows mv --folder sim workflows deploy|undeploy|rollback sim logs list [--level error] [--workflow …] [--trigger …] [--start ] sim logs get sim logs execution -sim tables ls [--folder ] [--search ] [--limit ] -sim tables list [--folder ] +sim tables ls [path] [--search ] [--limit ] +sim tables list [--folder ] sim tables get -sim tables mv --folder +sim tables mv --folder sim tables columns sim tables rows list [--limit ] sim tables rows query [--filter ] [--sort ] [--limit ] sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes -sim files ls [--folder ] [--search ] [--limit ] -sim files list [--folder ] +sim files ls [path] [--search ] [--limit ] +sim files list [--folder ] sim files get -sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] -sim files upload [--name ] [--folder ] +sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] +sim files upload [--name ] [--folder ] sim files download [-o ] -sim files mv --file-ids … [--to ] +sim files mv --file-ids … [--to ] sim files batch-delete --file-ids … --yes sim files delete -sim knowledge ls [--folder ] [--search ] [--limit ] -sim knowledge list [--folder ] +sim knowledge ls [path] [--search ] [--limit ] +sim knowledge list [--folder ] sim knowledge get -sim knowledge mv --folder +sim knowledge mv --folder sim knowledge documents [--search ] sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] ``` -`ls` is a directory view: it combines the resources at `--folder` with that -folder's direct child folders. Its `ref` column is the resource ID or canonical -folder path to pass to the next command. Use `list` when you want resources only, -or `folders ls` when you want folders only. +`ls` is a directory view: it combines the resources at its optional path with +that folder's direct child folders. It never includes deeper descendants. Its +`ref` column is the resource ID or canonical folder path to pass to the next +command. Use `list` when you want resources only, or `folders ls` when you want +folders only. Each folder-backed resource has the same path commands: ```bash -sim tables folders ls --parent /Reports -sim tables mkdir /Reports/Quarterly -sim tables folders create /Reports/Quarterly -sim tables folders mv /Reports/Quarterly /Archive/Quarterly -sim tables folders delete /Archive/Quarterly --recursive false --yes +sim tables ls Reports +sim tables folders ls --parent Reports +sim tables mkdir Reports/Quarterly +sim tables folders create Reports/Quarterly +sim tables folders mv Reports/Quarterly Archive/Quarterly +sim tables folders delete Archive/Quarterly --recursive false --yes ``` `mkdir` is the concise form of `folders create`. Replace `tables` with `files`, -`workflows`, or `knowledge`. Paths are canonical, start with `/`, and use `/` for -root. A slash that belongs to a folder name is percent-encoded as `%2F` rather -than treated as a separator. +`workflows`, or `knowledge`. The leading `/` is optional on CLI inputs; the CLI +adds it before calling the API. Omit the `ls` path to list root. A slash that +belongs to a folder name is percent-encoded as `%2F` rather than treated as a +separator. ### List inputs @@ -178,9 +181,9 @@ Primitive lists take space-separated values. Prefix a path with `@` to read one value per line, or use `@-` to read the list from stdin. ```bash -sim files mv --file-ids file_1 file_2 --to /Archive -sim files mv --file-ids @file-ids.txt --to /Archive -printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to /Archive +sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids @file-ids.txt --to Archive +printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive ``` Arrays of objects remain JSON inputs because they cannot be represented as a diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 53ec1f7cbb3..8ddffe560a4 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -95,7 +95,7 @@ describe('files upload', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', '/Reports']) + await program().parseAsync(['node', 'sim', 'file', 'upload', path, '--folder', 'Reports']) expect(fetchMock).toHaveBeenCalledWith( 'https://storage.example/file', diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 627e35b12a5..e8837c57c09 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -4,6 +4,7 @@ import type { CompleteFileUploadResponse, CreateFileUploadResponse, } from '../../generated/v2-api.js' +import { normalizeFolderPath } from '../../runtime/folder-path.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' @@ -26,7 +27,9 @@ export function attachFileUpload(files: Command): void { name, contentType: contentTypeFor(name), size, - ...(options.folder ? { folderPath: options.folder } : {}), + ...(options.folder !== undefined + ? { folderPath: normalizeFolderPath(options.folder) } + : {}), }, }) const { session, uploadToken, transfer } = created.data diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 18a62787ef4..9e2914428da 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -25,6 +25,11 @@ function program(): Command { const root = new Command('sim').exitOverride() for (const group of buildGeneratedCommands()) root.addCommand(group) attachProtocolCommands(root) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) return root } @@ -77,16 +82,7 @@ describe('resource directory', () => { const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) - await program().parseAsync([ - 'node', - 'sim', - 'table', - 'ls', - '--folder', - '/Reports', - '--search', - 'r', - ]) + await program().parseAsync(['node', 'sim', 'table', 'ls', 'Reports', '--search', 'r']) expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { query: { @@ -140,11 +136,18 @@ describe('resource directory', () => { }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await program().parseAsync(['node', 'sim', 'table', 'mkdir', '/Reports/Quarterly']) + await program().parseAsync(['node', 'sim', 'table', 'mkdir', 'Reports/Quarterly']) expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { method: 'POST', body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' }, }) }) + + it('rejects extra directory arguments instead of silently ignoring them', async () => { + await expect( + program().parseAsync(['node', 'sim', 'file', 'ls', 'Reports', 'ignored']) + ).rejects.toThrow(/too many arguments/i) + expect(mockRequest).not.toHaveBeenCalled() + }) }) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index c842d88d460..846b9bd71fd 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -14,6 +14,7 @@ import { } from '../../generated/v2-api.js' import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' +import { normalizeFolderPath } from '../../runtime/folder-path.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' import { renderResult } from '../../runtime/result.js' @@ -70,7 +71,6 @@ type ResourceDirectoryConfig = } interface ListOptions { - folder: string search?: string limit: string } @@ -163,27 +163,28 @@ export function attachResourceDirectoryCommands( config: ResourceDirectoryConfig ): void { group - .command('ls') + .command('ls [path]') + .allowExcessArguments(false) .description(`List ${config.kind} resources and child folders together`) - .option('--folder ', 'Canonical folder path to list', '/') .option('--search ', 'Filter folders and resources by name') .addOption( new Option('--limit ', 'Maximum combined items to return (0 for everything)').default( String(DEFAULT_LIMIT) ) ) - .action(async (options: ListOptions, command: Command) => { + .action(async (path: string | undefined, options: ListOptions, command: Command) => { const rawLimit = Number(options.limit) if (!Number.isSafeInteger(rawLimit) || rawLimit < 0) { throw new SimApiError('--limit must be a non-negative integer', 0) } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit + const folderPath = normalizeFolderPath(path ?? '/') const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() const [folders, resources] = await Promise.all([ - listFolders(client, config.folders, workspaceId, options.folder, options.search), - listResources(client, config, workspaceId, options.folder, options.search, limit), + listFolders(client, config.folders, workspaceId, folderPath, options.search), + listResources(client, config, workspaceId, folderPath, options.search, limit), ]) const entries = entriesFor(config, folders, resources) printList(profile.output, entries.slice(0, limit), COLUMNS) @@ -191,13 +192,14 @@ export function attachResourceDirectoryCommands( group .command('mkdir ') + .allowExcessArguments(false) .description(`Create a ${config.kind} directory at a canonical path`) .action(async (path: string, _options: Record, command: Command) => { const { client, profile } = clientFrom(command) const operation = V2_OPERATIONS[config.createFolder] const result = await client.request<{ data?: unknown }>(operation.path, { method: operation.method, - body: { workspaceId: client.requireWorkspace(), path }, + body: { workspaceId: client.requireWorkspace(), path: normalizeFolderPath(path) }, }) renderResult(config.createFolder, profile.output, result.data ?? result, {}) }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index 7dcde00bfd7..a6b5a063e16 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -106,7 +106,7 @@ describe('tables import output', () => { '--name', 'Customers', '--folder', - '/Reports', + 'Reports', '--no-wait', ]) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 4fa72ec4300..1391176720b 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -8,6 +8,7 @@ import type { GetTableImportResponse, } from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' +import { normalizeFolderPath } from '../../runtime/folder-path.js' import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' @@ -139,7 +140,13 @@ export function attachTableImport(tables: Command): void { if (!name) { throw new SimApiError('Pass --name to say what the new table is called', 0) } - target = { type: 'new', name, ...(options.folder ? { folderPath: options.folder } : {}) } + target = { + type: 'new', + name, + ...(options.folder !== undefined + ? { folderPath: normalizeFolderPath(options.folder) } + : {}), + } } const started = await client.request('/api/v2/tables/imports', { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index dc75015f278..c12f257bf3c 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -5,9 +5,13 @@ const TABLE_FILTER_HELP = 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' +const FOLDER_PATH_INPUT = { + normalize: 'folder-path', + describe: 'Folder path; the leading / is optional', +} as const const FOLDER_PATH_FLAG = { + ...FOLDER_PATH_INPUT, name: 'folder', - describe: 'Canonical folder path, starting with /', } as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, @@ -92,7 +96,7 @@ export const CLI_CONTRACT: CliContract = { listLogs: { flags: { workflowIds: { name: 'workflow', list: true }, - folderPaths: { name: 'folder', list: true }, + folderPaths: { ...FOLDER_PATH_FLAG, list: true }, triggers: { name: 'trigger', list: true }, }, columns: [ @@ -315,7 +319,11 @@ export const CLI_CONTRACT: CliContract = { describe: 'Move files into another folder', flags: { fileIds: { list: true }, - targetFolderPath: { name: 'to', describe: 'Destination folder path; omit for root' }, + targetFolderPath: { + ...FOLDER_PATH_INPUT, + name: 'to', + describe: 'Destination folder path; omit for root', + }, }, }, renameFile: { @@ -345,80 +353,110 @@ export const CLI_CONTRACT: CliContract = { // ─── Resource-scoped, path-addressed folders ────────────────────────────── listFileFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, listKnowledgeFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, listTableFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, listWorkflowFolders: { aliases: ['ls'], - flags: { parentPath: { name: 'parent', describe: 'Direct parent folder path' } }, + flags: { + parentPath: { ...FOLDER_PATH_INPUT, name: 'parent', describe: 'Direct parent folder path' }, + }, columns: FOLDER_LIST_COLUMNS, }, - createFileFolder: { positionals: ['path'], describe: 'Create a file folder at a path' }, + createFileFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a file folder at a path', + }, createKnowledgeFolder: { positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, describe: 'Create a knowledge folder at a path', }, - createTableFolder: { positionals: ['path'], describe: 'Create a table folder at a path' }, + createTableFolder: { + positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, + describe: 'Create a table folder at a path', + }, createWorkflowFolder: { positionals: ['path'], + flags: { path: FOLDER_PATH_INPUT }, describe: 'Create a workflow folder at a path', }, relocateFileFolder: { command: 'files folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a file folder', }, relocateKnowledgeFolder: { command: 'knowledge folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a knowledge folder', }, relocateTableFolder: { command: 'tables folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a table folder', }, relocateWorkflowFolder: { command: 'workflows folders move', aliases: ['mv'], positionals: ['path', 'destinationPath'], - flags: { destinationPath: { name: 'destination' } }, + flags: { + path: FOLDER_PATH_INPUT, + destinationPath: { ...FOLDER_PATH_INPUT, name: 'destination' }, + }, describe: 'Rename or move a workflow folder', }, deleteFileFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the file folder and, when recursive, everything inside it.', }, deleteKnowledgeFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', }, deleteTableFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the table folder and, when recursive, everything inside it.', }, deleteWorkflowFolder: { positionals: ['path'], - flags: { recursive: { choices: ['true', 'false'] } }, + flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, confirm: 'This archives the workflow folder and, when recursive, everything inside it.', }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 6b0c857b0a0..bdaac5bb6f8 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -49,6 +49,8 @@ export interface FlagSpec { describe?: string /** Accepted values when the generated descriptor cannot recover an enum. */ choices?: readonly string[] + /** Normalizes a terminal-friendly value into its API wire representation. */ + normalize?: 'folder-path' /** * Never expose this field as a flag, and never send it. * diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index fb3ee4dc17e..c8cb1452f5a 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -169,7 +169,7 @@ describe('commands parsed through commander', () => { 'file_1', 'file_2', '--to', - '/Archive', + 'Archive', ]) expect(path).toBe('/api/v2/files/move') expect(options.body).toEqual({ @@ -180,13 +180,13 @@ describe('commands parsed through commander', () => { }) it('uses mv as the resource move alias', async () => { - const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', '/Archive']) + const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', 'Archive']) expect(path).toBe('/api/v2/tables/tbl_1') expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: '/Archive' }) }) it('exposes path-addressed folder commands under each resource', async () => { - const [createPath, createOptions] = await run(['table', 'folders', 'create', '/Reports']) + const [createPath, createOptions] = await run(['table', 'folders', 'create', 'Reports']) expect(createPath).toBe('/api/v2/tables/folders') expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: '/Reports' }) @@ -194,8 +194,8 @@ describe('commands parsed through commander', () => { 'table', 'folders', 'mv', - '/Reports', - '/Archive/Reports', + 'Reports', + 'Archive/Reports', ]) expect(movePath).toBe('/api/v2/tables/folders') expect(moveOptions.body).toEqual({ @@ -204,15 +204,15 @@ describe('commands parsed through commander', () => { destinationPath: '/Archive/Reports', }) - const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', '/']) + const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', 'Reports']) expect(listPath).toBe('/api/v2/tables/folders') - expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/' }) + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/Reports' }) const [deletePath, deleteOptions] = await run([ 'table', 'folders', 'delete', - '/Archive/Reports', + 'Archive/Reports', '--recursive', 'false', '--yes', diff --git a/packages/sim-cli/src/runtime/folder-path.ts b/packages/sim-cli/src/runtime/folder-path.ts new file mode 100644 index 00000000000..a89433c2ac1 --- /dev/null +++ b/packages/sim-cli/src/runtime/folder-path.ts @@ -0,0 +1,7 @@ +import { SimApiError } from '../http/client.js' + +/** Accepts root-relative folder input while preserving already-canonical paths. */ +export function normalizeFolderPath(path: string): string { + if (!path) throw new SimApiError('Folder path cannot be empty', 0) + return path.startsWith('/') ? path : `/${path}` +} diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 6c805040f93..145cba64750 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -4,6 +4,7 @@ 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 { camel, kebab } from './derive.js' +import { normalizeFolderPath } from './folder-path.js' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -181,7 +182,8 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: */ if (flag.list) { const values = readListValues(raw, flagName) - return field.kind === 'string' ? values.join(',') : values + const normalized = flag.normalize === 'folder-path' ? values.map(normalizeFolderPath) : values + return field.kind === 'string' ? normalized.join(',') : normalized } if (takesJson(field, flag)) { @@ -210,7 +212,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } - return raw + return flag.normalize === 'folder-path' ? normalizeFolderPath(String(raw)) : raw } export interface BuiltRequest { From da611cbeae2452640d2b50f2ed07b469dc8ad396 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 02:10:58 -0700 Subject: [PATCH 36/46] feat(cli): streamline common resource workflows --- packages/sim-cli/README.md | 44 ++++--- .../commands/protocol/files-download.test.ts | 31 ++++- .../src/commands/protocol/files-download.ts | 38 +++++- .../commands/protocol/files-upload.test.ts | 12 +- .../src/commands/protocol/files-upload.ts | 17 +-- .../knowledge-document-upload.test.ts | 6 +- .../protocol/resource-directory.test.ts | 6 +- .../commands/protocol/resource-directory.ts | 7 +- .../commands/protocol/tables-import.test.ts | 2 +- .../src/commands/protocol/tables-import.ts | 7 +- packages/sim-cli/src/config/index.ts | 1 + packages/sim-cli/src/config/profile.test.ts | 23 ++-- packages/sim-cli/src/config/profile.ts | 32 ++--- packages/sim-cli/src/context.ts | 9 +- packages/sim-cli/src/contract/commands.ts | 69 +++++++++-- packages/sim-cli/src/contract/types.ts | 19 ++- packages/sim-cli/src/generated/v2-api.ts | 109 ++++++++++------- packages/sim-cli/src/http/client.ts | 65 ---------- packages/sim-cli/src/index.ts | 13 +- packages/sim-cli/src/runtime/build.test.ts | 115 +++++++++++++++--- packages/sim-cli/src/runtime/build.ts | 33 ++++- packages/sim-cli/src/runtime/folder-path.ts | 7 -- packages/sim-cli/src/runtime/options.ts | 21 +++- packages/sim-cli/src/runtime/request.test.ts | 4 + packages/sim-cli/src/runtime/request.ts | 40 ++++-- .../sim-cli/src/transfer/upload-session.ts | 12 +- 26 files changed, 495 insertions(+), 247 deletions(-) delete mode 100644 packages/sim-cli/src/runtime/folder-path.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index d5fbcc96b0f..9c92d7fcaec 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -3,7 +3,7 @@ Talk to the [Sim](https://sim.ai) API from your terminal. ```bash -npm install -g @sim/cli +bun add --global @sim/cli sim login sim workflows list ``` @@ -53,7 +53,7 @@ Each setting resolves independently, first match wins: | Rank | Source | | --- | --- | -| 1 | Command-line flag (`--endpoint`, `--workspace`) | +| 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`) | @@ -112,12 +112,15 @@ also accepts its singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural spellings. +`knowledge` also accepts the shorter `kb` alias. + ```bash sim workflows ls [path] [--search ] [--limit ] sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get sim workflows mv --folder sim workflows deploy|undeploy|rollback +sim workflows run [--input ] [--select-output …] sim logs list [--level error] [--workflow …] [--trigger …] [--start ] sim logs get @@ -129,7 +132,10 @@ sim tables get sim tables mv --folder sim tables columns sim tables rows list [--limit ] +sim tables rows create --data +sim tables rows create --rows sim tables rows query [--filter ] [--sort ] [--limit ] +sim tables rows query --filter '{"all":[{"field":"status","op":"eq","value":"active"}]}' sim tables upsert --data sim tables rows batch-delete (--row … | --filter ) --yes @@ -138,10 +144,10 @@ sim files list [--folder ] sim files get sim files create --name [--folder ] [--content ] [--encoding utf-8|base64] sim files upload [--name ] [--folder ] -sim files download [-o ] +sim files download [-o ] sim files mv --file-ids … [--to ] sim files batch-delete --file-ids … --yes -sim files delete +sim files delete --yes sim knowledge ls [path] [--search ] [--limit ] sim knowledge list [--folder ] @@ -150,8 +156,14 @@ sim knowledge mv --folder sim knowledge documents [--search ] sim knowledge documents upload [--tag ...] sim knowledge search --query --kb … [--search-mode vector|hybrid] + +sim billing +sim billing logs [--period 7d] [--limit ] ``` +Workflow output selectors use `blockName.field` syntax, such as +`--select-output agent_1.content`; fields that are not produced are omitted. + `ls` is a directory view: it combines the resources at its optional path with that folder's direct child folders. It never includes deeper descendants. Its `ref` column is the resource ID or canonical folder path to pass to the next @@ -166,14 +178,13 @@ sim tables folders ls --parent Reports sim tables mkdir Reports/Quarterly sim tables folders create Reports/Quarterly sim tables folders mv Reports/Quarterly Archive/Quarterly -sim tables folders delete Archive/Quarterly --recursive false --yes +sim tables folders delete Archive/Quarterly --yes +sim tables folders delete Archive --recursive --yes ``` `mkdir` is the concise form of `folders create`. Replace `tables` with `files`, -`workflows`, or `knowledge`. The leading `/` is optional on CLI inputs; the CLI -adds it before calling the API. Omit the `ls` path to list root. A slash that -belongs to a folder name is percent-encoded as `%2F` rather than treated as a -separator. +`workflows`, or `knowledge`. The leading `/` is optional on API inputs; the API +returns the canonical leading-slash form. Omit the `ls` path to list root. ### List inputs @@ -210,9 +221,9 @@ everything" default. ### Output formats -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: +Output format can be selected per command with `--output`, saved as a profile +default with `sim configure --set-output `, or set ambiently with +`SIM_OUTPUT` for CI: | Format | For | | --- | --- | @@ -230,7 +241,8 @@ parsing. sim configure --set-output json # for this profile, from now on sim configure --set-output text --profile scripts # a profile dedicated to scripting -SIM_OUTPUT=json sim logs list --level error | jq -r '.[].executionId' +sim --output json logs list --level error | jq -r '.[].executionId' +sim logs list --level error --output json | 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 @@ -241,9 +253,9 @@ done An absent value is an em-dash in `table` and an **empty field** in `text`, so emptiness tests downstream behave. -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. +An invalid active `SIM_OUTPUT` or `output =` value fails with the accepted +formats. A valid higher-priority `--output` still overrides a stale lower tier, +so `sim --output table configure --set-output json` can repair a profile. ## How this stays in sync with the API diff --git a/packages/sim-cli/src/commands/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-download.test.ts index 11b4f743bce..78b57300345 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.test.ts @@ -102,6 +102,35 @@ describe('files download', () => { target, ]) - expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', path: target, status: 'saved' }) + expect(JSON.parse(logged[0])).toEqual({ + id: 'file_1', + path: target, + status: 'saved', + }) + }) + + it('streams raw bytes to stdout with the conventional - destination', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + const chunks: Uint8Array[] = [] + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + return true + }) + const logged = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-']) + + expect(Buffer.concat(chunks).toString('utf8')).toBe('downloaded') + expect(logged).not.toHaveBeenCalled() + }) + + it('rejects overwrite semantics for stdout', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-', '--force']) + ).rejects.toThrow(/--force cannot be used/) + expect(fetch).not.toHaveBeenCalled() }) }) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-download.ts index a2683bf735a..6aba29862d3 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.ts @@ -47,11 +47,28 @@ export async function streamToFile( } } +/** Streams a fetch body to stdout without closing the process-wide stream. */ +export async function streamToStdout( + body: ReadableStream, + output: NodeJS.WriteStream = process.stdout +): Promise { + const reader = body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) return + if (!output.write(value)) await once(output, 'drain') + } + } finally { + reader.releaseLock() + } +} + export function attachFileDownload(files: Command): void { files .command('download ') .description('Download a file') - .option('-o, --output-file ', 'Where to write it (defaults to the file name)') + .option('-o, --output-file ', 'Where to write it (default: file name; -: stdout)') .option('--force', 'Overwrite the destination if it already exists') .action( async ( @@ -59,6 +76,10 @@ export function attachFileDownload(files: Command): void { options: { outputFile?: string; force?: boolean }, command: Command ) => { + if (options.outputFile === '-' && options.force) { + throw new SimApiError('--force cannot be used when --output-file is -', 0) + } + const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() @@ -70,7 +91,9 @@ export function attachFileDownload(files: Command): void { url.searchParams.set('workspaceId', workspaceId) // boundary-raw-fetch: binary download cannot pass through the JSON client - const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } }) + 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( @@ -79,6 +102,11 @@ export function attachFileDownload(files: Command): void { ) } + if (options.outputFile === '-') { + await streamToStdout(response.body) + return + } + const target = options.outputFile ?? basename( @@ -90,7 +118,11 @@ export function attachFileDownload(files: Command): void { response.body, createWriteStream(target, { flags: options.force ? 'w' : 'wx' }) ) - printProtocolResult(profile.output, { id: fileId, path: target, status: 'saved' }) + printProtocolResult(profile.output, { + id: fileId, + path: target, + status: 'saved', + }) } ) } diff --git a/packages/sim-cli/src/commands/protocol/files-upload.test.ts b/packages/sim-cli/src/commands/protocol/files-upload.test.ts index 8ddffe560a4..5b023be631f 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.test.ts @@ -44,7 +44,7 @@ function program(): Command { } describe('files upload', () => { - it('uses a signed PUT transfer and completes with an empty body', async () => { + it('uses a signed PUT transfer and completes without a request body', async () => { const path = join(dir, 'notes.txt') writeFileSync(path, 'hello') mockRequest @@ -114,7 +114,7 @@ describe('files upload', () => { name: 'notes.txt', contentType: 'text/plain', size: 5, - folderPath: '/Reports', + folderPath: 'Reports', }, }, ]) @@ -124,14 +124,18 @@ describe('files upload', () => { method: 'POST', query: { workspaceId: 'ws_local' }, headers: { 'upload-token': 'secret-token' }, - body: {}, }, ]) expect(JSON.parse(logged[0])).toEqual({ id: 'file_1', name: 'notes.txt', size: 5, - status: 'uploaded', + type: 'text/plain', + key: 'workspace/ws_local/notes.txt', + folderPath: '/', + uploadedBy: 'user_1', + uploadedAt: '2026-08-04T19:00:00.000Z', + updatedAt: '2026-08-04T19:00:00.000Z', }) expect(logged[0]).not.toContain('secret-token') }) diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index e8837c57c09..d0c7f3185bf 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -4,7 +4,6 @@ import type { CompleteFileUploadResponse, CreateFileUploadResponse, } from '../../generated/v2-api.js' -import { normalizeFolderPath } from '../../runtime/folder-path.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' @@ -13,7 +12,7 @@ export function attachFileUpload(files: Command): void { files .command('upload ') .description('Upload a file to the workspace') - .option('--folder ', 'Canonical destination folder path (defaults to /)') + .option('--folder ', 'Destination folder path (defaults to /)') .option('--name ', 'Store it under a different name') .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { const { client, profile } = clientFrom(command) @@ -27,9 +26,7 @@ export function attachFileUpload(files: Command): void { name, contentType: contentTypeFor(name), size, - ...(options.folder !== undefined - ? { folderPath: normalizeFolderPath(options.folder) } - : {}), + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), }, }) const { session, uploadToken, transfer } = created.data @@ -45,11 +42,9 @@ export function attachFileUpload(files: Command): void { path ) - printProtocolResult(profile.output, { - id: completed.file?.id ?? session.id, - name, - size, - status: 'uploaded', - }) + if (!completed.file) { + throw new Error(`File upload ${session.id} completed without a file`) + } + printProtocolResult(profile.output, completed.file) }) } diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 525751c58d9..a7f6dff40a8 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -166,8 +166,10 @@ describe('knowledge documents upload', () => { expect(mockRequest.mock.calls[2][0]).toBe( '/api/v2/knowledge/kb_1/documents/uploads/upload_1/complete' ) - expect(mockRequest.mock.calls[2][1].body).toEqual({ - parts: [{ partNumber: 1, etag: 'etag-1' }], + expect(mockRequest.mock.calls[2][1]).toEqual({ + method: 'POST', + query: { workspaceId: 'ws_local' }, + headers: { 'upload-token': 'secret-token' }, }) expect(JSON.parse(logged[0])).toEqual({ id: 'doc_1', diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts index 9e2914428da..950512efc0b 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.test.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.test.ts @@ -87,7 +87,7 @@ describe('resource directory', () => { expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { query: { workspaceId: 'ws_local', - parentPath: '/Reports', + parentPath: 'Reports', search: 'r', sortBy: 'name', sortOrder: 'asc', @@ -96,7 +96,7 @@ describe('resource directory', () => { expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables', { query: { workspaceId: 'ws_local', - folderPath: '/Reports', + folderPath: 'Reports', search: 'r', sortBy: 'name', sortOrder: 'asc', @@ -140,7 +140,7 @@ describe('resource directory', () => { expect(mockRequest).toHaveBeenCalledWith('/api/v2/tables/folders', { method: 'POST', - body: { workspaceId: 'ws_local', path: '/Reports/Quarterly' }, + body: { workspaceId: 'ws_local', path: 'Reports/Quarterly' }, }) }) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index 846b9bd71fd..e59f50c2200 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -14,7 +14,6 @@ import { } from '../../generated/v2-api.js' import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' -import { normalizeFolderPath } from '../../runtime/folder-path.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' import { renderResult } from '../../runtime/result.js' @@ -179,7 +178,7 @@ export function attachResourceDirectoryCommands( } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit - const folderPath = normalizeFolderPath(path ?? '/') + const folderPath = path ?? '/' const { client, profile } = clientFrom(command) const workspaceId = client.requireWorkspace() const [folders, resources] = await Promise.all([ @@ -193,13 +192,13 @@ export function attachResourceDirectoryCommands( group .command('mkdir ') .allowExcessArguments(false) - .description(`Create a ${config.kind} directory at a canonical path`) + .description(`Create a ${config.kind} directory at a path`) .action(async (path: string, _options: Record, command: Command) => { const { client, profile } = clientFrom(command) const operation = V2_OPERATIONS[config.createFolder] const result = await client.request<{ data?: unknown }>(operation.path, { method: operation.method, - body: { workspaceId: client.requireWorkspace(), path: normalizeFolderPath(path) }, + body: { workspaceId: client.requireWorkspace(), path }, }) renderResult(config.createFolder, profile.output, result.data ?? result, {}) }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index a6b5a063e16..0a04990ce1e 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -115,7 +115,7 @@ describe('tables import output', () => { body: { workspaceId: 'ws_local', source: { type: 'workspace_file', fileId: 'file_1' }, - target: { type: 'new', name: 'Customers', folderPath: '/Reports' }, + target: { type: 'new', name: 'Customers', folderPath: 'Reports' }, }, }) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 1391176720b..78803a51f9e 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -8,7 +8,6 @@ import type { GetTableImportResponse, } from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' -import { normalizeFolderPath } from '../../runtime/folder-path.js' import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' @@ -107,7 +106,7 @@ export function attachTableImport(tables: Command): void { 'How to write into --table-id (default: append)' ).choices(['append', 'replace']) ) - .option('--folder ', 'Canonical folder path for the new table') + .option('--folder ', 'Folder path for the new table') .option('--file-id ', 'Import a file already in the workspace instead of a local path') .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') @@ -143,9 +142,7 @@ export function attachTableImport(tables: Command): void { target = { type: 'new', name, - ...(options.folder !== undefined - ? { folderPath: normalizeFolderPath(options.folder) } - : {}), + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), } } diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 5a11e311370..50ead0e790d 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -6,6 +6,7 @@ export { listProfiles, OUTPUT_FORMATS, type OutputFormat, + ProfileConfigError, type ProfileOverrides, type ResolvedProfile, readConfigProfile, diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 48661750b7c..fee767e474f 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -96,24 +96,31 @@ describe('profile resolution', () => { expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') }) - it('ignores an unrecognized output format instead of failing the whole resolve', () => { - // 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. + it('fails fast on an unrecognized active output format', () => { process.env.SIM_OUTPUT = 'xml' - expect(resolveProfile().output).toBe('table') + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from env. Use one of: table, json, yaml, text' + ) - process.env.SIM_OUTPUT = undefined + Reflect.deleteProperty(process.env, 'SIM_OUTPUT') writeConfigProfile('default', { output: 'xml' }) - expect(resolveProfile().output).toBe('table') + expect(() => resolveProfile()).toThrow( + 'Unknown output format "xml" from config. Use one of: table, json, yaml, text' + ) + expect(resolveProfile({ output: 'json' }).output).toBe('json') }) - 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. + it('resolves output from flag, environment, then profile', () => { 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' } }) + + expect(resolveProfile({ output: 'text' })).toMatchObject({ + output: 'text', + sources: { output: 'flag' }, + }) }) 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 48fca121498..9826b79daa1 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -24,6 +24,14 @@ export const DEFAULT_ENDPOINT = 'https://sim.ai' export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] +/** An invalid active profile setting that the user can correct. */ +export class ProfileConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProfileConfigError' + } +} + /** Everything a command needs to make a call, after the resolution chain runs. */ export interface ResolvedProfile { name: string @@ -47,6 +55,7 @@ export interface ProfileOverrides { endpoint?: string apiKey?: string workspaceId?: string + output?: OutputFormat } /** @@ -127,12 +136,6 @@ function normalizeEndpoint(endpoint: string): string { 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 @@ -185,19 +188,20 @@ 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( + const output = resolve( [ - ['env', parseOutput(process.env.SIM_OUTPUT)], - ['config', parseOutput(config.output)], + ['flag', overrides.output], + ['env', process.env.SIM_OUTPUT], + ['config', config.output], ], 'table', 'default' ) + if (!(OUTPUT_FORMATS as readonly string[]).includes(output.value as string)) { + throw new ProfileConfigError( + `Unknown output format "${output.value}" from ${output.source}. Use one of: ${OUTPUT_FORMATS.join(', ')}` + ) + } return { name, diff --git a/packages/sim-cli/src/context.ts b/packages/sim-cli/src/context.ts index 7486100815f..1880f366eac 100644 --- a/packages/sim-cli/src/context.ts +++ b/packages/sim-cli/src/context.ts @@ -1,5 +1,10 @@ import type { Command } from 'commander' -import { type ProfileOverrides, type ResolvedProfile, resolveProfile } from './config/index.js' +import { + type OutputFormat, + type ProfileOverrides, + type ResolvedProfile, + resolveProfile, +} from './config/index.js' import { SimClient } from './http/client.js' /** Global flags, shared by every subcommand. */ @@ -7,6 +12,7 @@ export interface GlobalOptions { profile?: string endpoint?: string workspace?: string + output?: OutputFormat } /** @@ -24,6 +30,7 @@ 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/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c12f257bf3c..68d1100fe72 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -2,17 +2,22 @@ import type { CliContract, ColumnSpec } from './types.js' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = - 'Predicate tree using all/any and operators eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, or isNotNull' + 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' +const TABLE_SORT_HELP = + 'Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc)' const CUSTOM_TOOL_SCHEMA_HELP = 'OpenAI function schema: {"type":"function","function":{"name":"...","parameters":{"type":"object","properties":{}}}}' const FOLDER_PATH_INPUT = { - normalize: 'folder-path', describe: 'Folder path; the leading / is optional', } as const const FOLDER_PATH_FLAG = { ...FOLDER_PATH_INPUT, name: 'folder', } as const +const FOLDER_DELETE_FLAGS = { + path: FOLDER_PATH_INPUT, + recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, +} as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, { header: 'name' }, @@ -33,6 +38,31 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { + getUsageSummary: { + command: 'billing', + groupDefault: true, + describe: 'Show current billing-period usage', + fields: [ + { header: 'plan' }, + { header: 'period start', path: 'period.start', format: 'timestamp' }, + { header: 'period end', path: 'period.end', format: 'timestamp' }, + { header: 'used credits', path: 'totalCredits' }, + { header: 'limit credits', path: 'limitCredits' }, + { header: 'by source', path: 'bySourceCredits' }, + ], + }, + listUsageLogs: { + command: 'billing logs', + describe: 'List credit usage events', + columns: [ + { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'source' }, + { header: 'workflow', path: 'workflowName' }, + { header: 'credits', path: 'creditCost' }, + { header: 'id' }, + ], + }, + // ─── 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 @@ -160,12 +190,28 @@ export const CLI_CONTRACT: CliContract = { command: 'tables rows query', flags: { predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, - sort: { json: true }, + sort: { json: true, describe: TABLE_SORT_HELP }, }, // 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', }, + createTableRows: { + bodyVariants: [ + { + name: 'data', + property: 'data', + kind: 'object', + describe: 'One row keyed by column name', + }, + { + name: 'rows', + property: 'rows', + kind: 'array', + describe: 'Several rows keyed by column name', + }, + ], + }, createTable: { flags: { name: { describe: TABLE_NAME_HELP }, @@ -441,22 +487,22 @@ export const CLI_CONTRACT: CliContract = { }, deleteFileFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the file folder and, when recursive, everything inside it.', }, deleteKnowledgeFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the knowledge folder and, when recursive, everything inside it.', }, deleteTableFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the table folder and, when recursive, everything inside it.', }, deleteWorkflowFolder: { positionals: ['path'], - flags: { path: FOLDER_PATH_INPUT, recursive: { choices: ['true', 'false'] } }, + flags: FOLDER_DELETE_FLAGS, confirm: 'This archives the workflow folder and, when recursive, everything inside it.', }, @@ -478,7 +524,7 @@ export const CLI_CONTRACT: CliContract = { flags: { q: { describe: 'Value to find' }, predicate: { name: 'filter', json: true, describe: TABLE_FILTER_HELP }, - sort: { json: true }, + sort: { json: true, describe: TABLE_SORT_HELP }, }, itemsPath: 'matches', columns: [{ header: 'ordinal' }, { header: 'row', path: 'rowId' }, { header: 'column' }], @@ -530,7 +576,12 @@ export const CLI_CONTRACT: CliContract = { describe: 'Run a deployed workflow and wait for the result', flags: { input: { json: true, describe: 'Trigger input as JSON' }, - selectedOutputs: { name: 'output', list: true }, + selectedOutputs: { + name: 'select-output', + list: true, + describe: + 'Return blockName.field values (e.g. agent_1.content); missing fields are omitted', + }, // 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 diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index bdaac5bb6f8..c3ad7ce4fee 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -49,8 +49,8 @@ export interface FlagSpec { describe?: string /** Accepted values when the generated descriptor cannot recover an enum. */ choices?: readonly string[] - /** Normalizes a terminal-friendly value into its API wire representation. */ - normalize?: 'folder-path' + /** Expose a string-backed API boolean as a conventional terminal toggle. */ + boolean?: true /** * Never expose this field as a flag, and never send it. * @@ -72,12 +72,25 @@ export interface ColumnSpec { format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' } +export interface BodyVariantSpec { + /** User-facing flag name, without `--`. */ + name: string + /** Request-body property populated by this variant. */ + property: string + /** JSON shape accepted by this variant. */ + kind: 'object' | 'array' + /** One-line help describing when to use this variant. */ + describe: string +} + export interface CommandSpec { /** * Command path, space-separated. Omit to accept the derived * ` [sub-resource] ` name. */ command?: string + /** Run this operation when its top-level group is invoked without a subcommand. */ + groupDefault?: boolean /** Alternate leaf command names, such as `ls` for `list`. */ aliases?: readonly string[] /** Required query/body fields exposed as positional arguments, in order. */ @@ -86,6 +99,8 @@ export interface CommandSpec { describe?: string /** Per-field flag overrides, keyed by the contract's field name. */ flags?: Record + /** Friendly mutually-exclusive flags for an otherwise opaque union body. */ + bodyVariants?: readonly BodyVariantSpec[] /** Columns for table output. Omit on non-list commands to print a record. */ columns?: ColumnSpec[] /** Fields shown for a single record in human formats. Machine output stays raw. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 51f82deec62..cf0cb692751 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -26,7 +26,15 @@ export type AbortFileUploadHeaders = { export type AbortFileUploadResponse = { data: { id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -64,7 +72,15 @@ export type AbortKnowledgeDocumentUploadResponse = { data: { id: string knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -345,15 +361,6 @@ export type CompleteFileUploadQuery = { workspaceId: string } -export type CompleteFileUploadBody = - | { - parts: Array<{ - partNumber: number - etag?: string - }> - } - | Record - export type CompleteFileUploadHeaders = { 'upload-token': string } @@ -361,7 +368,15 @@ export type CompleteFileUploadHeaders = { export type CompleteFileUploadResponse = { data: { id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -391,15 +406,6 @@ export type CompleteKnowledgeDocumentUploadQuery = { workspaceId: string } -export type CompleteKnowledgeDocumentUploadBody = - | { - parts: Array<{ - partNumber: number - etag?: string - }> - } - | Record - export type CompleteKnowledgeDocumentUploadHeaders = { 'upload-token': string } @@ -408,7 +414,15 @@ export type CompleteKnowledgeDocumentUploadResponse = { data: { id: string knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -439,15 +453,6 @@ export type CompleteTableImportQuery = { workspaceId: string } -export type CompleteTableImportBody = - | { - parts: Array<{ - partNumber: number - etag?: string - }> - } - | Record - export type CompleteTableImportHeaders = { 'upload-token': string } @@ -623,7 +628,15 @@ export type CreateFileUploadResponse = { data: { session: { id: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -755,7 +768,15 @@ export type CreateKnowledgeDocumentUploadResponse = { session: { id: string knowledgeBaseId: string - status: 'uploading' | 'finalizing' | 'completed' | 'failed' | 'aborted' | 'expired' + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' name: string contentType: string size: number @@ -1404,7 +1425,7 @@ export type DeleteFileResponse = { export type DeleteFileFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteFileFolderResponse = { @@ -1455,7 +1476,7 @@ export type DeleteKnowledgeDocumentResponse = { export type DeleteKnowledgeFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteKnowledgeFolderResponse = { @@ -1513,6 +1534,7 @@ export type DeleteTableQuery = { export type DeleteTableResponse = { data: { id: string + deleted: true } } @@ -1549,7 +1571,7 @@ export type DeleteTableColumnResponse = { export type DeleteTableFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteTableFolderResponse = { @@ -1633,7 +1655,7 @@ export type DeleteWorkflowResponse = { export type DeleteWorkflowFolderQuery = { workspaceId: string path: string - recursive: string + recursive?: string } export type DeleteWorkflowFolderResponse = { @@ -3867,6 +3889,7 @@ export type UpdateTableParams = { export type UpdateTableBody = { workspaceId: string name?: string + description?: string | null folderPath?: string } @@ -4402,7 +4425,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - opaqueBody: true, }, completeKnowledgeDocumentUpload: { method: 'POST', @@ -4413,7 +4435,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - opaqueBody: true, }, completeTableImport: { method: 'POST', @@ -4424,7 +4445,6 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, }, - opaqueBody: true, }, createCredential: { method: 'POST', @@ -4763,7 +4783,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteKnowledgeBase: { @@ -4795,7 +4815,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteMcpServer: { @@ -4848,7 +4868,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteTableRow: { @@ -4900,7 +4920,7 @@ export const V2_OPERATIONS = { query: { workspaceId: { kind: 'string', required: true }, path: { kind: 'string', required: true }, - recursive: { kind: 'string', required: true }, + recursive: { kind: 'string', default: false }, }, }, deleteWorkflowGroup: { @@ -5814,6 +5834,7 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, name: { kind: 'string' }, + description: { kind: 'string' }, folderPath: { kind: 'string' }, }, }, diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index af14b433da7..f195e6364d2 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -1,5 +1,4 @@ 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 @@ -19,11 +18,6 @@ export class SimApiError extends Error { } } -/** `{ data }` — a single resource. */ -interface V2DataEnvelope { - data: T -} - /** `{ data, nextCursor }` — one page of a list. */ export interface V2Page { data: T[] @@ -195,65 +189,6 @@ export class SimClient { 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) - } - - /** - * 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 } /** diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 85e67d3635c..77aa78911bc 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,10 +1,11 @@ #!/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 { attachProtocolCommands } from './commands/protocol/index.js' +import { OUTPUT_FORMATS, ProfileConfigError } from './config/index.js' import { formatApiErrorDetails, SimApiError } from './http/client.js' import { sanitize } from './output/render.js' import { buildGeneratedCommands } from './runtime/build.js' @@ -18,6 +19,9 @@ 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)') + .addOption( + new Option('--output ', 'Output format for this command').choices([...OUTPUT_FORMATS]) + ) program.addCommand(loginCommand()) program.addCommand(logoutCommand()) @@ -42,7 +46,8 @@ 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 --output json tables get tbl_123 Override output for one command + $ sim configure --set-output json Save a profile output default $ sim knowledge search --query "refund policy" --kb kb_123 $ sim workflows export wf_123 > wf.json JSON flags read files with @ $ sim workflows import --workflow @wf.json @@ -59,6 +64,10 @@ async function main() { try { await program.parseAsync(process.argv) } catch (error) { + if (error instanceof ProfileConfigError) { + console.error(chalk.red(`Error: ${error.message}`)) + process.exit(1) + } if (error instanceof SimApiError) { console.error(chalk.red(`Error: ${error.message}`)) if (error.code) console.error(chalk.dim(` code: ${error.code}`)) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index c8cb1452f5a..8cdd1c6885f 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -75,6 +75,7 @@ describe('commands parsed through commander', () => { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', + knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -92,12 +93,35 @@ describe('commands parsed through commander', () => { expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) + it('describes generated resource and sub-resource groups', () => { + expect(commandAt('tables').description()).toBe('Manage tables') + expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') + }) + it('dispatches generated commands through their singular resource alias', async () => { const [tablePath] = await run(['table', 'list']) expect(tablePath).toBe('/api/v2/tables') const [filePath] = await run(['file', 'list']) expect(filePath).toBe('/api/v2/files') + + const [knowledgePath] = await run(['kb', 'list']) + expect(knowledgePath).toBe('/api/v2/knowledge') + }) + + it('uses billing as the usage summary and keeps detailed events under logs', async () => { + expect(commandAt('billing').commands.map((command) => command.name())).toContain('logs') + expect(commandAt('billing').commands.map((command) => command.name())).not.toContain('usage') + + const [summaryPath, summaryOptions] = await run(['billing'], { + data: { plan: 'pro', totalCredits: 10 }, + }) + expect(summaryPath).toBe('/api/v2/billing/usage') + expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) + + const [logsPath, logsOptions] = await run(['billing', 'logs', '--period', '7d']) + expect(logsPath).toBe('/api/v2/billing/usage/logs') + expect(logsOptions.query).toMatchObject({ workspaceId: 'ws_local', period: '7d' }) }) it('carries every multi-word flag on a command, not just the first', async () => { @@ -175,20 +199,20 @@ describe('commands parsed through commander', () => { expect(options.body).toEqual({ workspaceId: 'ws_local', fileIds: ['file_1', 'file_2'], - targetFolderPath: '/Archive', + targetFolderPath: 'Archive', }) }) it('uses mv as the resource move alias', async () => { const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', 'Archive']) expect(path).toBe('/api/v2/tables/tbl_1') - expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: '/Archive' }) + expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) }) it('exposes path-addressed folder commands under each resource', async () => { const [createPath, createOptions] = await run(['table', 'folders', 'create', 'Reports']) expect(createPath).toBe('/api/v2/tables/folders') - expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: '/Reports' }) + expect(createOptions.body).toEqual({ workspaceId: 'ws_local', path: 'Reports' }) const [movePath, moveOptions] = await run([ 'table', @@ -200,13 +224,13 @@ describe('commands parsed through commander', () => { expect(movePath).toBe('/api/v2/tables/folders') expect(moveOptions.body).toEqual({ workspaceId: 'ws_local', - path: '/Reports', - destinationPath: '/Archive/Reports', + path: 'Reports', + destinationPath: 'Archive/Reports', }) const [listPath, listOptions] = await run(['table', 'folders', 'ls', '--parent', 'Reports']) expect(listPath).toBe('/api/v2/tables/folders') - expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: '/Reports' }) + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local', parentPath: 'Reports' }) const [deletePath, deleteOptions] = await run([ 'table', @@ -214,15 +238,31 @@ describe('commands parsed through commander', () => { 'delete', 'Archive/Reports', '--recursive', - 'false', '--yes', ]) expect(deletePath).toBe('/api/v2/tables/folders') expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local', - path: '/Archive/Reports', - recursive: 'false', + path: 'Archive/Reports', + recursive: true, + }) + + const [, nonRecursiveOptions] = await run([ + 'table', + 'folders', + 'delete', + 'Archive/Empty', + '--yes', + ]) + expect(nonRecursiveOptions.query).toEqual({ + workspaceId: 'ws_local', + path: 'Archive/Empty', }) + + const help = commandAt('tables', 'folders', 'delete').helpInformation() + expect(help).toContain('--recursive') + expect(help).not.toContain('--recursive ') + expect(help).not.toContain('--no-recursive') }) it('exposes credential data centers added by the v2 credential contract', async () => { @@ -257,6 +297,29 @@ describe('commands parsed through commander', () => { expect(without.query).not.toHaveProperty('deployedOnly') }) + it('runs a workflow without input and keeps output selection distinct from rendering', async () => { + const help = commandAt('workflows', 'run').helpInformation() + expect(help).toContain('--select-output ') + expect(help).toContain('blockName.field') + expect(help).toContain('agent_1.content') + expect(help).not.toContain('--output ') + + const [, withoutInput] = await run(['workflows', 'run', 'wf_1'], { data: { success: true } }) + expect(withoutInput.body).toEqual({}) + + const [, selected] = await run( + ['workflows', 'run', 'wf_1', '--select-output', 'agent.answer', 'save.result'], + { data: { success: true } } + ) + expect(selected.body).toEqual({ selectedOutputs: ['agent.answer', 'save.result'] }) + }) + + it('documents the table predicate and sort wire shapes in help', () => { + const help = commandAt('tables', 'rows', 'query').helpInformation() + expect(help).toContain('{"all":[{"field":"status","op":"eq","value":"active"}]}') + expect(help).toContain('[{"field":"createdAt","direction":"desc"}]') + }) + 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/ @@ -650,8 +713,8 @@ describe('bodies and fields the generator cannot flatten', () => { 'rows', 'create', 'tbl_1', - '--body', - '{"rows":[{"city":"Paris"}]}', + '--rows', + '[{"city":"Paris"}]', ]) expect(path).toBe('/api/v2/tables/tbl_1/rows') @@ -659,24 +722,40 @@ describe('bodies and fields the generator cannot flatten', () => { expect(options.body).toEqual({ workspaceId: 'ws_local', rows: [{ city: 'Paris' }] }) }) - it('lets the caller override a shared field', async () => { + it('offers a direct single-row flag', async () => { const [, options] = await run([ 'tables', 'rows', 'create', 'tbl_1', - '--body', - '{"workspaceId":"ws_other","rows":[]}', + '--data', + '{"city":"Paris"}', ]) - expect(options.body).toMatchObject({ workspaceId: 'ws_other' }) + expect(options.body).toEqual({ workspaceId: 'ws_local', data: { city: 'Paris' } }) + }) + + it('requires exactly one row-body form', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1'])).rejects.toThrow( + /exactly one of --data or --rows/ + ) + await expect( + run(['tables', 'rows', 'create', 'tbl_1', '--data', '{}', '--rows', '[]']) + ).rejects.toThrow(/exactly one of --data or --rows/) }) - 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('rejects the wrong JSON shape for a row-body flag', async () => { + await expect(run(['tables', 'rows', 'create', 'tbl_1', '--data', '[1,2]'])).rejects.toThrow( + /--data must be a JSON object/ ) }) + it('explains the single and batch row forms in help', () => { + const help = commandAt('tables', 'rows', 'create').helpInformation() + expect(help).toMatch(/--data.*One row keyed by column name/s) + expect(help).toMatch(/--rows.*Several rows keyed by column name/s) + expect(help).not.toContain('--body') + }) + 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, diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 6bb6210cd43..c26b98931c6 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -13,6 +13,7 @@ const GROUP_ALIASES: Readonly> = { credentials: 'credential', 'custom-tools': 'custom-tool', files: 'file', + knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', skills: 'skill', @@ -20,9 +21,13 @@ const GROUP_ALIASES: Readonly> = { workflows: 'workflow', } -function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { +function configureOperation( + command: Command, + operation: V2OperationName, + spec: CommandSpec +): Command { const operationSpec = V2_OPERATIONS[operation] as OperationSpec - const command = new Command(leafName).allowExcessArguments(false) + command.allowExcessArguments(false) for (const alias of spec.aliases ?? []) command.alias(alias) @@ -47,22 +52,33 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri return command } +function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { + return configureOperation(new Command(leafName), operation, spec) +} + function groupFor(groups: Map, name: string): Command { const existing = groups.get(name) if (existing) return existing - const group = new Command(name) + const group = new Command(name).description(`Manage ${name.replaceAll('-', ' ')}`) const alias = GROUP_ALIASES[name] if (alias) group.alias(alias) groups.set(name, group) return group } +function resourceLabel(name: string): string { + const label = name.endsWith('s') ? name.slice(0, -1) : name + return label.replaceAll('-', ' ') +} + function nestedGroup(parent: Command, name: string): Command { const existing = parent.commands.find((candidate) => candidate.name() === name) if (existing) return existing - const created = new Command(name) + const created = new Command(name).description( + `Manage ${resourceLabel(parent.name())} ${name.replaceAll('-', ' ')}` + ) parent.addCommand(created) return created } @@ -81,6 +97,15 @@ export function buildGeneratedCommands(): Command[] { const leafName = rest.join(' ') || 'run' const group = groupFor(groups, groupName) + if (spec.groupDefault) { + if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) + if (operationSpec.pathParams.length > 0 || spec.positionals?.length) { + throw new Error(`${operation} groupDefault cannot require positional arguments`) + } + configureOperation(group, operation, spec) + continue + } + if (rest.length > 1) { const [subName, ...tail] = rest nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) diff --git a/packages/sim-cli/src/runtime/folder-path.ts b/packages/sim-cli/src/runtime/folder-path.ts deleted file mode 100644 index a89433c2ac1..00000000000 --- a/packages/sim-cli/src/runtime/folder-path.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SimApiError } from '../http/client.js' - -/** Accepts root-relative folder input while preserving already-canonical paths. */ -export function normalizeFolderPath(path: string): string { - if (!path) throw new SimApiError('Folder path cannot be empty', 0) - return path.startsWith('/') ? path : `/${path}` -} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index f085b0ee468..badffb740f3 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -35,7 +35,7 @@ function addFieldOption( return } - if (descriptor.kind === 'boolean') { + if (descriptor.kind === 'boolean' || flag.boolean) { if (descriptor.required) { command.addOption( new Option( @@ -49,7 +49,7 @@ function addFieldOption( } command.option(`${short}--${name}`, flag.describe ?? `Set ${field}`) - command.option(`--no-${name}`, `Set ${field} to false`) + if (!flag.boolean) command.option(`--no-${name}`, `Set ${field} to false`) return } @@ -91,10 +91,19 @@ export function addOperationOptions( } if (operationSpec.opaqueBody) { - command.requiredOption( - '--body ', - 'Request body as JSON (or @path / @- to read a file or stdin) (required)' - ) + if (commandSpec.bodyVariants) { + for (const variant of commandSpec.bodyVariants) { + command.option( + `--${variant.name} `, + `${variant.describe} (JSON, or @path / @-; choose exactly one body flag)` + ) + } + } else { + command.requiredOption( + '--body ', + 'Request body as JSON (or @path / @- to read a file or stdin) (required)' + ) + } } if (commandSpec.confirm) { diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 88dfa4e3d25..34e3feaa427 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -53,6 +53,10 @@ describe('buildRequest', () => { expect(built.body ?? {}).not.toHaveProperty('stream') }) + it('sends an empty object when a declared body has no provided fields', () => { + expect(buildRequest('executeWorkflow', ['wf_1'], {}, WORKSPACE).body).toEqual({}) + }) + 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') }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 145cba64750..57d1bad5b3b 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,10 +1,9 @@ import { existsSync, readFileSync, readSync } from 'node:fs' import { CLI_CONTRACT } from '../contract/commands.js' -import type { FlagSpec } from '../contract/types.js' +import type { CommandSpec, FlagSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { type QueryValue, SimApiError } from '../http/client.js' import { camel, kebab } from './derive.js' -import { normalizeFolderPath } from './folder-path.js' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -182,8 +181,7 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: */ if (flag.list) { const values = readListValues(raw, flagName) - const normalized = flag.normalize === 'folder-path' ? values.map(normalizeFolderPath) : values - return field.kind === 'string' ? normalized.join(',') : normalized + return field.kind === 'string' ? values.join(',') : values } if (takesJson(field, flag)) { @@ -205,14 +203,14 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: return value } - if (field.kind === 'boolean') return raw === true || raw === 'true' + if (field.kind === 'boolean' || flag.boolean) return raw === true || raw === 'true' const choices = flag.choices ?? field.values if (choices && !choices.includes(String(raw))) { throw new SimApiError(`--${flagName} must be one of: ${choices.join(', ')}`, 0) } - return flag.normalize === 'folder-path' ? normalizeFolderPath(String(raw)) : raw + return raw } export interface BuiltRequest { @@ -246,6 +244,7 @@ export function buildRequest( flags: Record, workspaceId: string | null ): BuiltRequest { + const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} const spec = V2_OPERATIONS[operation] as { method: string path: string @@ -301,6 +300,29 @@ export function buildRequest( // 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) { + if (commandSpec.bodyVariants) { + const provided = commandSpec.bodyVariants.filter( + (variant) => flags[camel(variant.name)] !== undefined + ) + const names = commandSpec.bodyVariants.map((variant) => `--${variant.name}`).join(' or ') + if (provided.length !== 1) { + throw new SimApiError(`Pass exactly one of ${names}`, 0) + } + + const variant = provided[0] + const raw = flags[camel(variant.name)] + if (typeof raw !== 'string') throw new SimApiError(`--${variant.name} is required`, 0) + const parsed = coerce(raw, { kind: variant.kind }, { json: true }, variant.name) + if ( + (variant.kind === 'object' && + (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))) || + (variant.kind === 'array' && !Array.isArray(parsed)) + ) { + throw new SimApiError(`--${variant.name} must be a JSON ${variant.kind}`, 0) + } + return { path, query, body: { ...body, [variant.property]: parsed } } + } + const raw = flags.body if (typeof raw !== 'string') throw new SimApiError('--body is required', 0) const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body') @@ -313,6 +335,10 @@ export function buildRequest( return { path, query, - body: Object.keys(body).length > 0 ? body : undefined, + /** + * A declared JSON body is still an object when all of its fields are optional. + * Sending no bytes makes the server reject before field defaults can apply. + */ + body: spec.body ? body : undefined, } } diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index 959643095f6..c97d02bd2bd 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -46,7 +46,7 @@ async function uploadParts( session: UploadSession, transfer: Extract, blob: Blob -): Promise> { +): Promise { const expectedPartCount = Math.ceil(session.size / transfer.partSize) if (expectedPartCount !== transfer.partCount) { throw new Error( @@ -54,7 +54,6 @@ async function uploadParts( ) } - const completed: Array<{ partNumber: number; etag?: string }> = [] for (let first = 1; first <= transfer.partCount; first += PART_URL_BATCH) { const partNumbers = [] for (let n = first; n < first + PART_URL_BATCH && n <= transfer.partCount; n++) { @@ -87,12 +86,8 @@ async function uploadParts( response.status ) } - - const etag = response.headers.get('etag')?.replace(/"/g, '') - completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber }) } } - return completed } /** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ @@ -104,19 +99,16 @@ export async function finishUploadSession( ): Promise { try { const blob = await openAsBlob(path) - let body: Record if (session.transfer.method === 'put') { await uploadPut(session.transfer, blob) - body = {} } else { - body = { parts: await uploadParts(client, workspaceId, session, session.transfer, blob) } + await uploadParts(client, workspaceId, session, session.transfer, blob) } const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { method: 'POST', query: { workspaceId }, headers: { 'upload-token': session.uploadToken }, - body, }) return completed.data } catch (error) { From 28698ee731b30ff681d38dced397d58307f3f8ce Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 11:34:40 -0700 Subject: [PATCH 37/46] feat(cli): standardize resource command syntax --- packages/sim-cli/README.md | 19 ++-- .../sim-cli/src/commands/protocol/index.ts | 3 +- .../knowledge-document-upload.test.ts | 28 ++++-- .../protocol/knowledge-document-upload.ts | 91 +++++++++--------- packages/sim-cli/src/contract/commands.ts | 52 +++++++++-- packages/sim-cli/src/contract/types.ts | 36 +++++++- packages/sim-cli/src/runtime/build.test.ts | 79 +++++++++++++++- packages/sim-cli/src/runtime/build.ts | 92 ++++++++++++++++--- packages/sim-cli/src/runtime/execute.ts | 8 +- packages/sim-cli/src/runtime/options.ts | 20 +++- packages/sim-cli/src/runtime/request.test.ts | 14 +++ packages/sim-cli/src/runtime/request.ts | 32 +++++-- 12 files changed, 368 insertions(+), 106 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 9c92d7fcaec..6d21f3de141 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -112,13 +112,15 @@ also accepts its singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural spellings. -`knowledge` also accepts the shorter `kb` alias. +`knowledge` also accepts the shorter `kb` alias, and `documents` accepts +`document`. ```bash sim workflows ls [path] [--search ] [--limit ] sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get -sim workflows mv --folder +sim workflows update [--name ] [--description ] [--folder ] +sim workflows mv sim workflows deploy|undeploy|rollback sim workflows run [--input ] [--select-output …] @@ -129,7 +131,8 @@ sim logs execution sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] sim tables get -sim tables mv --folder +sim tables update [--name ] [--description ] [--folder ] +sim tables mv sim tables columns sim tables rows list [--limit ] sim tables rows create --data @@ -152,11 +155,15 @@ sim files delete --yes sim knowledge ls [path] [--search ] [--limit ] sim knowledge list [--folder ] sim knowledge get -sim knowledge mv --folder -sim knowledge documents [--search ] -sim knowledge documents upload [--tag ...] +sim knowledge update [--name ] [--description ] [--folder ] +sim knowledge mv sim knowledge search --query --kb … [--search-mode vector|hybrid] +sim documents list --kb [--search ] +sim documents get --kb +sim documents upload --kb [--tag ...] +sim documents delete --kb --yes + sim billing sim billing logs [--period 7d] [--limit ] ``` diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 67df42a865b..33939b7cdde 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -25,8 +25,9 @@ export function attachProtocolCommands(program: Command): void { createFolder: 'createFileFolder', }) + attachKnowledgeDocumentUpload(group(program, 'documents')) + const knowledge = group(program, 'knowledge') - attachKnowledgeDocumentUpload(group(knowledge, 'documents')) attachResourceDirectoryCommands(knowledge, { kind: 'knowledge', resources: 'listKnowledgeBases', diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index a7f6dff40a8..18f6be45562 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -62,14 +62,16 @@ function uploadSession() { } } -describe('knowledge documents upload', () => { +describe('documents upload', () => { it('owns the multipart protocol while hiding its low-level operations', () => { - const knowledge = program().commands.find((command) => command.name() === 'knowledge') + const root = program() + const knowledge = root.commands.find((command) => command.name() === 'knowledge') expect(knowledge?.commands.map((command) => command.name())).not.toEqual( - expect.arrayContaining(['uploads', 'parts', 'complete']) + expect.arrayContaining(['documents', 'uploads', 'parts', 'complete']) ) - const documents = knowledge?.commands.find((command) => command.name() === 'documents') + const documents = root.commands.find((command) => command.name() === 'documents') + expect(documents?.alias()).toBe('document') expect(documents?.commands.map((command) => command.name())).toContain('upload') }) @@ -131,11 +133,11 @@ describe('knowledge documents upload', () => { await program().parseAsync([ 'node', 'sim', - 'knowledge', 'documents', 'upload', - 'kb_1', path, + '--kb', + 'kb_1', '--tag', 'customer', 'priority', @@ -189,11 +191,11 @@ describe('knowledge documents upload', () => { program().parseAsync([ 'node', 'sim', - 'knowledge', 'documents', 'upload', - 'kb_1', path, + '--kb', + 'kb_1', '--tag', '1', '2', @@ -207,4 +209,14 @@ describe('knowledge documents upload', () => { ).rejects.toThrow(/at most seven/) expect(mockRequest).not.toHaveBeenCalled() }) + + it('requires an explicit knowledge-base scope before reading the file', async () => { + const path = join(dir, 'notes.txt') + writeFileSync(path, 'hello') + + await expect( + program().parseAsync(['node', 'sim', 'documents', 'upload', path]) + ).rejects.toThrow(/required option '--kb '/) + expect(mockRequest).not.toHaveBeenCalled() + }) }) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index 1a459930628..8abf8c60b43 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -10,6 +10,7 @@ import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' interface KnowledgeDocumentUploadOptions { + kb: string name?: string tag?: string[] recipe?: string @@ -37,62 +38,54 @@ function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record ') + .command('upload ') .description('Upload a document to a knowledge base') + .requiredOption('--kb ', 'Knowledge base ID (required)') .option('--name ', 'Store it under a different name') .option('--tag ', 'Document tags, in tag1 through tag7 order') .option('--recipe ', 'Document processing recipe') .option('--lang ', 'Document language code') - .action( - async ( - knowledgeBaseId: string, - path: string, - options: KnowledgeDocumentUploadOptions, - command: Command - ) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - const created = await client.request( - `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, - { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...uploadMetadata(options), - }, - } - ) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession< - CompleteKnowledgeDocumentUploadResponse['data'] - >( - client, - workspaceId, - { - basePath: `/api/v2/knowledge/${encodeURIComponent( - knowledgeBaseId - )}/documents/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, + .action(async (path: string, options: KnowledgeDocumentUploadOptions, command: Command) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(options.kb)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), size, + ...uploadMetadata(options), }, - path - ) - - if (!completed.document) { - throw new Error(`Knowledge upload ${session.id} completed without a document`) } - printProtocolResult(profile.output, { - id: completed.document.id, - knowledgeBaseId: completed.document.knowledgeBaseId, - name: completed.document.filename, - size: completed.document.fileSize, - status: completed.document.processingStatus, - }) + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + options.kb + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, + size, + }, + path + ) + + if (!completed.document) { + throw new Error(`Knowledge upload ${session.id} completed without a document`) } - ) + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) + }) } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 68d1100fe72..72fcb312c67 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1,4 +1,4 @@ -import type { CliContract, ColumnSpec } from './types.js' +import type { CliContract, ColumnSpec, CommandVariantSpec } from './types.js' const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = @@ -18,6 +18,13 @@ const FOLDER_DELETE_FLAGS = { path: FOLDER_PATH_INPUT, recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, } as const +const KNOWLEDGE_DOCUMENT_SCOPE = { + id: { + name: 'kb', + placeholder: 'knowledgeBaseId', + describe: 'Knowledge base ID', + }, +} as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, { header: 'name' }, @@ -25,6 +32,15 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ] +function moveResource(command: string, resource: string): CommandVariantSpec { + return { + command, + positionals: ['folderPath'], + requestFields: ['folderPath'], + describe: `Move a ${resource} to a folder`, + } +} + /** * The CLI contract for the v2 surface. * @@ -34,7 +50,7 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ * * Derived by default: * listTables → sim tables list - * getKnowledgeDocument → sim knowledge documents get + * getKnowledgeDocument → sim documents get --kb * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { @@ -54,6 +70,12 @@ export const CLI_CONTRACT: CliContract = { listUsageLogs: { command: 'billing logs', describe: 'List credit usage events', + flags: { + source: { describe: 'Filter by usage source' }, + period: { describe: 'Billing period' }, + startDate: { describe: 'Custom period start (ISO 8601)' }, + endDate: { describe: 'Custom period end (ISO 8601)' }, + }, columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, { header: 'source' }, @@ -99,7 +121,11 @@ export const CLI_CONTRACT: CliContract = { fields: [{ header: 'remaining columns', path: 'columns', format: 'count' }], }, deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, - deleteKnowledgeDocument: { confirm: 'This deletes the document and its embeddings.' }, + deleteKnowledgeDocument: { + command: 'documents delete', + pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + 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.' }, @@ -223,7 +249,7 @@ export const CLI_CONTRACT: CliContract = { }, }, updateTable: { - aliases: ['mv'], + variants: [moveResource('tables mv', 'table')], flags: { name: { describe: TABLE_NAME_HELP }, folderPath: FOLDER_PATH_FLAG, @@ -231,9 +257,15 @@ export const CLI_CONTRACT: CliContract = { }, createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, - updateKnowledgeBase: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + updateKnowledgeBase: { + variants: [moveResource('knowledge mv', 'knowledge base')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, createWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, - updateWorkflow: { aliases: ['mv'], flags: { folderPath: FOLDER_PATH_FLAG } }, + updateWorkflow: { + variants: [moveResource('workflows mv', 'workflow')], + flags: { folderPath: FOLDER_PATH_FLAG }, + }, importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, @@ -285,7 +317,13 @@ export const CLI_CONTRACT: CliContract = { { header: 'model', path: 'embeddingModel' }, ], }, + getKnowledgeDocument: { + command: 'documents get', + pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + }, listKnowledgeDocuments: { + command: 'documents list', + pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, columns: [ { header: 'id' }, { header: 'filename' }, @@ -601,7 +639,7 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim knowledge documents upload ` needs its + // Multipart upload; `sim documents upload --kb ` needs its // own file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, createKnowledgeDocumentUpload: { hidden: true }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index c3ad7ce4fee..c21b62326b2 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -6,8 +6,7 @@ import type { V2OperationName } from '../generated/v2-api.js' * 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. + * command name itself usually derives from ` `. * * This file carries only what a schema cannot say: * @@ -17,6 +16,8 @@ import type { V2OperationName } from '../generated/v2-api.js' * - `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`). + * - `pathFlags` — when a parent path segment is command context rather than the + * resource being acted on (`documents get --kb `). * - `columns` — which of a response's fields belong in a table. Editorial. * - `confirm` — which operations are destructive enough to demand `--yes`. * @@ -62,6 +63,18 @@ export interface FlagSpec { omit?: boolean } +/** How a route path parameter is exposed as a required named option. */ +export interface PathFlagSpec { + /** Flag name, kebab-case, without `--`. Defaults to the kebab-cased path parameter. */ + name?: string + /** Help placeholder without angle brackets. Defaults to `value`. */ + placeholder?: string + /** Short alias, e.g. `k` for `--kb`. */ + short?: string + /** One-line help for the scope selected by this path parameter. */ + describe?: string +} + /** A column in table-mode output. */ export interface ColumnSpec { /** Header, and the default path into the row when `value` is omitted. */ @@ -83,6 +96,17 @@ export interface BodyVariantSpec { describe: string } +export interface CommandVariantSpec { + /** Full alternate command path, such as `workflows mv`. */ + command: string + /** Request fields exposed as required positional arguments. */ + positionals?: readonly string[] + /** Request fields available on this narrower command surface. */ + requestFields?: readonly string[] + /** One-line help for the alternate command. */ + describe?: string +} + export interface CommandSpec { /** * Command path, space-separated. Omit to accept the derived @@ -93,8 +117,14 @@ export interface CommandSpec { groupDefault?: boolean /** Alternate leaf command names, such as `ls` for `list`. */ aliases?: readonly string[] - /** Required query/body fields exposed as positional arguments, in order. */ + /** Route path parameters exposed as required named options instead of positionals. */ + pathFlags?: Record + /** Request fields exposed as required positional arguments, in order. */ positionals?: readonly string[] + /** Restrict this command to these request fields; profile fields remain implicit. */ + requestFields?: readonly string[] + /** Additional command shapes backed by the same API operation. */ + variants?: readonly CommandVariantSpec[] /** 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. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 8cdd1c6885f..625190003f7 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -74,6 +74,7 @@ describe('commands parsed through commander', () => { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', + documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -109,10 +110,64 @@ describe('commands parsed through commander', () => { expect(knowledgePath).toBe('/api/v2/knowledge') }) + it('uses top-level document commands with a named knowledge-base scope', async () => { + expect(commandAt('knowledge').commands.map((command) => command.name())).not.toContain( + 'documents' + ) + + const help = commandAt('documents', 'get').helpInformation() + expect(help).toContain('') + expect(help).toMatch(/--kb .*required/s) + expect(help).not.toContain(' ') + + const [listPath, listOptions] = await run(['documents', 'list', '--kb', 'kb_1']) + expect(listPath).toBe('/api/v2/knowledge/kb_1/documents') + expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local' }) + + const [getPathBefore, getOptionsBefore] = await run([ + 'documents', + 'get', + '--kb', + 'kb_1', + 'doc_1', + ]) + expect(getPathBefore).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(getOptionsBefore.query).toEqual({ workspaceId: 'ws_local' }) + + const [getPathAfter] = await run(['document', 'get', 'doc_1', '--kb', 'kb_1']) + expect(getPathAfter).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + + await expect(run(['documents', 'delete', 'doc_1', '--kb', 'kb_1'])).rejects.toThrow( + /document and its embeddings/ + ) + expect(mockRequest).not.toHaveBeenCalled() + + const [deletePath, deleteOptions] = await run([ + 'documents', + 'delete', + 'doc_1', + '--kb', + 'kb_1', + '--yes', + ]) + expect(deletePath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local' }) + + await expect(run(['documents', 'get', 'doc_1'])).rejects.toThrow( + /required option '--kb '/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('uses billing as the usage summary and keeps detailed events under logs', async () => { expect(commandAt('billing').commands.map((command) => command.name())).toContain('logs') expect(commandAt('billing').commands.map((command) => command.name())).not.toContain('usage') + const help = commandAt('billing', 'logs').helpInformation() + expect(help).toContain('--source ') + expect(help).toContain('Filter by usage source (choices:') + expect(help).not.toContain('One of: workflow') + const [summaryPath, summaryOptions] = await run(['billing'], { data: { plan: 'pro', totalCredits: 10 }, }) @@ -203,10 +258,26 @@ describe('commands parsed through commander', () => { }) }) - it('uses mv as the resource move alias', async () => { - const [path, options] = await run(['table', 'mv', 'tbl_1', '--folder', 'Archive']) - expect(path).toBe('/api/v2/tables/tbl_1') - expect(options.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + it('uses Linux-style resource move commands without changing update syntax', async () => { + const [tablePath, tableOptions] = await run(['table', 'mv', 'tbl_1', 'Archive']) + expect(tablePath).toBe('/api/v2/tables/tbl_1') + expect(tableOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + + const [workflowPath, workflowOptions] = await run(['workflow', 'mv', 'wf_1', 'Archive']) + expect(workflowPath).toBe('/api/v2/workflows/wf_1') + expect(workflowOptions.body).toEqual({ folderPath: 'Archive' }) + + const [knowledgePath, knowledgeOptions] = await run(['kb', 'mv', 'kb_1', 'Archive']) + expect(knowledgePath).toBe('/api/v2/knowledge/kb_1') + expect(knowledgeOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) + + const [, updateOptions] = await run(['workflow', 'update', 'wf_1', '--description', 'Updated']) + expect(updateOptions.body).toEqual({ description: 'Updated' }) + + const moveHelp = commandAt('workflows', 'mv').helpInformation() + expect(moveHelp).toContain(' ') + expect(moveHelp).not.toContain('--folder') + expect(commandAt('workflows', 'update').helpInformation()).not.toContain('update|mv') }) it('exposes path-addressed folder commands under each resource', async () => { diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index c26b98931c6..1b68242651b 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -1,17 +1,18 @@ import { Command } from 'commander' import { CLI_CONTRACT } from '../contract/commands.js' -import type { CommandSpec } from '../contract/types.js' +import type { CommandSpec, CommandVariantSpec } from '../contract/types.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { deriveCommandPath } from './derive.js' import { executeOperation } from './execute.js' import { addOperationOptions } from './options.js' -import { flagNameFor } from './request.js' +import { flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' import type { OperationSpec } from './types.js' const GROUP_ALIASES: Readonly> = { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', + documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -31,17 +32,45 @@ function configureOperation( for (const alias of spec.aliases ?? []) command.alias(alias) + for (const param of Object.keys(spec.pathFlags ?? {})) { + if (!operationSpec.pathParams.includes(param)) { + throw new Error(`${operation}.${param} is not a path parameter`) + } + } + for (const param of operationSpec.pathParams) { + if (spec.pathFlags?.[param]) continue command.argument(`<${param}>`) } for (const field of spec.positionals ?? []) { const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) - if (!descriptor.required) throw new Error(`${operation}.${field} is not required`) + if (spec.requestFields && !spec.requestFields.includes(field)) { + throw new Error(`${operation}.${field} is positional but not exposed`) + } command.argument(`<${flagNameFor(operation, field)}>`) } + if (spec.requestFields) { + for (const field of spec.requestFields) { + if (!operationSpec.query?.[field] && !operationSpec.body?.[field]) { + throw new Error(`${operation}.${field} is not a request field`) + } + } + for (const slot of ['query', 'body'] as const) { + for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if ( + descriptor.required && + field !== PROFILE_INJECTED_FIELD && + !spec.requestFields.includes(field) + ) { + throw new Error(`${operation}.${field} is required but not exposed`) + } + } + } + } + command.description( spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` ) @@ -83,6 +112,38 @@ function nestedGroup(parent: Command, name: string): Command { return created } +function addLeafCommand( + groups: Map, + operation: V2OperationName, + spec: CommandSpec, + segments: string[] +): void { + const [groupName, ...rest] = segments + if (rest.length === 0) throw new Error(`${operation} leaf command must include a verb`) + const group = groupFor(groups, groupName) + + if (rest.length > 1) { + const [subName, ...tail] = rest + nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) + return + } + + group.addCommand(buildLeaf(operation, spec, rest[0])) +} + +function variantCommandSpec(spec: CommandSpec, variant: CommandVariantSpec): CommandSpec { + return { + ...spec, + command: variant.command, + groupDefault: false, + aliases: [], + positionals: variant.positionals, + requestFields: variant.requestFields, + variants: [], + describe: variant.describe ?? spec.describe, + } +} + /** Builds every JSON command described by the generated operation table. */ export function buildGeneratedCommands(): Command[] { const groups = new Map() @@ -93,26 +154,27 @@ export function buildGeneratedCommands(): Command[] { if (spec.hidden || operationSpec.responseMode !== 'json') continue const segments = spec.command ? spec.command.split(' ') : deriveCommandPath(operation) - const [groupName, ...rest] = segments - const leafName = rest.join(' ') || 'run' - const group = groupFor(groups, groupName) - if (spec.groupDefault) { + const [groupName, ...rest] = segments + const group = groupFor(groups, groupName) if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) - if (operationSpec.pathParams.length > 0 || spec.positionals?.length) { + const pathPositionals = operationSpec.pathParams.filter((param) => !spec.pathFlags?.[param]) + if (pathPositionals.length > 0 || spec.positionals?.length) { throw new Error(`${operation} groupDefault cannot require positional arguments`) } configureOperation(group, operation, spec) - continue + } else { + addLeafCommand(groups, operation, spec, segments) } - if (rest.length > 1) { - const [subName, ...tail] = rest - nestedGroup(group, subName).addCommand(buildLeaf(operation, spec, tail.join(' '))) - continue + for (const variant of spec.variants ?? []) { + addLeafCommand( + groups, + operation, + variantCommandSpec(spec, variant), + variant.command.split(' ') + ) } - - 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/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 1d03ed4f64b..29fd9e7abba 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -24,11 +24,13 @@ export async function executeOperation( ): Promise { 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[] + const pathPositionalCount = operationSpec.pathParams.filter( + (param) => !commandSpec.pathFlags?.[param] + ).length + const positional = invocation.slice(0, pathPositionalCount) as string[] const requestFlags = { ...flags } for (const [index, field] of (commandSpec.positionals ?? []).entries()) { - requestFlags[camel(flagNameFor(operation, field))] = - invocation[operationSpec.pathParams.length + index] + requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] } if (commandSpec.confirm && !requestFlags.yes) { diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index badffb740f3..9b13d33292e 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -6,6 +6,7 @@ import { flagNameFor, flagSpecFor, PROFILE_INJECTED_FIELD, + pathFlagNameFor, takesJson, } from './request.js' import type { OperationSpec } from './types.js' @@ -57,9 +58,7 @@ function addFieldOption( const wantsJson = takesJson(descriptor, flag) const placeholder = takesList ? '' : wantsJson ? '' : '' const choices = flag.choices ?? descriptor.values - const describe = `${ - flag.describe ?? (choices ? `One of: ${choices.join(', ')}` : `Set ${field}`) - }${ + const describe = `${flag.describe ?? `Set ${name.replaceAll('-', ' ')}`}${ takesList ? ' (space-separated, or @path / @- with one value per line)' : wantsJson @@ -83,8 +82,23 @@ export function addOperationOptions( commandSpec: CommandSpec, operationSpec: OperationSpec ): void { + for (const param of operationSpec.pathParams) { + const flag = commandSpec.pathFlags?.[param] + if (!flag) continue + + const name = pathFlagNameFor(commandSpec, param) + const short = flag.short ? `-${flag.short}, ` : '' + command.addOption( + new Option( + `${short}--${name} <${flag.placeholder ?? 'value'}>`, + `${flag.describe ?? `Set ${name.replaceAll('-', ' ')}`} (required)` + ).makeOptionMandatory() + ) + } + for (const slot of ['query', 'body'] as const) { for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { + if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) continue if (commandSpec.positionals?.includes(field)) continue addFieldOption(command, operation, field, descriptor) } diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 34e3feaa427..0190a74c289 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -61,6 +61,14 @@ describe('buildRequest', () => { expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') }) + it('combines a named parent scope with a positional resource id in route order', () => { + expect(buildRequest('getKnowledgeDocument', ['doc_1'], { kb: 'kb_1' }, WORKSPACE)).toEqual({ + path: '/api/v2/knowledge/kb_1/documents/doc_1', + query: { workspaceId: WORKSPACE }, + body: undefined, + }) + }) + describe('failures, all before any network call', () => { it('rejects a missing path arg', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') @@ -72,6 +80,12 @@ describe('buildRequest', () => { ) }) + it('rejects a missing named path scope', () => { + expect(() => buildRequest('getKnowledgeDocument', ['doc_1'], {}, WORKSPACE)).toThrow( + '--kb 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' diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 57d1bad5b3b..cf8d6e56e5d 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -34,6 +34,11 @@ export function flagNameFor(operation: V2OperationName, field: string): string { return flagSpecFor(operation, field).name ?? kebab(field) } +/** The named option used for a path parameter that is contextual rather than primary. */ +export function pathFlagNameFor(commandSpec: CommandSpec, param: string): string { + return commandSpec.pathFlags?.[param]?.name ?? kebab(param) +} + export function takesJson(field: FieldSpec, flag: FlagSpec): boolean { return flag.json === true || JSON_KINDS.has(field.kind) } @@ -234,9 +239,11 @@ function asQueryValue(value: unknown): 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. + * Primary path params come from positional arguments in declared order. A + * contextual path param can instead come from a named option declared by the + * CLI contract. Every other field is looked up by its flag name in the slot the + * API contract declares it in, so a field that moved from query to body moves + * here on the next regeneration. */ export function buildRequest( operation: V2OperationName, @@ -255,12 +262,23 @@ export function buildRequest( } let path = spec.path - spec.pathParams.forEach((param, index) => { - const value = positional[index] - if (value === undefined) throw new SimApiError(`Missing <${param}>`, 0) + let positionalIndex = 0 + for (const param of spec.pathParams) { + const pathFlag = commandSpec.pathFlags?.[param] + const flagName = pathFlagNameFor(commandSpec, param) + const value = pathFlag ? flags[camel(flagName)] : positional[positionalIndex++] + if (value === undefined) { + throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${param}>`, 0) + } + if (typeof value !== 'string' || value.length === 0) { + throw new SimApiError( + pathFlag ? `--${flagName} cannot be empty` : `<${param}> cannot be empty`, + 0 + ) + } // Ids are opaque; an unencoded `/` or `?` would silently retarget the request. path = path.replace(`[${param}]`, encodeURIComponent(value)) - }) + } const query: Record = {} const body: Record = {} From e4115099af45d8b50f373dc0b06a66291e67fcaa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 13:41:55 -0700 Subject: [PATCH 38/46] feat(cli): sync unified chat billing source --- packages/sim-cli/README.md | 4 +++- packages/sim-cli/src/contract/commands.ts | 2 +- packages/sim-cli/src/generated/v2-api.ts | 9 +++----- packages/sim-cli/src/runtime/build.test.ts | 27 +++++++++++++++++++--- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 6d21f3de141..4eaca19b47a 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -165,9 +165,11 @@ sim documents upload --kb [--tag ...] sim documents delete --kb --yes sim billing -sim billing logs [--period 7d] [--limit ] +sim billing logs [--period 7d] [--source sim-chat] [--limit ] ``` +The `sim-chat` billing source combines Copilot and workspace chat usage. + Workflow output selectors use `blockName.field` syntax, such as `--select-output agent_1.content`; fields that are not produced are omitted. diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 72fcb312c67..cb59fc30e3a 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -71,7 +71,7 @@ export const CLI_CONTRACT: CliContract = { command: 'billing logs', describe: 'List credit usage events', flags: { - source: { describe: 'Filter by usage source' }, + source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, startDate: { describe: 'Custom period start (ISO 8601)' }, endDate: { describe: 'Custom period end (ISO 8601)' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index cf0cb692751..1b851c94dd9 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3173,8 +3173,7 @@ export type ListUsageLogsQuery = { source?: | 'workflow' | 'wand' - | 'copilot' - | 'workspace-chat' + | 'sim-chat' | 'mcp_copilot' | 'mothership_block' | 'knowledge-base' @@ -3196,8 +3195,7 @@ export type ListUsageLogsResponse = { source: | 'workflow' | 'wand' - | 'copilot' - | 'workspace-chat' + | 'sim-chat' | 'mcp_copilot' | 'mothership_block' | 'knowledge-base' @@ -5486,8 +5484,7 @@ export const V2_OPERATIONS = { values: [ 'workflow', 'wand', - 'copilot', - 'workspace-chat', + 'sim-chat', 'mcp_copilot', 'mothership_block', 'knowledge-base', diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 625190003f7..47f4df2438d 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -165,7 +165,10 @@ describe('commands parsed through commander', () => { const help = commandAt('billing', 'logs').helpInformation() expect(help).toContain('--source ') - expect(help).toContain('Filter by usage source (choices:') + expect(help).toMatch(/sim-chat combines Copilot and\s+workspace chat/) + expect(help).toContain('"sim-chat"') + expect(help).not.toContain('"workspace-chat"') + expect(help).not.toContain('"copilot"') expect(help).not.toContain('One of: workflow') const [summaryPath, summaryOptions] = await run(['billing'], { @@ -174,9 +177,27 @@ describe('commands parsed through commander', () => { expect(summaryPath).toBe('/api/v2/billing/usage') expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) - const [logsPath, logsOptions] = await run(['billing', 'logs', '--period', '7d']) + const [logsPath, logsOptions] = await run([ + 'billing', + 'logs', + '--period', + '7d', + '--source', + 'sim-chat', + ]) expect(logsPath).toBe('/api/v2/billing/usage/logs') - expect(logsOptions.query).toMatchObject({ workspaceId: 'ws_local', period: '7d' }) + expect(logsOptions.query).toMatchObject({ + workspaceId: 'ws_local', + period: '7d', + source: 'sim-chat', + }) + + for (const deprecated of ['copilot', 'workspace-chat']) { + await expect(run(['billing', 'logs', '--source', deprecated])).rejects.toThrow( + /allowed choices.*sim-chat/i + ) + expect(mockRequest).not.toHaveBeenCalled() + } }) it('carries every multi-word flag on a command, not just the first', async () => { From b0a761a2d9fd806aa3ef2feb10c056145b23cdb7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 5 Aug 2026 15:04:06 -0700 Subject: [PATCH 39/46] feat(cli): expose log detail trace spans --- packages/sim-cli/README.md | 11 +++- packages/sim-cli/src/contract/commands.ts | 3 +- packages/sim-cli/src/generated/v2-api.ts | 69 +++++++++++++++++++++- packages/sim-cli/src/runtime/build.test.ts | 32 +++++++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 4eaca19b47a..43c7eee434a 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -125,8 +125,8 @@ sim workflows deploy|undeploy|rollback sim workflows run [--input ] [--select-output …] sim logs list [--level error] [--workflow …] [--trigger …] [--start ] -sim logs get -sim logs execution +sim logs get +sim logs executions get sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] @@ -170,6 +170,13 @@ sim billing logs [--period 7d] [--source sim-chat] [--limit ] The `sim-chat` billing source combines Copilot and workspace chat usage. +`sim logs get` keeps the default human output concise. Use JSON or YAML to +inspect its complete `executionData` and recursive `traceSpans` tree: + +```bash +sim logs get --output json | jq '.traceSpans' +``` + Workflow output selectors use `blockName.field` syntax, such as `--select-output agent_1.content`; fields that are not produced are omitted. diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index cb59fc30e3a..81b9cc06554 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -166,7 +166,8 @@ export const CLI_CONTRACT: CliContract = { ], }, getLog: { - describe: 'Show a log summary (execution data is available in JSON or YAML output)', + describe: + 'Show a log summary (traceSpans and executionData are included in JSON or YAML output)', fields: [ { header: 'id' }, { header: 'execution', path: 'executionId' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 1b851c94dd9..b5cefac4ecc 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -2175,6 +2175,39 @@ export type GetLogParams = { id: string } +type GetLogResponseRef0 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + export type GetLogResponse = { data: { id: string @@ -2198,6 +2231,7 @@ export type GetLogResponse = { deleted: boolean } executionData: unknown + traceSpans: Array cost: { total: number } | null @@ -2897,6 +2931,39 @@ export type ListLogsQuery = { folderPaths?: string } +type ListLogsResponseRef0 = { + id: string + name: string + type: string + duration?: number + durationMs?: number + startTime?: string + endTime?: string + status?: string + blockId?: string + input?: unknown + output?: unknown + tokens?: + | number + | { + total?: number + input?: number + output?: number + } + relativeStartMs?: number + toolCalls?: Array<{ + id?: string + name?: string + arguments?: unknown + result?: unknown + error?: string + startTime?: string + endTime?: string + duration?: number + }> + children?: Array +} + export type ListLogsResponse = { data: Array<{ id: string @@ -2919,7 +2986,7 @@ export type ListLogsResponse = { deleted: boolean } finalOutput?: unknown - traceSpans?: unknown + traceSpans?: Array }> nextCursor: string | null } diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 47f4df2438d..51d74c7f5e0 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -458,6 +458,10 @@ describe('commands parsed through commander', () => { /--encoding.*utf-8.*base64/s ) }) + + it('points log detail users to complete trace output', () => { + expect(commandAt('logs', 'get').description()).toMatch(/traceSpans.*JSON or YAML/) + }) }) describe('single-resource rendering', () => { @@ -555,7 +559,7 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps sensitive execution data out of human log output', async () => { + it('keeps sensitive execution detail out of human log output', async () => { const log = { id: 'log_1', executionId: 'exec_1', @@ -568,14 +572,38 @@ describe('single-resource rendering', () => { cost: { total: 0.001 }, files: [], executionData: { env: { SECRET_TOKEN: 'encrypted-value' } }, + traceSpans: [ + { + id: 'span_1', + name: 'Workflow Execution', + type: 'workflow', + children: [ + { + id: 'span_2', + name: 'Send email', + type: 'block', + input: { recipient: 'private@example.com' }, + }, + ], + }, + ], } const human = await lines(['logs', 'get', 'log_1'], log, 'text') expect(human.join('\n')).not.toContain('executionData') expect(human.join('\n')).not.toContain('SECRET_TOKEN') + expect(human.join('\n')).not.toContain('traceSpans') + expect(human.join('\n')).not.toContain('private@example.com') const machine = await lines(['logs', 'get', 'log_1'], log, 'json') - expect(JSON.parse(machine[0])).toMatchObject({ executionData: log.executionData }) + expect(JSON.parse(machine[0])).toMatchObject({ + executionData: log.executionData, + traceSpans: log.traceSpans, + }) + + const yaml = await lines(['logs', 'get', 'log_1'], log, 'yaml') + expect(yaml.join('\n')).toContain('traceSpans:') + expect(yaml.join('\n')).toContain('span_2') }) }) From 21e14c77ab2165aa62607c96dc3b74eb3426472a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 16:58:27 -0700 Subject: [PATCH 40/46] feat(cli): improve v2 command workflows --- packages/sim-cli/README.md | 33 +- packages/sim-cli/src/contract/commands.ts | 139 +++- packages/sim-cli/src/contract/types.ts | 6 +- packages/sim-cli/src/generated/v2-api.ts | 688 +++++++++---------- packages/sim-cli/src/http/client.test.ts | 6 +- packages/sim-cli/src/output/trace.ts | 115 ++++ packages/sim-cli/src/runtime/build.test.ts | 236 ++++++- packages/sim-cli/src/runtime/build.ts | 45 +- packages/sim-cli/src/runtime/execute.ts | 24 +- packages/sim-cli/src/runtime/options.ts | 14 + packages/sim-cli/src/runtime/request.test.ts | 5 + packages/sim-cli/src/runtime/request.ts | 8 +- packages/sim-cli/src/runtime/result.ts | 53 +- 13 files changed, 940 insertions(+), 432 deletions(-) create mode 100644 packages/sim-cli/src/output/trace.ts diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 43c7eee434a..fe265772d95 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -122,11 +122,17 @@ sim workflows get sim workflows update [--name ] [--description ] [--folder ] sim workflows mv sim workflows deploy|undeploy|rollback -sim workflows run [--input ] [--select-output …] +sim workflows run [--input ] [--select-output …] [--async] +sim workflows executions list --workflow [--status ] +sim workflows executions get --workflow [--include-output] +sim workflows executions cancel --workflow +sim workflows executions resume --workflow --context [--input ] -sim logs list [--level error] [--workflow …] [--trigger …] [--start ] -sim logs get -sim logs executions get +sim logs list [--level error] [--workflow …] [--trigger …] [--start-date ] +sim logs get + +sim audit-logs list --organization [--all-workspaces] +sim audit-logs get --organization sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] @@ -164,17 +170,26 @@ sim documents get --kb sim documents upload --kb [--tag ...] sim documents delete --kb --yes -sim billing -sim billing logs [--period 7d] [--source sim-chat] [--limit ] +sim billing status [--all-workspaces] +sim billing logs [--period 7d] [--source sim-chat] [--limit ] [--all-workspaces] ``` The `sim-chat` billing source combines Copilot and workspace chat usage. +Organization audit logs require a personal API key. Commands with +`--all-workspaces` otherwise default to the workspace in the active profile. -`sim logs get` keeps the default human output concise. Use JSON or YAML to -inspect its complete `executionData` and recursive `traceSpans` tree: +`workflows executions get` is the lightweight status and polling resource. +`--workflow` names the parent resource, while the execution ID remains positional. +For a paused execution, its status includes the context ID needed by `resume`. +`logs get` is the full diagnostic resource. It keeps the default human output +concise; add `--trace` for the expanded recursive trace with span inputs, +outputs, errors, timing, and cost. JSON and YAML retain the complete structured +response: ```bash -sim logs get --output json | jq '.traceSpans' +sim logs get --trace +sim logs get --output json | jq '.traceSpans' +sim logs list --include-trace-spans --output json ``` Workflow output selectors use `blockName.field` syntax, such as diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 81b9cc06554..e35b8f7f470 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -25,6 +25,13 @@ const KNOWLEDGE_DOCUMENT_SCOPE = { describe: 'Knowledge base ID', }, } as const +const WORKFLOW_EXECUTION_SCOPE = { + id: { + name: 'workflow', + placeholder: 'workflowId', + describe: 'Workflow ID', + }, +} as const const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'path' }, { header: 'name' }, @@ -54,21 +61,24 @@ function moveResource(command: string, resource: string): CommandVariantSpec { * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { - getUsageSummary: { - command: 'billing', - groupDefault: true, - describe: 'Show current billing-period usage', + getBillingStatus: { + command: 'billing status', + allWorkspaces: true, + describe: 'Show billing status and current-period credit usage', fields: [ { header: 'plan' }, + { header: 'status' }, + { header: 'workspace', path: 'workspaceId' }, { header: 'period start', path: 'period.start', format: 'timestamp' }, { header: 'period end', path: 'period.end', format: 'timestamp' }, - { header: 'used credits', path: 'totalCredits' }, - { header: 'limit credits', path: 'limitCredits' }, - { header: 'by source', path: 'bySourceCredits' }, + { header: 'used credits', path: 'credits.used' }, + { header: 'limit credits', path: 'credits.limit' }, + { header: 'remaining credits', path: 'credits.remaining' }, ], }, - listUsageLogs: { + listBillingLogs: { command: 'billing logs', + allWorkspaces: true, describe: 'List credit usage events', flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, @@ -78,9 +88,11 @@ export const CLI_CONTRACT: CliContract = { }, columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'workspace', path: 'workspaceId' }, { header: 'source' }, - { header: 'workflow', path: 'workflowName' }, + { header: 'workflow', path: 'workflow.name' }, { header: 'credits', path: 'creditCost' }, + { header: 'execution', path: 'executionId' }, { header: 'id' }, ], }, @@ -154,9 +166,19 @@ export const CLI_CONTRACT: CliContract = { workflowIds: { name: 'workflow', list: true }, folderPaths: { ...FOLDER_PATH_FLAG, list: true }, triggers: { name: 'trigger', list: true }, + details: { describe: 'Response detail level' }, + includeTraceSpans: { + boolean: true, + describe: 'Include trace spans in JSON or YAML output (implies full detail)', + }, + includeFinalOutput: { + boolean: true, + describe: 'Include final output in JSON or YAML output (implies full detail)', + }, }, columns: [ { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, { header: 'level' }, { header: 'trigger' }, { header: 'workflow', path: 'workflow.name' }, @@ -166,12 +188,12 @@ export const CLI_CONTRACT: CliContract = { ], }, getLog: { - describe: - 'Show a log summary (traceSpans and executionData are included in JSON or YAML output)', + describe: 'Show execution diagnostics', + expandedTrace: true, fields: [ - { header: 'id' }, { header: 'execution', path: 'executionId' }, { header: 'workflow', path: 'workflow.name' }, + { header: 'status' }, { header: 'level' }, { header: 'trigger' }, { header: 'started', path: 'startedAt', format: 'timestamp' }, @@ -179,6 +201,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'duration', path: 'totalDurationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost' }, { header: 'files', format: 'count' }, + { header: 'trace', path: 'traceSpans', format: 'trace-count' }, ], }, searchKnowledge: { @@ -372,13 +395,29 @@ export const CLI_CONTRACT: CliContract = { }, listAuditLogs: { + allWorkspaces: true, + flags: { + organizationId: { + name: 'organization', + describe: 'Organization ID (personal API key required)', + }, + }, columns: [ { header: 'at', path: 'createdAt', format: 'timestamp' }, + { header: 'workspace', path: 'workspaceId' }, { header: 'actor', path: 'actorEmail' }, { header: 'action' }, { header: 'resource', path: 'resourceName' }, ], }, + getAuditLog: { + flags: { + organizationId: { + name: 'organization', + describe: 'Organization ID (personal API key required)', + }, + }, + }, // ─── The expanded files surface ─────────────────────────────────────────── // Every one of these derives badly. `/files/move` and `/files/bulk-delete` @@ -612,8 +651,9 @@ export const CLI_CONTRACT: CliContract = { // `workflows execute create` and `workflows cancel create`. executeWorkflow: { command: 'workflows run', - describe: 'Run a deployed workflow and wait for the result', + describe: 'Run a deployed workflow', flags: { + async: { boolean: true, describe: 'Queue the execution and return immediately' }, input: { json: true, describe: 'Trigger input as JSON' }, selectedOutputs: { name: 'select-output', @@ -626,18 +666,89 @@ export const CLI_CONTRACT: CliContract = { // command; advertising a flag that breaks the response is worse than // not offering it yet. stream: { omit: true }, + includeThinking: { omit: true }, + includeToolCalls: { omit: true }, }, }, getWorkflowExecution: { command: 'workflows executions get', - describe: 'Show the status of one execution', + pathFlags: WORKFLOW_EXECUTION_SCOPE, + describe: 'Show execution status (requested outputs are included in JSON or YAML output)', + flags: { + includeOutput: { + boolean: true, + describe: 'Include the final output in JSON or YAML output', + }, + selectedOutputs: { + name: 'select-output', + list: true, + describe: 'Include blockName.field values in JSON or YAML output (e.g. agent_1.content)', + }, + }, + fields: [ + { header: 'execution', path: 'executionId' }, + { header: 'workflow', path: 'workflowId' }, + { header: 'status' }, + { header: 'trigger' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'context', path: 'paused.contextId' }, + { header: 'pause kind', path: 'paused.pauseKind' }, + { header: 'paused at', path: 'paused.pausedAt', format: 'timestamp' }, + { header: 'resume at', path: 'paused.resumeAt', format: 'timestamp' }, + { header: 'blocked on', path: 'paused.blockedOnBlockId' }, + { header: 'pause points', path: 'paused.pausePointCount' }, + { header: 'error', path: 'error.message' }, + ], + }, + listWorkflowExecutions: { + command: 'workflows executions list', + pathFlags: WORKFLOW_EXECUTION_SCOPE, + describe: 'List executions for a workflow', + columns: [ + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'status' }, + { header: 'trigger' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'cost', path: 'cost.total', format: 'cost' }, + { header: 'execution', path: 'executionId' }, + ], }, cancelWorkflowExecution: { command: 'workflows executions cancel', + pathFlags: WORKFLOW_EXECUTION_SCOPE, 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. }, + resumeWorkflow: { + command: 'workflows executions resume', + pathFlags: WORKFLOW_EXECUTION_SCOPE, + describe: 'Resume a paused execution (output is included in JSON or YAML output)', + flags: { + contextId: { + name: 'context', + describe: 'Pause context ID returned by execution status', + }, + input: { + json: true, + describe: 'Resume input as JSON', + }, + }, + fields: [ + { header: 'execution', path: 'executionId' }, + { header: 'workflow', path: 'workflowId' }, + { header: 'status' }, + { header: 'status URL', path: 'statusUrl' }, + { header: 'queue position', path: 'queuePosition' }, + { header: 'started', path: 'startedAt', format: 'timestamp' }, + { header: 'ended', path: 'endedAt', format: 'timestamp' }, + { header: 'duration', path: 'durationMs', format: 'duration' }, + { header: 'error', path: 'error.message' }, + ], + }, // ─── Not a terminal-shaped operation ────────────────────────────────────── // Multipart upload; `sim documents upload --kb ` needs its diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index c21b62326b2..30838fa081f 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -82,7 +82,7 @@ export interface ColumnSpec { /** Dot path into the row. Defaults to `header`. */ path?: string /** Rendering hint; `auto` inspects the value. */ - format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' + format?: 'auto' | 'timestamp' | 'bytes' | 'duration' | 'bool' | 'cost' | 'count' | 'trace-count' } export interface BodyVariantSpec { @@ -135,8 +135,12 @@ export interface CommandSpec { columns?: ColumnSpec[] /** Fields shown for a single record in human formats. Machine output stays raw. */ fields?: ColumnSpec[] + /** Add `--trace` to expand recursive trace spans in human-readable output. */ + expandedTrace?: boolean /** Dot path to a nested result array rendered as the command's human list. */ itemsPath?: string + /** Allow an optional workspaceId field to omit the configured workspace filter. */ + allWorkspaces?: boolean /** * Require `--yes`. The message should say what is about to be destroyed — * the point is that the caller can tell whether they meant it. diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index b5cefac4ecc..039261dceec 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1195,6 +1195,25 @@ export type CreateTableRowsBody = | { workspaceId: string data: unknown + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } afterRowId?: string beforeRowId?: string } @@ -1243,68 +1262,6 @@ export type CreateTableViewBody = { } } -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: { @@ -1316,7 +1273,7 @@ export type CreateTableViewResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: CreateTableViewResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -1760,6 +1717,7 @@ export type ExecuteWorkflowParams = { export type ExecuteWorkflowBody = { input?: Record async?: boolean + executionTimeoutSeconds?: number stream?: boolean selectedOutputs?: Array includeThinking?: boolean @@ -1950,6 +1908,10 @@ export type GetAuditLogParams = { id: string } +export type GetAuditLogQuery = { + organizationId: string +} + export type GetAuditLogResponse = { data: { id: string @@ -1967,6 +1929,28 @@ export type GetAuditLogResponse = { } } +/** `GET /api/v2/billing/status` */ +export type GetBillingStatusQuery = { + workspaceId?: string +} + +export type GetBillingStatusResponse = { + data: { + workspaceId: string | null + period: { + start: string + end: string + } + plan: string + status: 'active' | 'limit_exceeded' | 'billing_blocked' + credits: { + used: number + limit: number + remaining: number + } + } +} + /** `GET /api/v2/credentials/[id]` */ export type GetCredentialParams = { id: string @@ -2027,28 +2011,6 @@ export type GetCustomToolResponse = { } } -/** `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/files/[fileId]/metadata` */ export type GetFileParams = { fileId: string @@ -2170,9 +2132,9 @@ export type GetKnowledgeDocumentResponse = { } } -/** `GET /api/v2/logs/[id]` */ +/** `GET /api/v2/logs/[executionId]` */ export type GetLogParams = { - id: string + executionId: string } type GetLogResponseRef0 = { @@ -2184,6 +2146,9 @@ type GetLogResponseRef0 = { startTime?: string endTime?: string status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string blockId?: string input?: unknown output?: unknown @@ -2194,6 +2159,12 @@ type GetLogResponseRef0 = { input?: number output?: number } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } relativeStartMs?: number toolCalls?: Array<{ id?: string @@ -2210,9 +2181,10 @@ type GetLogResponseRef0 = { export type GetLogResponse = { data: { - id: string - workflowId: string | null executionId: string + workflowId: string | null + deploymentVersionId: string | null + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' level: string trigger: string startedAt: string @@ -2230,8 +2202,9 @@ export type GetLogResponse = { updatedAt: string | null deleted: boolean } - executionData: unknown + workflowState: unknown traceSpans: Array + finalOutput: unknown | null cost: { total: number } | null @@ -2451,68 +2424,6 @@ 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: { @@ -2524,7 +2435,7 @@ export type GetTableViewResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: GetTableViewResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -2538,24 +2449,6 @@ export type GetTableViewResponse = { } } -/** `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 @@ -2604,6 +2497,7 @@ export type GetWorkflowExecutionResponse = { endedAt: string | null durationMs: number | null paused: { + contextId: string pausedAt: string resumeAt: string | null pauseKind: 'time' | 'human' | null @@ -2687,6 +2581,7 @@ export type ListAuditLogsQuery = { includeDeparted?: 'true' | 'false' limit?: number cursor?: string + organizationId: string } export type ListAuditLogsResponse = { @@ -2707,6 +2602,51 @@ export type ListAuditLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/billing/logs` */ +export type ListBillingLogsQuery = { + source?: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workspaceId?: string + period?: '1d' | '7d' | '30d' | 'all' | 'custom' + startDate?: string + endDate?: string + limit?: number + cursor?: string +} + +export type ListBillingLogsResponse = { + data: Array<{ + id: string + createdAt: string + source: + | 'workflow' + | 'wand' + | 'sim-chat' + | 'mcp_copilot' + | 'mothership_block' + | 'knowledge-base' + | 'voice-input' + | 'enrichment' + | 'voice-output' + workspaceId: string | null + workflow: { + id: string + name: string | null + } | null + executionId: string | null + creditCost: number + }> + nextCursor: string | null +} + /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string @@ -2940,6 +2880,9 @@ type ListLogsResponseRef0 = { startTime?: string endTime?: string status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string blockId?: string input?: unknown output?: unknown @@ -2950,6 +2893,12 @@ type ListLogsResponseRef0 = { input?: number output?: number } + cost?: { + total?: number + input?: number + output?: number + toolCost?: number + } relativeStartMs?: number toolCalls?: Array<{ id?: string @@ -2966,10 +2915,10 @@ type ListLogsResponseRef0 = { export type ListLogsResponse = { data: Array<{ - id: string - workflowId: string | null executionId: string + workflowId: string | null deploymentVersionId: string | null + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' level: string trigger: string startedAt: string @@ -3149,68 +3098,6 @@ 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 @@ -3221,7 +3108,7 @@ export type ListTableViewsResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: ListTableViewsResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -3235,42 +3122,33 @@ export type ListTableViewsResponse = { nextCursor: string | null } -/** `GET /api/v2/billing/usage/logs` */ -export type ListUsageLogsQuery = { - source?: - | 'workflow' - | 'wand' - | 'sim-chat' - | 'mcp_copilot' - | 'mothership_block' - | 'knowledge-base' - | 'voice-input' - | 'enrichment' - | 'voice-output' - workspaceId?: string - period?: '1d' | '7d' | '30d' | 'all' | 'custom' +/** `GET /api/v2/workflows/[id]/executions` */ +export type ListWorkflowExecutionsParams = { + id: string +} + +export type ListWorkflowExecutionsQuery = { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger?: string startDate?: string endDate?: string limit?: number cursor?: string + order?: 'asc' | 'desc' } -export type ListUsageLogsResponse = { +export type ListWorkflowExecutionsResponse = { data: Array<{ - id: string - createdAt: string - source: - | 'workflow' - | 'wand' - | 'sim-chat' - | 'mcp_copilot' - | 'mothership_block' - | 'knowledge-base' - | 'voice-input' - | 'enrichment' - | 'voice-output' - workflowName: string | null - creditCost: number + executionId: string + workflowId: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string + startedAt: string + endedAt: string | null + durationMs: number | null + cost: { + total: number + } | null }> nextCursor: string | null } @@ -3526,6 +3404,52 @@ export type RenameFileResponse = { } } +/** `POST /api/v2/workflows/[id]/executions/[executionId]/resume` */ +export type ResumeWorkflowParams = { + id: string + executionId: string +} + +export type ResumeWorkflowBody = { + contextId: string + input?: unknown +} + +export type ResumeWorkflowResponse = + | { + 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 + } + } + | { + data: { + executionId: string + statusUrl: string + queuePosition?: number + } + } + /** `POST /api/v2/workflows/[id]/rollback` */ export type RollbackWorkflowParams = { id: string @@ -3911,6 +3835,25 @@ export type UpdateRowsByFilterBody = { filter: unknown data: unknown limit?: number + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } } export type UpdateRowsByFilterResponse = { @@ -4052,6 +3995,25 @@ export type UpdateTableRowParams = { export type UpdateTableRowBody = { workspaceId: string data: unknown + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } } export type UpdateTableRowResponse = { @@ -4099,68 +4061,6 @@ export type UpdateTableViewBody = { 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: { @@ -4172,7 +4072,7 @@ export type UpdateTableViewResponse = { columnOrder?: Array pinnedColumns?: Array hiddenColumns?: Array - filter?: UpdateTableViewResponseRef0 | null + filter?: unknown | null sort?: Array<{ field: string direction: 'asc' | 'desc' @@ -4358,6 +4258,25 @@ export type UpsertTableRowBody = { workspaceId: string data: unknown conflictTarget?: string + __privateSecretProvenance?: { + version: 1 + complete: boolean + selections: Array<{ + key: string + provenance: { + version: 1 + complete: boolean + entries: Array<{ + encryptedValue: string + name?: string + }> + scope?: { + userId: string + workspaceId?: string + } + } + }> + } } export type UpsertTableRowResponse = { @@ -5025,6 +4944,7 @@ export const V2_OPERATIONS = { body: { input: { kind: 'object' }, async: { kind: 'boolean', default: false }, + executionTimeoutSeconds: { kind: 'integer' }, stream: { kind: 'boolean', default: false }, selectedOutputs: { kind: 'array' }, includeThinking: { kind: 'boolean', default: false }, @@ -5059,6 +4979,19 @@ export const V2_OPERATIONS = { pathParams: ['id'] as const, responseMode: 'json', summary: 'Get Audit Log', + query: { + organizationId: { kind: 'string', required: true }, + }, + }, + getBillingStatus: { + method: 'GET', + path: '/api/v2/billing/status', + pathParams: [] as const, + responseMode: 'json', + summary: 'Get Billing Status', + query: { + workspaceId: { kind: 'string' }, + }, }, getCredential: { method: 'GET', @@ -5080,13 +5013,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - getExecution: { - method: 'GET', - path: '/api/v2/logs/executions/[executionId]', - pathParams: ['executionId'] as const, - responseMode: 'json', - summary: 'Get Execution', - }, getFile: { method: 'GET', path: '/api/v2/files/[fileId]/metadata', @@ -5129,8 +5055,8 @@ export const V2_OPERATIONS = { }, getLog: { method: 'GET', - path: '/api/v2/logs/[id]', - pathParams: ['id'] as const, + path: '/api/v2/logs/[executionId]', + pathParams: ['executionId'] as const, responseMode: 'json', summary: 'Get Log', }, @@ -5204,16 +5130,6 @@ export const V2_OPERATIONS = { 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]', @@ -5270,6 +5186,40 @@ export const V2_OPERATIONS = { includeDeparted: { kind: 'enum', values: ['true', 'false'] as const }, limit: { kind: 'number', default: 50 }, cursor: { kind: 'string' }, + organizationId: { kind: 'string', required: true }, + }, + }, + listBillingLogs: { + method: 'GET', + path: '/api/v2/billing/logs', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Billing Logs', + query: { + source: { + kind: 'enum', + values: [ + 'workflow', + 'wand', + 'sim-chat', + 'mcp_copilot', + 'mothership_block', + 'knowledge-base', + 'voice-input', + 'enrichment', + 'voice-output', + ] 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' }, }, }, listCredentials: { @@ -5539,37 +5489,23 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - listUsageLogs: { + listWorkflowExecutions: { method: 'GET', - path: '/api/v2/billing/usage/logs', - pathParams: [] as const, + path: '/api/v2/workflows/[id]/executions', + pathParams: ['id'] as const, responseMode: 'json', - summary: 'List Usage Logs', + summary: 'List workflow executions', query: { - source: { + status: { kind: 'enum', - values: [ - 'workflow', - 'wand', - 'sim-chat', - 'mcp_copilot', - 'mothership_block', - 'knowledge-base', - 'voice-input', - 'enrichment', - 'voice-output', - ] as const, - }, - workspaceId: { kind: 'string' }, - period: { - kind: 'enum', - values: ['1d', '7d', '30d', 'all', 'custom'] as const, - default: '30d', + values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, }, + trigger: { kind: 'string' }, startDate: { kind: 'string' }, endDate: { kind: 'string' }, limit: { kind: 'integer', default: 50 }, cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, listWorkflowFolders: { @@ -5717,6 +5653,17 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true }, }, }, + resumeWorkflow: { + method: 'POST', + path: '/api/v2/workflows/[id]/executions/[executionId]/resume', + pathParams: ['id', 'executionId'] as const, + responseMode: 'json', + summary: 'Resume a workflow execution', + body: { + contextId: { kind: 'string', required: true }, + input: { kind: 'unknown' }, + }, + }, rollbackWorkflow: { method: 'POST', path: '/api/v2/workflows/[id]/rollback', @@ -5874,6 +5821,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', required: true }, data: { kind: 'unknown', required: true }, limit: { kind: 'integer' }, + __privateSecretProvenance: { kind: 'object' }, }, }, updateSkill: { @@ -5923,6 +5871,7 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, + __privateSecretProvenance: { kind: 'object' }, }, }, updateTableView: { @@ -6006,6 +5955,7 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, conflictTarget: { kind: 'string' }, + __privateSecretProvenance: { kind: 'object' }, }, }, } as const diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index ebbc07f5d1e..8102090d8f5 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -131,7 +131,11 @@ describe('generated operation table', () => { 'rollbackWorkflow', 'listLogs', 'getLog', - 'getExecution', + 'getBillingStatus', + 'listBillingLogs', + 'listWorkflowExecutions', + 'getWorkflowExecution', + 'resumeWorkflow', 'listFiles', 'deleteFile', 'listKnowledgeBases', diff --git a/packages/sim-cli/src/output/trace.ts b/packages/sim-cli/src/output/trace.ts new file mode 100644 index 00000000000..3661d758780 --- /dev/null +++ b/packages/sim-cli/src/output/trace.ts @@ -0,0 +1,115 @@ +import chalk from 'chalk' +import type { OutputFormat } from '../config/index.js' +import { duration, sanitize } from './render.js' + +type TraceSpan = Record + +function traceSpan(value: unknown): TraceSpan { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Trace contains a malformed span') + } + return value as TraceSpan +} + +function requiredText(span: TraceSpan, field: string): string { + const value = span[field] + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Trace span is missing ${field}`) + } + return sanitize(value).replace(/\s+/g, ' ').trim() +} + +function optionalText(span: TraceSpan, field: string): string | undefined { + const value = span[field] + if (value === undefined) return undefined + if (typeof value !== 'string') throw new Error(`Trace span ${field} must be a string`) + return sanitize(value).replace(/\s+/g, ' ').trim() +} + +function optionalNumber(span: TraceSpan, field: string): number | undefined { + const value = span[field] + if (value === undefined) return undefined + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Trace span ${field} must be a finite number`) + } + return value +} + +function costTotal(span: TraceSpan): number | undefined { + const value = span.cost + if (value === undefined) return undefined + const cost = traceSpan(value) + return optionalNumber(cost, 'total') +} + +function appendValue(lines: string[], indent: string, label: string, value: unknown): void { + if (value === undefined) return + const encoded = JSON.stringify(value, null, 2) + if (encoded === undefined) throw new Error(`Trace span ${label} cannot be rendered`) + const valueLines = sanitize(encoded).split('\n') + if (valueLines.length === 1) { + lines.push(`${indent}${label}: ${valueLines[0]}`) + return + } + lines.push(`${indent}${label}:`) + lines.push(...valueLines.map((line) => `${indent} ${line}`)) +} + +function renderSpan(value: unknown, depth: number): string[] { + const span = traceSpan(value) + const indent = ' '.repeat(depth) + const detailIndent = `${indent} ` + const name = requiredText(span, 'name') + const type = requiredText(span, 'type') + const status = optionalText(span, 'status') + const elapsed = optionalNumber(span, 'durationMs') ?? optionalNumber(span, 'duration') + const totalCost = costTotal(span) + const summary = [ + `${indent}- ${name}`, + `[${type}]`, + status, + elapsed === undefined ? undefined : duration(Math.round(elapsed)), + totalCost === undefined ? undefined : `$${totalCost.toFixed(4)}`, + ] + .filter((part): part is string => Boolean(part)) + .join(' ') + const lines = [summary, `${detailIndent}id: ${requiredText(span, 'id')}`] + const blockId = optionalText(span, 'blockId') + if (blockId) lines.push(`${detailIndent}block: ${blockId}`) + const startTime = optionalText(span, 'startTime') + const endTime = optionalText(span, 'endTime') + if (startTime || endTime) { + lines.push(`${detailIndent}time: ${startTime ?? '—'} → ${endTime ?? '—'}`) + } + const relativeStartMs = optionalNumber(span, 'relativeStartMs') + if (relativeStartMs !== undefined) { + lines.push(`${detailIndent}relative start: ${duration(Math.round(relativeStartMs))}`) + } + const errorType = optionalText(span, 'errorType') + const errorMessage = optionalText(span, 'errorMessage') + if (errorType || errorMessage) { + lines.push(`${detailIndent}error: ${[errorType, errorMessage].filter(Boolean).join(': ')}`) + } + appendValue(lines, detailIndent, 'tokens', span.tokens) + appendValue(lines, detailIndent, 'input', span.input) + appendValue(lines, detailIndent, 'output', span.output) + appendValue(lines, detailIndent, 'tool calls', span.toolCalls) + + if (span.children !== undefined) { + if (!Array.isArray(span.children)) throw new Error('Trace span children must be an array') + for (const child of span.children) lines.push(...renderSpan(child, depth + 1)) + } + return lines +} + +/** Prints the complete recursive execution trace for an explicitly expanded log. */ +export function printTraceSpans(format: OutputFormat, traceSpans: unknown[]): void { + if (format === 'json' || format === 'yaml') return + console.log('') + console.log(format === 'table' ? chalk.dim('trace:') : 'trace:') + if (traceSpans.length === 0) { + console.log(chalk.dim(' No trace spans.')) + return + } + console.log(traceSpans.flatMap((span) => renderSpan(span, 0)).join('\n')) +} diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 51d74c7f5e0..aaeb8436d5c 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -13,20 +13,32 @@ import { buildGeneratedCommands } from './build.js' * catch that class of bug. */ -const { mockRequest, output } = vi.hoisted(() => ({ +const { mockRequest, output, profileState } = vi.hoisted(() => ({ mockRequest: vi.fn(), output: { format: 'json' }, + profileState: { workspaceId: 'ws_local' as string | null }, })) vi.mock('../context.js', () => ({ clientFrom: () => ({ - client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, - profile: { workspaceId: 'ws_local', output: output.format, name: 'default', apiKey: 'k' }, + client: { + request: mockRequest, + requireWorkspace: () => { + if (!profileState.workspaceId) throw new Error('workspace required') + return profileState.workspaceId + }, + }, + profile: { + workspaceId: profileState.workspaceId, + output: output.format, + name: 'default', + apiKey: 'k', + }, }), })) function program(): Command { - const root = new Command('sim').exitOverride() + const root = new Command('sim').exitOverride().option('--workspace ') for (const group of buildGeneratedCommands()) 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 @@ -60,6 +72,7 @@ async function run(argv: string[], response: unknown = { data: [], nextCursor: n describe('commands parsed through commander', () => { beforeEach(() => { vi.restoreAllMocks() + profileState.workspaceId = 'ws_local' }) it('carries a multi-word flag all the way to the request', async () => { @@ -99,6 +112,27 @@ describe('commands parsed through commander', () => { expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') }) + it('shows the command syntax when a required positional argument is missing', async () => { + const root = program() + const skills = root.commands.find((command) => command.name() === 'skills') + const update = skills?.commands.find((command) => command.name() === 'update') + if (!update) throw new Error('Missing command skills update') + + let errorOutput = '' + update.configureOutput({ + writeErr: (message) => { + errorOutput += message + }, + }) + + await expect(root.parseAsync(['node', 'sim', 'skills', 'update'])).rejects.toMatchObject({ + code: 'commander.missingArgument', + }) + expect(errorOutput).toContain("error: missing required argument 'id'") + expect(errorOutput).toContain('Example: sim skills update ') + expect(errorOutput).not.toContain('--id') + }) + it('dispatches generated commands through their singular resource alias', async () => { const [tablePath] = await run(['table', 'list']) expect(tablePath).toBe('/api/v2/tables') @@ -159,9 +193,12 @@ describe('commands parsed through commander', () => { expect(mockRequest).not.toHaveBeenCalled() }) - it('uses billing as the usage summary and keeps detailed events under logs', async () => { - expect(commandAt('billing').commands.map((command) => command.name())).toContain('logs') - expect(commandAt('billing').commands.map((command) => command.name())).not.toContain('usage') + it('keeps billing status and logs as explicit subcommands', async () => { + expect( + commandAt('billing') + .commands.map((command) => command.name()) + .sort() + ).toEqual(['logs', 'status']) const help = commandAt('billing', 'logs').helpInformation() expect(help).toContain('--source ') @@ -171,12 +208,28 @@ describe('commands parsed through commander', () => { expect(help).not.toContain('"copilot"') expect(help).not.toContain('One of: workflow') - const [summaryPath, summaryOptions] = await run(['billing'], { - data: { plan: 'pro', totalCredits: 10 }, + const [summaryPath, summaryOptions] = await run(['billing', 'status'], { + data: { + plan: 'pro', + status: 'active', + credits: { used: 10, limit: 100, remaining: 90 }, + }, }) - expect(summaryPath).toBe('/api/v2/billing/usage') + expect(summaryPath).toBe('/api/v2/billing/status') expect(summaryOptions.query).toEqual({ workspaceId: 'ws_local' }) + const [, accountOptions] = await run(['billing', 'status', '--all-workspaces']) + expect(accountOptions.query).toEqual({}) + + profileState.workspaceId = null + const [, unconfiguredAccountOptions] = await run(['billing', 'status', '--all-workspaces']) + expect(unconfiguredAccountOptions.query).toEqual({}) + await expect(run(['billing', 'status'])).rejects.toThrow('workspace required') + profileState.workspaceId = 'ws_local' + await expect( + run(['--workspace', 'ws_other', 'billing', 'status', '--all-workspaces']) + ).rejects.toThrow('--all-workspaces cannot be combined with --workspace') + const [logsPath, logsOptions] = await run([ 'billing', 'logs', @@ -185,13 +238,16 @@ describe('commands parsed through commander', () => { '--source', 'sim-chat', ]) - expect(logsPath).toBe('/api/v2/billing/usage/logs') + expect(logsPath).toBe('/api/v2/billing/logs') expect(logsOptions.query).toMatchObject({ workspaceId: 'ws_local', period: '7d', source: 'sim-chat', }) + const [, accountLogsOptions] = await run(['billing', 'logs', '--all-workspaces']) + expect(accountLogsOptions.query).not.toHaveProperty('workspaceId') + for (const deprecated of ['copilot', 'workspace-chat']) { await expect(run(['billing', 'logs', '--source', deprecated])).rejects.toThrow( /allowed choices.*sim-chat/i @@ -459,8 +515,126 @@ describe('commands parsed through commander', () => { ) }) - it('points log detail users to complete trace output', () => { - expect(commandAt('logs', 'get').description()).toMatch(/traceSpans.*JSON or YAML/) + it('offers expanded trace output without changing the default summary', () => { + expect(commandAt('logs', 'get').description()).toBe('Show execution diagnostics') + expect(commandAt('logs', 'get').helpInformation()).toMatch( + /--trace.*inputs, outputs, errors, timing,\s+and cost/s + ) + const listHelp = commandAt('logs', 'list').helpInformation() + expect(listHelp).toMatch(/--include-trace-spans.*implies full detail/s) + expect(listHelp).toMatch(/--include-final-output.*implies full detail/s) + }) + + it('uses a named workflow scope for execution subresources', async () => { + const executions = commandAt('workflows', 'executions') + expect(executions.commands.map((command) => command.name()).sort()).toEqual([ + 'cancel', + 'get', + 'list', + 'resume', + ]) + + const help = commandAt('workflows', 'executions', 'get').helpInformation() + expect(help).toContain('') + expect(help).toMatch(/--workflow .*required/s) + expect(help).toContain('--include-output') + expect(help).toContain('--select-output ') + + const [path, options] = await run([ + 'workflows', + 'executions', + 'get', + 'exec_1', + '--workflow', + 'wf_1', + '--include-output', + '--select-output', + 'agent.content', + 'writer.text', + ]) + expect(path).toBe('/api/v2/workflows/wf_1/executions/exec_1') + expect(options.query).toEqual({ + includeOutput: true, + selectedOutputs: 'agent.content,writer.text', + }) + + const [listPath] = await run(['workflows', 'executions', 'list', '--workflow', 'wf_1']) + expect(listPath).toBe('/api/v2/workflows/wf_1/executions') + + const [cancelPath] = await run([ + 'workflows', + 'executions', + 'cancel', + 'exec_1', + '--workflow', + 'wf_1', + ]) + expect(cancelPath).toBe('/api/v2/workflows/wf_1/executions/exec_1/cancel') + + const resumeHelp = commandAt('workflows', 'executions', 'resume').helpInformation() + expect(resumeHelp).toContain('') + expect(resumeHelp).toMatch(/--workflow .*required/s) + expect(resumeHelp).toMatch(/--context .*required/s) + + const [resumePath, resumeOptions] = await run([ + 'workflows', + 'executions', + 'resume', + 'exec_1', + '--workflow', + 'wf_1', + '--context', + 'ctx_1', + '--input', + '{"approved":true}', + ]) + expect(resumePath).toBe('/api/v2/workflows/wf_1/executions/exec_1/resume') + expect(resumeOptions.body).toEqual({ + contextId: 'ctx_1', + input: { approved: true }, + }) + }) + + it('supports organization-wide audit listing explicitly', async () => { + const help = commandAt('audit-logs', 'list').helpInformation() + expect(help).toMatch(/--organization .*personal API key required.*required/s) + expect(help).toContain('--all-workspaces') + + const [, scopedOptions] = await run(['audit-logs', 'list', '--organization', 'org_1']) + expect(scopedOptions.query).toMatchObject({ + organizationId: 'org_1', + workspaceId: 'ws_local', + }) + + const [, organizationOptions] = await run([ + 'audit-logs', + 'list', + '--organization', + 'org_1', + '--all-workspaces', + ]) + expect(organizationOptions.query).toMatchObject({ + organizationId: 'org_1', + limit: 100, + }) + expect(organizationOptions.query).not.toHaveProperty('workspaceId') + + const [detailPath, detailOptions] = await run([ + 'audit-logs', + 'get', + 'audit_1', + '--organization', + 'org_1', + ]) + expect(detailPath).toBe('/api/v2/audit-logs/audit_1') + expect(detailOptions.query).toEqual({ organizationId: 'org_1' }) + }) + + it('describes asynchronous workflow runs without a contradictory negative flag', () => { + const help = commandAt('workflows', 'run').helpInformation() + expect(commandAt('workflows', 'run').description()).toBe('Run a deployed workflow') + expect(help).toContain('--async') + expect(help).not.toContain('--no-async') }) }) @@ -559,10 +733,10 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps sensitive execution detail out of human log output', async () => { + it('keeps sensitive execution detail opt-in for human log output', async () => { const log = { - id: 'log_1', executionId: 'exec_1', + status: 'completed', workflow: { name: 'Billing' }, level: 'info', trigger: 'api', @@ -571,7 +745,8 @@ describe('single-resource rendering', () => { totalDurationMs: 50, cost: { total: 0.001 }, files: [], - executionData: { env: { SECRET_TOKEN: 'encrypted-value' } }, + workflowState: { env: { SECRET_TOKEN: 'encrypted-value' } }, + finalOutput: { recipient: 'private@example.com' }, traceSpans: [ { id: 'span_1', @@ -582,26 +757,41 @@ describe('single-resource rendering', () => { id: 'span_2', name: 'Send email', type: 'block', - input: { recipient: 'private@example.com' }, + status: 'completed', + durationMs: 25, + cost: { total: 0.0005 }, + input: { recipient: 'trace-secret@example.com' }, + output: { delivered: true }, }, ], }, ], } - const human = await lines(['logs', 'get', 'log_1'], log, 'text') - expect(human.join('\n')).not.toContain('executionData') + const human = await lines(['logs', 'get', 'exec_1'], log, 'text') + expect(human.join('\n')).not.toContain('workflowState') expect(human.join('\n')).not.toContain('SECRET_TOKEN') expect(human.join('\n')).not.toContain('traceSpans') expect(human.join('\n')).not.toContain('private@example.com') - - const machine = await lines(['logs', 'get', 'log_1'], log, 'json') + expect(human.join('\n')).not.toContain('trace-secret@example.com') + expect(human.join('\n')).toContain('trace\t2 spans (use --trace)') + + const expanded = await lines(['logs', 'get', 'exec_1', '--trace'], log, 'text') + expect(expanded.join('\n')).toContain('trace\t2 spans') + expect(expanded.join('\n')).not.toContain('(use --trace)') + expect(expanded.join('\n')).toContain('Workflow Execution [workflow]') + expect(expanded.join('\n')).toContain('Send email [block] completed 25ms $0.0005') + expect(expanded.join('\n')).toContain('trace-secret@example.com') + expect(expanded.join('\n')).toContain('"delivered": true') + + const machine = await lines(['logs', 'get', 'exec_1'], log, 'json') expect(JSON.parse(machine[0])).toMatchObject({ - executionData: log.executionData, + workflowState: log.workflowState, traceSpans: log.traceSpans, + finalOutput: log.finalOutput, }) - const yaml = await lines(['logs', 'get', 'log_1'], log, 'yaml') + const yaml = await lines(['logs', 'get', 'exec_1'], log, 'yaml') expect(yaml.join('\n')).toContain('traceSpans:') expect(yaml.join('\n')).toContain('span_2') }) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 1b68242651b..3028419957a 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -22,6 +22,42 @@ const GROUP_ALIASES: Readonly> = { workflows: 'workflow', } +function argumentSyntax(command: Command): string { + return command.registeredArguments + .map((argument) => { + const name = `${argument.name()}${argument.variadic ? '...' : ''}` + return argument.required ? `<${name}>` : `[${name}]` + }) + .join(' ') +} + +function commandPath(command: Command): string { + const names: string[] = [] + let current: Command | null = command + while (current) { + names.unshift(current.name()) + current = current.parent + } + return names.join(' ') +} + +function addMissingArgumentExample(command: Command): Command { + const outputError = command.configureOutput().outputError + if (!outputError) throw new Error('Commander output formatter is not configured') + + command.configureOutput({ + outputError: (message, write) => { + outputError(message, write) + if (!message.startsWith('error: missing required argument ')) return + + const syntax = argumentSyntax(command) + const example = syntax ? `${commandPath(command)} ${syntax}` : commandPath(command) + write(`Example: ${example}\n`) + }, + }) + return command +} + function configureOperation( command: Command, operation: V2OperationName, @@ -43,6 +79,13 @@ function configureOperation( command.argument(`<${param}>`) } + if (spec.allWorkspaces) { + const workspace = operationSpec.query?.workspaceId ?? operationSpec.body?.workspaceId + if (!workspace || workspace.required) { + throw new Error(`${operation}.allWorkspaces requires an optional workspaceId field`) + } + } + for (const field of spec.positionals ?? []) { const descriptor = operationSpec.query?.[field] ?? operationSpec.body?.[field] if (!descriptor) throw new Error(`${operation}.${field} is not a request field`) @@ -82,7 +125,7 @@ function configureOperation( } function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: string): Command { - return configureOperation(new Command(leafName), operation, spec) + return addMissingArgumentExample(configureOperation(new Command(leafName), operation, spec)) } function groupFor(groups: Map, name: string): Command { diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 29fd9e7abba..71acec7bc05 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -23,12 +23,19 @@ export async function executeOperation( invocation: unknown[] ): Promise { const host = invocation[invocation.length - 1] as Command - const flags = invocation[invocation.length - 2] as Record + const inheritedFlags = host.optsWithGlobals() as Record + const flags: Record = { + ...(inheritedFlags.workspace === undefined ? {} : { workspace: inheritedFlags.workspace }), + ...(inheritedFlags.allWorkspaces === undefined + ? {} + : { allWorkspaces: inheritedFlags.allWorkspaces }), + ...(invocation[invocation.length - 2] as Record), + } const pathPositionalCount = operationSpec.pathParams.filter( (param) => !commandSpec.pathFlags?.[param] ).length const positional = invocation.slice(0, pathPositionalCount) as string[] - const requestFlags = { ...flags } + const requestFlags: Record = { ...flags } for (const [index, field] of (commandSpec.positionals ?? []).entries()) { requestFlags[camel(flagNameFor(operation, field))] = invocation[pathPositionalCount + index] } @@ -37,16 +44,21 @@ export async function executeOperation( throw new SimApiError(`${commandSpec.confirm} Re-run with --yes to confirm.`, 0) } + if (commandSpec.allWorkspaces && requestFlags.allWorkspaces && requestFlags.workspace) { + throw new SimApiError('--all-workspaces cannot be combined with --workspace', 0) + } + const { client, profile } = clientFrom(host) - const needsWorkspace = Boolean( + const hasWorkspaceField = Boolean( (operationSpec.query && PROFILE_INJECTED_FIELD in operationSpec.query) || (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) ) + const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true const request = buildRequest( operation, positional, requestFlags, - needsWorkspace ? client.requireWorkspace() : profile.workspaceId + hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) @@ -84,5 +96,7 @@ export async function executeOperation( query: request.query, body: request.body, }) - renderResult(operation, profile.output, result?.data ?? result, commandSpec) + renderResult(operation, profile.output, result?.data ?? result, commandSpec, { + expandedTrace: requestFlags.trace === true, + }) } diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index 9b13d33292e..5ca5757e034 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -104,6 +104,20 @@ export function addOperationOptions( } } + if (commandSpec.allWorkspaces) { + command.option( + '--all-workspaces', + 'Do not filter to the configured workspace (personal API key required for account-wide access)' + ) + } + + if (commandSpec.expandedTrace) { + command.option( + '--trace', + 'Show expanded trace spans with inputs, outputs, errors, timing, and cost' + ) + } + if (operationSpec.opaqueBody) { if (commandSpec.bodyVariants) { for (const variant of commandSpec.bodyVariants) { diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 0190a74c289..329bdcb8404 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -24,6 +24,11 @@ describe('buildRequest', () => { expect(built.body).toBeUndefined() }) + it('omits an optional profile workspace when all workspaces are requested', () => { + const built = buildRequest('listBillingLogs', [], { allWorkspaces: true }, WORKSPACE) + expect(built.query).not.toHaveProperty('workspaceId') + }) + 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' }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index cf8d6e56e5d..398a1c11405 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -291,7 +291,13 @@ export function buildRequest( const flagName = flagNameFor(operation, field) // 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 omitProfileWorkspace = commandSpec.allWorkspaces && flags.allWorkspaces === true + const raw = + field === PROFILE_INJECTED_FIELD + ? omitProfileWorkspace + ? undefined + : workspaceId + : flags[camel(flagName)] const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index e9b990e6f2d..fba5d5dc790 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -13,6 +13,21 @@ import { text, timestamp, } from '../output/render.js' +import { printTraceSpans } from '../output/trace.js' + +interface RenderResultOptions { + expandedTrace?: boolean +} + +function countTraceSpans(value: unknown): number { + if (!Array.isArray(value)) return 0 + return value.reduce((count, span) => { + if (!span || typeof span !== 'object' || Array.isArray(span)) { + throw new Error('Trace contains a malformed span') + } + return count + 1 + countTraceSpans((span as Record).children) + }, 0) +} function at(row: unknown, path: string): unknown { return path @@ -23,7 +38,11 @@ function at(row: unknown, path: string): unknown { ) } -function renderCell(value: unknown, format: ColumnSpec['format']): string { +function renderCell( + value: unknown, + format: ColumnSpec['format'], + options: RenderResultOptions = {} +): string { switch (format) { case 'timestamp': return timestamp(value as string | null) @@ -37,6 +56,12 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { return typeof value === 'number' ? `$${value.toFixed(4)}` : text(null) case 'count': return Array.isArray(value) ? String(value.length) : text(null) + case 'trace-count': { + const count = countTraceSpans(value) + return `${count} ${count === 1 ? 'span' : 'spans'}${ + options.expandedTrace ? '' : ' (use --trace)' + }` + } default: if (value === null || value === undefined || value === '') return text(null) return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value)) @@ -57,11 +82,15 @@ function columnsFrom(specs: ColumnSpec[]): Column[] { })) } -function fieldsFrom(data: unknown, specs: ColumnSpec[]): Array<[string, string]> { - return specs.map((spec) => [ - spec.header, - renderCell(at(data, spec.path ?? spec.header), spec.format), - ]) +function fieldsFrom( + data: unknown, + specs: ColumnSpec[], + options: RenderResultOptions = {} +): Array<[string, string]> { + return specs.flatMap((spec) => { + const value = at(data, spec.path ?? spec.header) + return value === undefined ? [] : [[spec.header, renderCell(value, spec.format, options)]] + }) } function inferColumns(rows: unknown[], expand?: string): Column[] { @@ -118,7 +147,8 @@ export function renderResult( operation: V2OperationName, format: OutputFormat, raw: unknown, - spec: CommandSpec + spec: CommandSpec, + options: RenderResultOptions = {} ): void { if (spec.document) { printDocument(format, raw) @@ -150,10 +180,17 @@ export function renderResult( } const fields = spec.fields - ? fieldsFrom(data, spec.fields) + ? fieldsFrom(data, spec.fields, options) : data && typeof data === 'object' ? Object.entries(data).map<[string, string]>(([key, value]) => [key, recordCell(value)]) : [] printRecord(format, fields, data) + if (spec.expandedTrace && options.expandedTrace) { + const traceSpans = at(data, 'traceSpans') + if (!Array.isArray(traceSpans)) { + throw new Error(`${operation} expected a traceSpans array`) + } + printTraceSpans(format, traceSpans) + } } From dc6f7fbf33de7e7be0436c0f94e8bcf7051eb4f7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 20:25:07 -0700 Subject: [PATCH 41/46] feat(cli): sync v2 API and personal login defaults --- apps/sim/app/cli/auth/cli-auth-view.test.tsx | 19 +- apps/sim/app/cli/auth/cli-auth-view.tsx | 44 +- packages/sim-cli/README.md | 24 +- packages/sim-cli/src/contract/commands.ts | 90 ++- packages/sim-cli/src/generated/v2-api.ts | 566 +++++++++---------- packages/sim-cli/src/http/client.test.ts | 4 +- packages/sim-cli/src/output/trace.ts | 2 +- packages/sim-cli/src/runtime/build.test.ts | 132 +++-- packages/sim-cli/src/runtime/build.ts | 2 + 9 files changed, 460 insertions(+), 423 deletions(-) diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx index 01d908daf05..c2f4e9b6005 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.test.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -81,22 +81,22 @@ describe('CliAuthView workspace loading', () => { }) 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. + // The regression: while pending, the picker falls back to no default, so an + // early click saved no workspace when the same click a moment later would + // have saved the user's last active 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)') + expect(container.textContent).not.toContain('No default workspace') }) - it('does not present the personal-key wording as the answer while loading', () => { + it('does not present a workspace choice as final while loading', () => { mockUseWorkspaces.mockReturnValue({ isPending: true, isError: false, data: undefined }) render() - expect(container.textContent).toContain('Checking which workspaces') + expect(container.textContent).toContain('Loading your workspace options') expect(container.textContent).not.toContain('Issues a personal key') }) @@ -106,10 +106,11 @@ describe('CliAuthView workspace loading', () => { expect(connectButton().disabled).toBe(false) expect(container.textContent).toContain('Acme') - expect(container.textContent).toContain('only reach Acme') + expect(container.textContent).toContain('personal key') + expect(container.textContent).toContain('makes Acme the CLI default') }) - it('binds the key to the workspace when the approver is an admin', () => { + it('issues a personal key even when the approver is a workspace admin', () => { mockUseWorkspaces.mockReturnValue(LOADED) render() act(() => { @@ -120,7 +121,7 @@ describe('CliAuthView workspace loading', () => { expect.objectContaining({ scope: 'platform', workspaceId: 'ws_admin', - bindKeyToWorkspace: true, + bindKeyToWorkspace: false, }), expect.anything() ) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 080b872f297..f7865af95da 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -11,8 +11,8 @@ 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__' +/** Sentinel for the "no default workspace" row; an empty string reads as unselected. */ +const NO_DEFAULT_WORKSPACE_VALUE = '__no_default_workspace__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -40,7 +40,7 @@ export function CliAuthView() { label: workspace.name, value: workspace.id, })) - return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + return [...rows, { label: 'No default workspace', value: NO_DEFAULT_WORKSPACE_VALUE }] }, [workspaces.data]) if (!resolution.valid) { @@ -63,11 +63,10 @@ export function CliAuthView() { * 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. + * as "No default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click save no default when a + * moment later the same click would have saved the user's workspace. Blocking + * is the only way the card can promise what it is about to configure. */ const loadingWorkspaces = isPlatform && workspaces.isPending @@ -88,11 +87,6 @@ export function CliAuthView() { 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 - // 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 (
Default workspace

{loadingWorkspaces - ? 'Checking which workspaces you can issue a key for…' + ? 'Loading your workspace options…' : 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}.` - : 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.'} + : chosen + ? `Issues a personal key tied to your account and makes ${chosen.name} the CLI default.` + : // 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.'}

)} @@ -151,10 +143,10 @@ export function CliAuthView() { 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. + // The picked workspace is only the terminal's default. Login + // always mints a personal key so the profile can switch workspaces. ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), - bindKeyToWorkspace: isPlatform && bindsToWorkspace, + bindKeyToWorkspace: false, }, { onSuccess: () => router.push('/cli/auth/done') } ) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index fe265772d95..55625cea447 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -123,13 +123,13 @@ sim workflows update [--name ] [--description ] [--folder sim workflows deploy|undeploy|rollback sim workflows run [--input ] [--select-output …] [--async] -sim workflows executions list --workflow [--status ] -sim workflows executions get --workflow [--include-output] -sim workflows executions cancel --workflow -sim workflows executions resume --workflow --context [--input ] +sim workflows runs list --workflow [--status ] +sim workflows runs get --workflow [--include-output] +sim workflows runs cancel --workflow +sim workflows runs resume --workflow --context [--input ] sim logs list [--level error] [--workflow …] [--trigger …] [--start-date ] -sim logs get +sim logs get sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization @@ -178,17 +178,17 @@ The `sim-chat` billing source combines Copilot and workspace chat usage. Organization audit logs require a personal API key. Commands with `--all-workspaces` otherwise default to the workspace in the active profile. -`workflows executions get` is the lightweight status and polling resource. -`--workflow` names the parent resource, while the execution ID remains positional. -For a paused execution, its status includes the context ID needed by `resume`. +`workflows runs get` is the lightweight status and polling resource. +`--workflow` names the parent resource, while the run ID remains positional. +For a paused run, its status includes the context ID needed by `resume`. `logs get` is the full diagnostic resource. It keeps the default human output concise; add `--trace` for the expanded recursive trace with span inputs, outputs, errors, timing, and cost. JSON and YAML retain the complete structured response: ```bash -sim logs get --trace -sim logs get --output json | jq '.traceSpans' +sim logs get --trace +sim logs get --output json | jq '.traceSpans' sim logs list --include-trace-spans --output json ``` @@ -272,8 +272,8 @@ parsing. sim configure --set-output json # for this profile, from now on sim configure --set-output text --profile scripts # a profile dedicated to scripting -sim --output json logs list --level error | jq -r '.[].executionId' -sim logs list --level error --output json | jq -r '.[].executionId' +sim --output json logs list --level error | jq -r '.[].runId' +sim logs list --level error --output json | jq -r '.[].runId' 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 diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index e35b8f7f470..31fab68ccb4 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -25,7 +25,7 @@ const KNOWLEDGE_DOCUMENT_SCOPE = { describe: 'Knowledge base ID', }, } as const -const WORKFLOW_EXECUTION_SCOPE = { +const WORKFLOW_RUN_SCOPE = { id: { name: 'workflow', placeholder: 'workflowId', @@ -92,7 +92,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'source' }, { header: 'workflow', path: 'workflow.name' }, { header: 'credits', path: 'creditCost' }, - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'id' }, ], }, @@ -124,6 +124,10 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows undeploy', describe: 'Take a workflow out of deployment', }, + setSecret: { + command: 'secrets set', + describe: 'Create or replace a named secret', + }, // ─── Destructive single-resource operations ─────────────────────────────── deleteTable: { confirm: 'This deletes the table and all of its rows.' }, @@ -144,8 +148,8 @@ export const CLI_CONTRACT: CliContract = { deleteMcpServer: { confirm: 'This removes the MCP server and the tools it provides.', }, - deleteCredential: { - confirm: 'This deletes the credential; anything authenticating with it stops working.', + deleteSecret: { + confirm: 'This deletes the secret; anything using it may stop working.', }, deleteWorkflow: { confirm: 'This deletes the workflow and its run history.' }, deleteTableView: { confirm: 'This deletes the saved view and its filters.' }, @@ -184,14 +188,14 @@ export const CLI_CONTRACT: CliContract = { { header: 'workflow', path: 'workflow.name' }, { header: 'duration', path: 'totalDurationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost' }, - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, ], }, getLog: { - describe: 'Show execution diagnostics', + describe: 'Show run diagnostics', expandedTrace: true, fields: [ - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'workflow', path: 'workflow.name' }, { header: 'status' }, { header: 'level' }, @@ -326,6 +330,7 @@ export const CLI_CONTRACT: CliContract = { { header: 'folder', path: 'folderPath' }, { header: 'size', format: 'bytes' }, { header: 'type' }, + { header: 'uploaded by', path: 'uploadedByEmail' }, { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, ], }, @@ -393,6 +398,35 @@ export const CLI_CONTRACT: CliContract = { { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ], }, + listSecrets: { + columns: [ + { header: 'name' }, + { header: 'scope' }, + { header: 'role' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + getWorkspace: { + fields: [ + { header: 'id' }, + { header: 'name' }, + { header: 'mode' }, + { header: 'members', path: 'memberCount' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + ], + }, + listWorkspaceMembers: { + command: 'workspaces members', + describe: 'List workspace members', + columns: [ + { header: 'email' }, + { header: 'name' }, + { header: 'role' }, + { header: 'external', path: 'isExternal', format: 'bool' }, + { header: 'joined', path: 'joinedAt', format: 'timestamp' }, + ], + }, listAuditLogs: { allWorkspaces: true, @@ -645,7 +679,7 @@ export const CLI_CONTRACT: CliContract = { document: true, }, - // ─── Execution ──────────────────────────────────────────────────────────── + // ─── Runs ───────────────────────────────────────────────────────────────── // 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`. @@ -653,7 +687,7 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows run', describe: 'Run a deployed workflow', flags: { - async: { boolean: true, describe: 'Queue the execution and return immediately' }, + async: { boolean: true, describe: 'Queue the run and return immediately' }, input: { json: true, describe: 'Trigger input as JSON' }, selectedOutputs: { name: 'select-output', @@ -670,10 +704,10 @@ export const CLI_CONTRACT: CliContract = { includeToolCalls: { omit: true }, }, }, - getWorkflowExecution: { - command: 'workflows executions get', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'Show execution status (requested outputs are included in JSON or YAML output)', + getWorkflowRun: { + command: 'workflows runs get', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Show run status (requested outputs are included in JSON or YAML output)', flags: { includeOutput: { boolean: true, @@ -686,7 +720,7 @@ export const CLI_CONTRACT: CliContract = { }, }, fields: [ - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'workflow', path: 'workflowId' }, { header: 'status' }, { header: 'trigger' }, @@ -703,34 +737,34 @@ export const CLI_CONTRACT: CliContract = { { header: 'error', path: 'error.message' }, ], }, - listWorkflowExecutions: { - command: 'workflows executions list', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'List executions for a workflow', + listWorkflowRuns: { + command: 'workflows runs list', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'List runs for a workflow', columns: [ { header: 'started', path: 'startedAt', format: 'timestamp' }, { header: 'status' }, { header: 'trigger' }, { header: 'duration', path: 'durationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost' }, - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, ], }, - cancelWorkflowExecution: { - command: 'workflows executions cancel', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'Cancel a running execution', + cancelWorkflowRun: { + command: 'workflows runs cancel', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Cancel a running workflow run', // Not `confirm`-gated: cancelling is recoverable (re-run it), and the // whole point is to stop something that is already going wrong. }, resumeWorkflow: { - command: 'workflows executions resume', - pathFlags: WORKFLOW_EXECUTION_SCOPE, - describe: 'Resume a paused execution (output is included in JSON or YAML output)', + command: 'workflows runs resume', + pathFlags: WORKFLOW_RUN_SCOPE, + describe: 'Resume a paused run (output is included in JSON or YAML output)', flags: { contextId: { name: 'context', - describe: 'Pause context ID returned by execution status', + describe: 'Pause context ID returned by run status', }, input: { json: true, @@ -738,7 +772,7 @@ export const CLI_CONTRACT: CliContract = { }, }, fields: [ - { header: 'execution', path: 'executionId' }, + { header: 'run', path: 'runId' }, { header: 'workflow', path: 'workflowId' }, { header: 'status' }, { header: 'status URL', path: 'statusUrl' }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 039261dceec..105429fb9d1 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -47,7 +47,7 @@ export type AbortFileUploadResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } | null @@ -329,16 +329,16 @@ export type CancelTableRunsResponse = { } } -/** `POST /api/v2/workflows/[id]/executions/[executionId]/cancel` */ -export type CancelWorkflowExecutionParams = { +/** `POST /api/v2/workflows/[id]/runs/[runId]/cancel` */ +export type CancelWorkflowRunParams = { id: string - executionId: string + runId: string } -export type CancelWorkflowExecutionResponse = { +export type CancelWorkflowRunResponse = { data: { success: boolean - executionId: string + runId: string redisAvailable: boolean durablyRecorded: boolean locallyAborted: boolean @@ -389,7 +389,7 @@ export type CompleteFileUploadResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } | null @@ -493,43 +493,6 @@ export type CompleteTableImportResponse = { } } -/** `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 - dataCenter?: 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 @@ -591,7 +554,7 @@ export type CreateFileResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } @@ -649,7 +612,7 @@ export type CreateFileUploadResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } | null @@ -1280,7 +1243,7 @@ export type CreateTableViewResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string } @@ -1330,22 +1293,6 @@ export type CreateWorkflowFolderResponse = { } } -/** `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 @@ -1463,6 +1410,24 @@ export type DeleteMcpServerResponse = { } } +/** `DELETE /api/v2/secrets/[name]` */ +export type DeleteSecretParams = { + name: string +} + +export type DeleteSecretQuery = { + workspaceId: string + scope: 'workspace' | 'personal' +} + +export type DeleteSecretResponse = { + data: { + name: string + scope: 'workspace' | 'personal' + deleted: true + } +} + /** `DELETE /api/v2/skills/[id]` */ export type DeleteSkillParams = { id: string @@ -1726,9 +1691,13 @@ export type ExecuteWorkflowBody = { base64MaxBytes?: number } +export type ExecuteWorkflowHeaders = { + 'x-run-id'?: string +} + export type ExecuteWorkflowResponse = { data: { - executionId: string + runId: string workflowId: string status: 'completed' | 'failed' | 'paused' | 'cancelled' output: unknown @@ -1916,7 +1885,6 @@ export type GetAuditLogResponse = { data: { id: string workspaceId: string | null - actorId: string | null actorName: string | null actorEmail: string | null action: string @@ -1951,33 +1919,6 @@ export type GetBillingStatusResponse = { } } -/** `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 @@ -2028,7 +1969,7 @@ export type GetFileResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } @@ -2132,9 +2073,9 @@ export type GetKnowledgeDocumentResponse = { } } -/** `GET /api/v2/logs/[executionId]` */ +/** `GET /api/v2/logs/[runId]` */ export type GetLogParams = { - executionId: string + runId: string } type GetLogResponseRef0 = { @@ -2181,7 +2122,7 @@ type GetLogResponseRef0 = { export type GetLogResponse = { data: { - executionId: string + runId: string workflowId: string | null deploymentVersionId: string | null status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -2196,7 +2137,7 @@ export type GetLogResponse = { name: string description: string | null folderPath: string | null - userId: string | null + ownerEmail: string | null workspaceId: string | null createdAt: string | null updatedAt: string | null @@ -2442,7 +2383,7 @@ export type GetTableViewResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string } @@ -2476,20 +2417,20 @@ export type GetWorkflowResponse = { } } -/** `GET /api/v2/workflows/[id]/executions/[executionId]` */ -export type GetWorkflowExecutionParams = { +/** `GET /api/v2/workflows/[id]/runs/[runId]` */ +export type GetWorkflowRunParams = { id: string - executionId: string + runId: string } -export type GetWorkflowExecutionQuery = { +export type GetWorkflowRunQuery = { includeOutput?: 'true' | 'false' selectedOutputs?: string } -export type GetWorkflowExecutionResponse = { +export type GetWorkflowRunResponse = { data: { - executionId: string + runId: string workflowId: string status: 'queued' | 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' trigger: string | null @@ -2503,7 +2444,6 @@ export type GetWorkflowExecutionResponse = { pauseKind: 'time' | 'human' | null blockedOnBlockId: string | null automaticResumeWaitingReason: string | null - pausedExecutionId: string pausePointCount: number resumedCount: number } | null @@ -2548,6 +2488,24 @@ export type GetWorkflowVersionResponse = { } } +/** `GET /api/v2/workspaces/[workspaceId]` */ +export type GetWorkspaceParams = { + workspaceId: string +} + +export type GetWorkspaceResponse = { + data: { + id: string + name: string + color: string + logoUrl: string | null + mode: 'personal' | 'organization' | 'grandfathered_shared' + memberCount: number + createdAt: string + updatedAt: string + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -2575,20 +2533,19 @@ export type ListAuditLogsQuery = { resourceType?: string resourceId?: string workspaceId?: string - actorId?: string startDate?: string endDate?: string includeDeparted?: 'true' | 'false' limit?: number cursor?: string organizationId: string + actorEmail?: string } export type ListAuditLogsResponse = { data: Array<{ id: string workspaceId: string | null - actorId: string | null actorName: string | null actorEmail: string | null action: string @@ -2641,7 +2598,7 @@ export type ListBillingLogsResponse = { id: string name: string | null } | null - executionId: string | null + runId: string | null creditCost: number }> nextCursor: string | null @@ -2650,7 +2607,7 @@ export type ListBillingLogsResponse = { /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string - type?: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + type?: 'oauth' | 'service_account' providerId?: string search?: string sortBy?: 'displayName' | 'createdAt' | 'updatedAt' @@ -2660,12 +2617,11 @@ export type ListCredentialsQuery = { export type ListCredentialsResponse = { data: Array<{ id: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' + type: 'oauth' | 'service_account' displayName: string description: string | null providerId: string | null accountId: string | null - envKey: string | null hasServiceAccountKey: boolean role: 'admin' | 'member' createdAt: string @@ -2744,7 +2700,7 @@ export type ListFilesResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string }> @@ -2856,7 +2812,6 @@ export type ListLogsQuery = { level?: 'info' | 'error' startDate?: string endDate?: string - executionId?: string minDurationMs?: number maxDurationMs?: number minCost?: number @@ -2868,6 +2823,7 @@ export type ListLogsQuery = { limit?: number cursor?: string order?: 'desc' | 'asc' + runId?: string folderPaths?: string } @@ -2915,7 +2871,7 @@ type ListLogsResponseRef0 = { export type ListLogsResponse = { data: Array<{ - executionId: string + runId: string workflowId: string | null deploymentVersionId: string | null status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' @@ -2974,6 +2930,26 @@ export type ListMcpServersResponse = { nextCursor: string | null } +/** `GET /api/v2/secrets` */ +export type ListSecretsQuery = { + workspaceId: string + scope?: 'workspace' | 'personal' + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export type ListSecretsResponse = { + data: Array<{ + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string + }> + nextCursor: string | null +} + /** `GET /api/v2/skills` */ export type ListSkillsQuery = { workspaceId: string @@ -3115,44 +3091,13 @@ export type ListTableViewsResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string }> nextCursor: string | null } -/** `GET /api/v2/workflows/[id]/executions` */ -export type ListWorkflowExecutionsParams = { - id: string -} - -export type ListWorkflowExecutionsQuery = { - status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' - trigger?: string - startDate?: string - endDate?: string - limit?: number - cursor?: string - order?: 'asc' | 'desc' -} - -export type ListWorkflowExecutionsResponse = { - data: Array<{ - executionId: string - workflowId: string - status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' - trigger: string - startedAt: string - endedAt: string | null - durationMs: number | null - cost: { - total: number - } | null - }> - nextCursor: string | null -} - /** `GET /api/v2/workflows/folders` */ export type ListWorkflowFoldersQuery = { workspaceId: string @@ -3209,6 +3154,37 @@ export type ListWorkflowGroupsResponse = { nextCursor: string | null } +/** `GET /api/v2/workflows/[id]/runs` */ +export type ListWorkflowRunsParams = { + id: string +} + +export type ListWorkflowRunsQuery = { + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger?: string + startDate?: string + endDate?: string + limit?: number + cursor?: string + order?: 'asc' | 'desc' +} + +export type ListWorkflowRunsResponse = { + data: Array<{ + runId: string + workflowId: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused' + trigger: string + startedAt: string + endedAt: string | null + durationMs: number | null + cost: { + total: number + } | null + }> + nextCursor: string | null +} + /** `GET /api/v2/workflows` */ export type ListWorkflowsQuery = { workspaceId: string @@ -3262,6 +3238,28 @@ export type ListWorkflowVersionsResponse = { nextCursor: string | null } +/** `GET /api/v2/workspaces/[workspaceId]/members` */ +export type ListWorkspaceMembersParams = { + workspaceId: string +} + +export type ListWorkspaceMembersQuery = { + limit?: number + cursor?: string +} + +export type ListWorkspaceMembersResponse = { + data: Array<{ + email: string + name: string + image: string | null + role: 'admin' | 'write' | 'read' + isExternal: boolean + joinedAt: string + }> + nextCursor: string | null +} + /** `POST /api/v2/files/move` */ export type MoveFileItemsBody = { workspaceId: string @@ -3398,16 +3396,16 @@ export type RenameFileResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } } -/** `POST /api/v2/workflows/[id]/executions/[executionId]/resume` */ +/** `POST /api/v2/workflows/[id]/runs/[runId]/resume` */ export type ResumeWorkflowParams = { id: string - executionId: string + runId: string } export type ResumeWorkflowBody = { @@ -3418,7 +3416,7 @@ export type ResumeWorkflowBody = { export type ResumeWorkflowResponse = | { data: { - executionId: string + runId: string workflowId: string status: 'completed' | 'failed' | 'paused' | 'cancelled' output: unknown @@ -3444,7 +3442,7 @@ export type ResumeWorkflowResponse = } | { data: { - executionId: string + runId: string statusUrl: string queuePosition?: number } @@ -3565,6 +3563,29 @@ export type SearchKnowledgeResponse = { } } +/** `PUT /api/v2/secrets/[name]` */ +export type SetSecretParams = { + name: string +} + +export type SetSecretBody = { + workspaceId: string + scope: 'workspace' | 'personal' + value: string +} + +export type SetSecretResponse = { + data: { + secret: { + name: string + scope: 'workspace' | 'personal' + role: 'admin' | 'member' + createdAt: string + updatedAt: string + } + } +} + /** `GET /api/v2/tables/exports/[exportId]/download` */ export type TableExportDownloadParams = { exportId: string @@ -3621,44 +3642,6 @@ 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 - dataCenter?: 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 @@ -3725,7 +3708,7 @@ export type UpdateFileContentResponse = { type: string key: string folderPath: string - uploadedBy: string + uploadedByEmail: string uploadedAt: string updatedAt: string } @@ -4079,7 +4062,7 @@ export type UpdateTableViewResponse = { }> | null } isDefault: boolean - createdBy: string | null + createdByEmail: string | null createdAt: string updatedAt: string } @@ -4393,12 +4376,12 @@ export const V2_OPERATIONS = { excludeRowIds: { kind: 'array' }, }, }, - cancelWorkflowExecution: { + cancelWorkflowRun: { method: 'POST', - path: '/api/v2/workflows/[id]/executions/[executionId]/cancel', - pathParams: ['id', 'executionId'] as const, + path: '/api/v2/workflows/[id]/runs/[runId]/cancel', + pathParams: ['id', 'runId'] as const, responseMode: 'json', - summary: 'Cancel an execution', + summary: 'Cancel a run', }, completeFileUpload: { method: 'POST', @@ -4430,34 +4413,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - 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' }, - dataCenter: { kind: 'string' }, - }, - }, createCustomTool: { method: 'POST', path: '/api/v2/custom-tools', @@ -4728,16 +4683,6 @@ export const V2_OPERATIONS = { path: { kind: 'string', required: true }, }, }, - 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]', @@ -4812,6 +4757,17 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + deleteSecret: { + method: 'DELETE', + path: '/api/v2/secrets/[name]', + pathParams: ['name'] as const, + responseMode: 'json', + summary: 'Delete Secret', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['workspace', 'personal'] as const }, + }, + }, deleteSkill: { method: 'DELETE', path: '/api/v2/skills/[id]', @@ -4993,16 +4949,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string' }, }, }, - 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]', @@ -5055,8 +5001,8 @@ export const V2_OPERATIONS = { }, getLog: { method: 'GET', - path: '/api/v2/logs/[executionId]', - pathParams: ['executionId'] as const, + path: '/api/v2/logs/[runId]', + pathParams: ['runId'] as const, responseMode: 'json', summary: 'Get Log', }, @@ -5137,12 +5083,12 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workflow', }, - getWorkflowExecution: { + getWorkflowRun: { method: 'GET', - path: '/api/v2/workflows/[id]/executions/[executionId]', - pathParams: ['id', 'executionId'] as const, + path: '/api/v2/workflows/[id]/runs/[runId]', + pathParams: ['id', 'runId'] as const, responseMode: 'json', - summary: 'Get execution status', + summary: 'Get run status', query: { includeOutput: { kind: 'enum', values: ['true', 'false'] as const }, selectedOutputs: { kind: 'string' }, @@ -5155,6 +5101,13 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workflow Version', }, + getWorkspace: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'Get Workspace', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -5180,13 +5133,13 @@ export const V2_OPERATIONS = { 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' }, organizationId: { kind: 'string', required: true }, + actorEmail: { kind: 'string' }, }, }, listBillingLogs: { @@ -5230,10 +5183,7 @@ export const V2_OPERATIONS = { summary: 'List Credentials', query: { workspaceId: { kind: 'string', required: true }, - type: { - kind: 'enum', - values: ['oauth', 'env_workspace', 'env_personal', 'service_account'] as const, - }, + type: { kind: 'enum', values: ['oauth', 'service_account'] as const }, providerId: { kind: 'string' }, search: { kind: 'string' }, sortBy: { @@ -5380,7 +5330,6 @@ export const V2_OPERATIONS = { 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' }, @@ -5392,6 +5341,7 @@ export const V2_OPERATIONS = { limit: { kind: 'number', default: 100 }, cursor: { kind: 'string' }, order: { kind: 'enum', values: ['desc', 'asc'] as const, default: 'desc' }, + runId: { kind: 'string' }, folderPaths: { kind: 'string' }, }, }, @@ -5412,6 +5362,24 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, }, }, + listSecrets: { + method: 'GET', + path: '/api/v2/secrets', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Secrets', + query: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', values: ['workspace', 'personal'] as const }, + search: { kind: 'string' }, + sortBy: { + kind: 'enum', + values: ['name', 'createdAt', 'updatedAt'] as const, + default: 'name', + }, + sortOrder: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'asc' }, + }, + }, listSkills: { method: 'GET', path: '/api/v2/skills', @@ -5489,25 +5457,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, - listWorkflowExecutions: { - method: 'GET', - path: '/api/v2/workflows/[id]/executions', - pathParams: ['id'] as const, - responseMode: 'json', - summary: 'List workflow executions', - query: { - status: { - kind: 'enum', - values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, - }, - trigger: { kind: 'string' }, - startDate: { kind: 'string' }, - endDate: { kind: 'string' }, - limit: { kind: 'integer', default: 50 }, - cursor: { kind: 'string' }, - order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, - }, - }, listWorkflowFolders: { method: 'GET', path: '/api/v2/workflows/folders', @@ -5536,6 +5485,25 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, }, }, + listWorkflowRuns: { + method: 'GET', + path: '/api/v2/workflows/[id]/runs', + pathParams: ['id'] as const, + responseMode: 'json', + summary: 'List workflow runs', + query: { + status: { + kind: 'enum', + values: ['pending', 'running', 'completed', 'failed', 'cancelled', 'paused'] as const, + }, + trigger: { kind: 'string' }, + startDate: { kind: 'string' }, + endDate: { kind: 'string' }, + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + order: { kind: 'enum', values: ['asc', 'desc'] as const, default: 'desc' }, + }, + }, listWorkflows: { method: 'GET', path: '/api/v2/workflows', @@ -5568,6 +5536,17 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listWorkspaceMembers: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/members', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'List Workspace Members', + query: { + limit: { kind: 'integer', default: 50 }, + cursor: { kind: 'string' }, + }, + }, moveFileItems: { method: 'POST', path: '/api/v2/files/move', @@ -5655,10 +5634,10 @@ export const V2_OPERATIONS = { }, resumeWorkflow: { method: 'POST', - path: '/api/v2/workflows/[id]/executions/[executionId]/resume', - pathParams: ['id', 'executionId'] as const, + path: '/api/v2/workflows/[id]/runs/[runId]/resume', + pathParams: ['id', 'runId'] as const, responseMode: 'json', - summary: 'Resume a workflow execution', + summary: 'Resume a workflow run', body: { contextId: { kind: 'string', required: true }, input: { kind: 'unknown' }, @@ -5712,6 +5691,18 @@ export const V2_OPERATIONS = { searchMode: { kind: 'enum', default: 'vector' }, }, }, + setSecret: { + method: 'PUT', + path: '/api/v2/secrets/[name]', + pathParams: ['name'] as const, + responseMode: 'json', + summary: 'Set Secret', + body: { + workspaceId: { kind: 'string', required: true }, + scope: { kind: 'enum', required: true, values: ['workspace', 'personal'] as const }, + value: { kind: 'string', required: true }, + }, + }, tableExportDownload: { method: 'GET', path: '/api/v2/tables/exports/[exportId]/download', @@ -5729,27 +5720,6 @@ 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' }, - dataCenter: { kind: 'string' }, - }, - }, updateCustomTool: { method: 'PATCH', path: '/api/v2/custom-tools/[id]', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 8102090d8f5..17d3f235ebf 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -133,8 +133,8 @@ describe('generated operation table', () => { 'getLog', 'getBillingStatus', 'listBillingLogs', - 'listWorkflowExecutions', - 'getWorkflowExecution', + 'listWorkflowRuns', + 'getWorkflowRun', 'resumeWorkflow', 'listFiles', 'deleteFile', diff --git a/packages/sim-cli/src/output/trace.ts b/packages/sim-cli/src/output/trace.ts index 3661d758780..3e4380519fd 100644 --- a/packages/sim-cli/src/output/trace.ts +++ b/packages/sim-cli/src/output/trace.ts @@ -102,7 +102,7 @@ function renderSpan(value: unknown, depth: number): string[] { return lines } -/** Prints the complete recursive execution trace for an explicitly expanded log. */ +/** Prints the complete recursive run trace for an explicitly expanded log. */ export function printTraceSpans(format: OutputFormat, traceSpans: unknown[]): void { if (format === 'json' || format === 'yaml') return console.log('') diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index aaeb8436d5c..47baa706220 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -92,9 +92,11 @@ describe('commands parsed through commander', () => { knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', + secrets: 'secret', skills: 'skill', tables: 'table', workflows: 'workflow', + workspaces: 'workspace', } for (const [name, alias] of Object.entries(aliases)) { @@ -266,14 +268,14 @@ describe('commands parsed through commander', () => { '20', '--min-cost', '1', - '--execution-id', - 'exec_1', + '--run-id', + 'run_1', ]) expect(options.query).toMatchObject({ minDurationMs: 10, maxDurationMs: 20, minCost: 1, - executionId: 'exec_1', + runId: 'run_1', }) }) @@ -413,18 +415,34 @@ describe('commands parsed through commander', () => { expect(help).not.toContain('--no-recursive') }) - it('exposes credential data centers added by the v2 credential contract', async () => { - const [, options] = await run([ - 'credential', - 'create', - '--type', - 'service_account', - '--display-name', - 'Zoho', - '--data-center', - 'eu', + it('exposes named secrets separately from connected credentials', async () => { + const [path, options] = await run([ + 'secret', + 'set', + 'ZOHO_API_KEY', + '--scope', + 'workspace', + '--value', + 'test-secret', ]) - expect(options.body).toMatchObject({ dataCenter: 'eu' }) + expect(path).toBe('/api/v2/secrets/ZOHO_API_KEY') + expect(options.body).toEqual({ + workspaceId: 'ws_local', + scope: 'workspace', + value: 'test-secret', + }) + expect(commandAt('credentials').commands.map((command) => command.name())).toEqual(['list']) + }) + + it('exposes workspace metadata and email-attributed members', async () => { + const [workspacePath] = await run(['workspace', 'get', 'ws_target'], { + data: { id: 'ws_target' }, + }) + expect(workspacePath).toBe('/api/v2/workspaces/ws_target') + + const [membersPath, membersOptions] = await run(['workspace', 'members', 'ws_target']) + expect(membersPath).toBe('/api/v2/workspaces/ws_target/members') + expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) }) it('comma-joins a repeated list flag', async () => { @@ -516,7 +534,7 @@ describe('commands parsed through commander', () => { }) it('offers expanded trace output without changing the default summary', () => { - expect(commandAt('logs', 'get').description()).toBe('Show execution diagnostics') + expect(commandAt('logs', 'get').description()).toBe('Show run diagnostics') expect(commandAt('logs', 'get').helpInformation()).toMatch( /--trace.*inputs, outputs, errors, timing,\s+and cost/s ) @@ -525,26 +543,29 @@ describe('commands parsed through commander', () => { expect(listHelp).toMatch(/--include-final-output.*implies full detail/s) }) - it('uses a named workflow scope for execution subresources', async () => { - const executions = commandAt('workflows', 'executions') - expect(executions.commands.map((command) => command.name()).sort()).toEqual([ + it('uses a named workflow scope for run subresources', async () => { + expect(commandAt('workflows').commands.map((command) => command.name())).not.toContain( + 'executions' + ) + const runs = commandAt('workflows', 'runs') + expect(runs.commands.map((command) => command.name()).sort()).toEqual([ 'cancel', 'get', 'list', 'resume', ]) - const help = commandAt('workflows', 'executions', 'get').helpInformation() - expect(help).toContain('') + const help = commandAt('workflows', 'runs', 'get').helpInformation() + expect(help).toContain('') expect(help).toMatch(/--workflow .*required/s) expect(help).toContain('--include-output') expect(help).toContain('--select-output ') const [path, options] = await run([ 'workflows', - 'executions', + 'runs', 'get', - 'exec_1', + 'run_1', '--workflow', 'wf_1', '--include-output', @@ -552,35 +573,28 @@ describe('commands parsed through commander', () => { 'agent.content', 'writer.text', ]) - expect(path).toBe('/api/v2/workflows/wf_1/executions/exec_1') + expect(path).toBe('/api/v2/workflows/wf_1/runs/run_1') expect(options.query).toEqual({ includeOutput: true, selectedOutputs: 'agent.content,writer.text', }) - const [listPath] = await run(['workflows', 'executions', 'list', '--workflow', 'wf_1']) - expect(listPath).toBe('/api/v2/workflows/wf_1/executions') + const [listPath] = await run(['workflows', 'runs', 'list', '--workflow', 'wf_1']) + expect(listPath).toBe('/api/v2/workflows/wf_1/runs') - const [cancelPath] = await run([ - 'workflows', - 'executions', - 'cancel', - 'exec_1', - '--workflow', - 'wf_1', - ]) - expect(cancelPath).toBe('/api/v2/workflows/wf_1/executions/exec_1/cancel') + const [cancelPath] = await run(['workflows', 'runs', 'cancel', 'run_1', '--workflow', 'wf_1']) + expect(cancelPath).toBe('/api/v2/workflows/wf_1/runs/run_1/cancel') - const resumeHelp = commandAt('workflows', 'executions', 'resume').helpInformation() - expect(resumeHelp).toContain('') + const resumeHelp = commandAt('workflows', 'runs', 'resume').helpInformation() + expect(resumeHelp).toContain('') expect(resumeHelp).toMatch(/--workflow .*required/s) expect(resumeHelp).toMatch(/--context .*required/s) const [resumePath, resumeOptions] = await run([ 'workflows', - 'executions', + 'runs', 'resume', - 'exec_1', + 'run_1', '--workflow', 'wf_1', '--context', @@ -588,7 +602,7 @@ describe('commands parsed through commander', () => { '--input', '{"approved":true}', ]) - expect(resumePath).toBe('/api/v2/workflows/wf_1/executions/exec_1/resume') + expect(resumePath).toBe('/api/v2/workflows/wf_1/runs/run_1/resume') expect(resumeOptions.body).toEqual({ contextId: 'ctx_1', input: { approved: true }, @@ -599,11 +613,21 @@ describe('commands parsed through commander', () => { const help = commandAt('audit-logs', 'list').helpInformation() expect(help).toMatch(/--organization .*personal API key required.*required/s) expect(help).toContain('--all-workspaces') + expect(help).toContain('--actor-email') + expect(help).not.toContain('--actor-id') - const [, scopedOptions] = await run(['audit-logs', 'list', '--organization', 'org_1']) + const [, scopedOptions] = await run([ + 'audit-logs', + 'list', + '--organization', + 'org_1', + '--actor-email', + 'owner@example.com', + ]) expect(scopedOptions.query).toMatchObject({ organizationId: 'org_1', workspaceId: 'ws_local', + actorEmail: 'owner@example.com', }) const [, organizationOptions] = await run([ @@ -733,9 +757,9 @@ describe('single-resource rendering', () => { expect(JSON.parse(printed[0])).toEqual({ row: { id: 'r1' }, operation: 'inserted' }) }) - it('keeps sensitive execution detail opt-in for human log output', async () => { + it('keeps sensitive run detail opt-in for human log output', async () => { const log = { - executionId: 'exec_1', + runId: 'run_1', status: 'completed', workflow: { name: 'Billing' }, level: 'info', @@ -768,7 +792,7 @@ describe('single-resource rendering', () => { ], } - const human = await lines(['logs', 'get', 'exec_1'], log, 'text') + const human = await lines(['logs', 'get', 'run_1'], log, 'text') expect(human.join('\n')).not.toContain('workflowState') expect(human.join('\n')).not.toContain('SECRET_TOKEN') expect(human.join('\n')).not.toContain('traceSpans') @@ -776,7 +800,7 @@ describe('single-resource rendering', () => { expect(human.join('\n')).not.toContain('trace-secret@example.com') expect(human.join('\n')).toContain('trace\t2 spans (use --trace)') - const expanded = await lines(['logs', 'get', 'exec_1', '--trace'], log, 'text') + const expanded = await lines(['logs', 'get', 'run_1', '--trace'], log, 'text') expect(expanded.join('\n')).toContain('trace\t2 spans') expect(expanded.join('\n')).not.toContain('(use --trace)') expect(expanded.join('\n')).toContain('Workflow Execution [workflow]') @@ -784,14 +808,14 @@ describe('single-resource rendering', () => { expect(expanded.join('\n')).toContain('trace-secret@example.com') expect(expanded.join('\n')).toContain('"delivered": true') - const machine = await lines(['logs', 'get', 'exec_1'], log, 'json') + const machine = await lines(['logs', 'get', 'run_1'], log, 'json') expect(JSON.parse(machine[0])).toMatchObject({ workflowState: log.workflowState, traceSpans: log.traceSpans, finalOutput: log.finalOutput, }) - const yaml = await lines(['logs', 'get', 'exec_1'], log, 'yaml') + const yaml = await lines(['logs', 'get', 'run_1'], log, 'yaml') expect(yaml.join('\n')).toContain('traceSpans:') expect(yaml.join('\n')).toContain('span_2') }) @@ -838,7 +862,7 @@ describe('contract-selected list rendering', () => { expect(printed).toEqual(['3\trow_1\temail']) }) - it('maps custom-tool and credential fields to their actual response paths', async () => { + it('maps custom-tool, credential, and secret fields to their actual response paths', async () => { const tools = await lines( ['custom-tools', 'list'], [ @@ -866,6 +890,20 @@ describe('contract-selected list rendering', () => { ) expect(credentials[0]).toContain('Production Stripe') expect(credentials[0]).toContain('stripe') + + const secrets = await lines( + ['secrets', 'list'], + [ + { + name: 'STRIPE_API_KEY', + scope: 'workspace', + role: 'admin', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ] + ) + expect(secrets[0]).toContain('STRIPE_API_KEY') + expect(secrets[0]).toContain('workspace') }) }) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 3028419957a..f716a7ca649 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -17,9 +17,11 @@ const GROUP_ALIASES: Readonly> = { knowledge: 'kb', logs: 'log', 'mcp-servers': 'mcp-server', + secrets: 'secret', skills: 'skill', tables: 'table', workflows: 'workflow', + workspaces: 'workspace', } function argumentSyntax(command: Command): string { From f4b654728c155a563cf23e68ceef1d32b4d86c2c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 00:32:35 -0700 Subject: [PATCH 42/46] fix(cli): use profile workspace for workspace get --- packages/sim-cli/README.md | 3 +++ packages/sim-cli/src/contract/commands.ts | 1 + packages/sim-cli/src/contract/types.ts | 4 ++++ packages/sim-cli/src/runtime/build.test.ts | 15 ++++++++++++--- packages/sim-cli/src/runtime/build.ts | 17 ++++++++++++++--- packages/sim-cli/src/runtime/execute.ts | 9 +++++++-- packages/sim-cli/src/runtime/request.test.ts | 12 ++++++++++++ packages/sim-cli/src/runtime/request.ts | 20 ++++++++++++++++++-- 8 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 55625cea447..e84dd8061e9 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -134,6 +134,9 @@ sim logs get sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization +sim workspaces get +sim workspaces members + sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] sim tables get diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 31fab68ccb4..5f6b4706ed5 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -407,6 +407,7 @@ export const CLI_CONTRACT: CliContract = { ], }, getWorkspace: { + profileWorkspacePath: true, fields: [ { header: 'id' }, { header: 'name' }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 30838fa081f..d009ed630aa 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -18,6 +18,8 @@ import type { V2OperationName } from '../generated/v2-api.js' * "list". Also friendlier aliases (`conflictTarget` → `--on`). * - `pathFlags` — when a parent path segment is command context rather than the * resource being acted on (`documents get --kb `). + * - `profileWorkspacePath` — when `[workspaceId]` is the active profile target, + * not a resource argument (`workspaces get`). * - `columns` — which of a response's fields belong in a table. Editorial. * - `confirm` — which operations are destructive enough to demand `--yes`. * @@ -119,6 +121,8 @@ export interface CommandSpec { aliases?: readonly string[] /** Route path parameters exposed as required named options instead of positionals. */ pathFlags?: Record + /** Fill a `[workspaceId]` route segment from the active profile instead of an argument. */ + profileWorkspacePath?: boolean /** Request fields exposed as required positional arguments, in order. */ positionals?: readonly string[] /** Restrict this command to these request fields; profile fields remain implicit. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 47baa706220..7b9cccc06e7 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -435,10 +435,19 @@ describe('commands parsed through commander', () => { }) it('exposes workspace metadata and email-attributed members', async () => { - const [workspacePath] = await run(['workspace', 'get', 'ws_target'], { - data: { id: 'ws_target' }, + const getHelp = commandAt('workspaces', 'get').helpInformation() + expect(getHelp).not.toContain('') + + const [workspacePath] = await run(['workspace', 'get'], { + data: { id: 'ws_local' }, }) - expect(workspacePath).toBe('/api/v2/workspaces/ws_target') + expect(workspacePath).toBe('/api/v2/workspaces/ws_local') + + profileState.workspaceId = null + await expect(run(['workspace', 'get'])).rejects.toThrow( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + ) + profileState.workspaceId = 'ws_local' const [membersPath, membersOptions] = await run(['workspace', 'members', 'ws_target']) expect(membersPath).toBe('/api/v2/workspaces/ws_target/members') diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index f716a7ca649..d080060f135 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -5,7 +5,7 @@ import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' import { deriveCommandPath } from './derive.js' import { executeOperation } from './execute.js' import { addOperationOptions } from './options.js' -import { flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' +import { flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD } from './request.js' import type { OperationSpec } from './types.js' const GROUP_ALIASES: Readonly> = { @@ -76,8 +76,17 @@ function configureOperation( } } + if (spec.profileWorkspacePath) { + if (!operationSpec.pathParams.includes(PROFILE_INJECTED_FIELD)) { + throw new Error(`${operation}.profileWorkspacePath requires a workspaceId path parameter`) + } + if (spec.pathFlags?.[PROFILE_INJECTED_FIELD]) { + throw new Error(`${operation}.workspaceId cannot be both profile-injected and a path flag`) + } + } + for (const param of operationSpec.pathParams) { - if (spec.pathFlags?.[param]) continue + if (spec.pathFlags?.[param] || isProfileWorkspacePath(spec, param)) continue command.argument(`<${param}>`) } @@ -203,7 +212,9 @@ export function buildGeneratedCommands(): Command[] { const [groupName, ...rest] = segments const group = groupFor(groups, groupName) if (rest.length > 0) throw new Error(`${operation} groupDefault must name a command group`) - const pathPositionals = operationSpec.pathParams.filter((param) => !spec.pathFlags?.[param]) + const pathPositionals = operationSpec.pathParams.filter( + (param) => !spec.pathFlags?.[param] && !isProfileWorkspacePath(spec, param) + ) if (pathPositionals.length > 0 || spec.positionals?.length) { throw new Error(`${operation} groupDefault cannot require positional arguments`) } diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 71acec7bc05..73d6adbe6c6 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -5,7 +5,12 @@ import type { V2OperationName } from '../generated/v2-api.js' import { SimApiError, type V2Page } from '../http/client.js' import { camel } from './derive.js' import { DEFAULT_LIMIT } from './options.js' -import { buildRequest, flagNameFor, PROFILE_INJECTED_FIELD } from './request.js' +import { + buildRequest, + flagNameFor, + isProfileWorkspacePath, + PROFILE_INJECTED_FIELD, +} from './request.js' import { renderPage, renderResult } from './result.js' import type { OperationSpec } from './types.js' @@ -32,7 +37,7 @@ export async function executeOperation( ...(invocation[invocation.length - 2] as Record), } const pathPositionalCount = operationSpec.pathParams.filter( - (param) => !commandSpec.pathFlags?.[param] + (param) => !commandSpec.pathFlags?.[param] && !isProfileWorkspacePath(commandSpec, param) ).length const positional = invocation.slice(0, pathPositionalCount) as string[] const requestFlags: Record = { ...flags } diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 329bdcb8404..816cdec5374 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -66,6 +66,12 @@ describe('buildRequest', () => { expect(buildRequest('getTable', ['a/b?c'], {}, WORKSPACE).path).toBe('/api/v2/tables/a%2Fb%3Fc') }) + it('fills a configured workspace path segment from the profile', () => { + expect(buildRequest('getWorkspace', [], {}, WORKSPACE).path).toBe( + `/api/v2/workspaces/${WORKSPACE}` + ) + }) + it('combines a named parent scope with a positional resource id in route order', () => { expect(buildRequest('getKnowledgeDocument', ['doc_1'], { kb: 'kb_1' }, WORKSPACE)).toEqual({ path: '/api/v2/knowledge/kb_1/documents/doc_1', @@ -79,6 +85,12 @@ describe('buildRequest', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') }) + it('rejects a profile-backed workspace path when no workspace is configured', () => { + expect(() => buildRequest('getWorkspace', [], {}, null)).toThrow( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + ) + }) + it('rejects a missing required flag', () => { expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( '--data is required' diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 398a1c11405..92a8bbe1bba 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -22,6 +22,11 @@ export interface FieldSpec { */ export const PROFILE_INJECTED_FIELD = 'workspaceId' +/** Whether this path segment comes from the active profile's workspace. */ +export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): boolean { + return commandSpec.profileWorkspacePath === true && param === PROFILE_INJECTED_FIELD +} + /** Kinds the CLI can only accept as a JSON string. */ const JSON_KINDS = new Set(['object', 'array', 'unknown']) @@ -265,9 +270,20 @@ export function buildRequest( let positionalIndex = 0 for (const param of spec.pathParams) { const pathFlag = commandSpec.pathFlags?.[param] + const profileWorkspacePath = isProfileWorkspacePath(commandSpec, param) const flagName = pathFlagNameFor(commandSpec, param) - const value = pathFlag ? flags[camel(flagName)] : positional[positionalIndex++] - if (value === undefined) { + const value = profileWorkspacePath + ? workspaceId + : pathFlag + ? flags[camel(flagName)] + : positional[positionalIndex++] + if (value === undefined || value === null) { + if (profileWorkspacePath) { + throw new SimApiError( + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ', + 0 + ) + } throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${param}>`, 0) } if (typeof value !== 'string' || value.length === 0) { From d84ac1248e2f4a91e77593eba1386f4932cee966 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 00:40:46 -0700 Subject: [PATCH 43/46] fix(cli): use profile workspace for member listing --- packages/sim-cli/README.md | 2 +- packages/sim-cli/src/contract/commands.ts | 1 + packages/sim-cli/src/runtime/build.test.ts | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index e84dd8061e9..f502d857e6f 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -135,7 +135,7 @@ sim audit-logs list --organization [--all-workspaces] sim audit-logs get --organization sim workspaces get -sim workspaces members +sim workspaces members sim tables ls [path] [--search ] [--limit ] sim tables list [--folder ] diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 5f6b4706ed5..0ac23315923 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -420,6 +420,7 @@ export const CLI_CONTRACT: CliContract = { listWorkspaceMembers: { command: 'workspaces members', describe: 'List workspace members', + profileWorkspacePath: true, columns: [ { header: 'email' }, { header: 'name' }, diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 7b9cccc06e7..7f0fe794e4f 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -449,8 +449,11 @@ describe('commands parsed through commander', () => { ) profileState.workspaceId = 'ws_local' - const [membersPath, membersOptions] = await run(['workspace', 'members', 'ws_target']) - expect(membersPath).toBe('/api/v2/workspaces/ws_target/members') + const membersHelp = commandAt('workspaces', 'members').helpInformation() + expect(membersHelp).not.toContain('') + + const [membersPath, membersOptions] = await run(['workspace', 'members']) + expect(membersPath).toBe('/api/v2/workspaces/ws_local/members') expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) }) From 416f600d65a3736e5a947ea2ab8da4b6d35e9e71 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 02:18:10 -0700 Subject: [PATCH 44/46] fix(cli): restore nested knowledge document commands --- packages/sim-cli/README.md | 11 +-- .../sim-cli/src/commands/protocol/index.ts | 3 +- .../knowledge-document-upload.test.ts | 29 +++--- .../protocol/knowledge-document-upload.ts | 91 ++++++++++--------- packages/sim-cli/src/contract/commands.ts | 23 ++--- packages/sim-cli/src/contract/types.ts | 6 +- packages/sim-cli/src/runtime/build.test.ts | 41 +++------ packages/sim-cli/src/runtime/build.ts | 12 ++- packages/sim-cli/src/runtime/request.test.ts | 10 +- packages/sim-cli/src/runtime/request.ts | 5 +- 10 files changed, 113 insertions(+), 118 deletions(-) diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index f502d857e6f..12d00ee0f42 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -112,8 +112,7 @@ also accepts its singular form: for example, `sim table list`, `sim file download`, and `sim workflow get` are equivalent to their plural spellings. -`knowledge` also accepts the shorter `kb` alias, and `documents` accepts -`document`. +`knowledge` also accepts the shorter `kb` alias. ```bash sim workflows ls [path] [--search ] [--limit ] @@ -168,10 +167,10 @@ sim knowledge update [--name ] [--description ] [--folder sim knowledge search --query --kb … [--search-mode vector|hybrid] -sim documents list --kb [--search ] -sim documents get --kb -sim documents upload --kb [--tag ...] -sim documents delete --kb --yes +sim knowledge documents list [--search ] +sim knowledge documents get +sim knowledge documents upload [--tag ...] +sim knowledge documents delete --yes sim billing status [--all-workspaces] sim billing logs [--period 7d] [--source sim-chat] [--limit ] [--all-workspaces] diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 33939b7cdde..67df42a865b 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -25,9 +25,8 @@ export function attachProtocolCommands(program: Command): void { createFolder: 'createFileFolder', }) - attachKnowledgeDocumentUpload(group(program, 'documents')) - const knowledge = group(program, 'knowledge') + attachKnowledgeDocumentUpload(group(knowledge, 'documents')) attachResourceDirectoryCommands(knowledge, { kind: 'knowledge', resources: 'listKnowledgeBases', diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 18f6be45562..74eb2bdea35 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -62,17 +62,20 @@ function uploadSession() { } } -describe('documents upload', () => { +describe('knowledge documents upload', () => { it('owns the multipart protocol while hiding its low-level operations', () => { const root = program() const knowledge = root.commands.find((command) => command.name() === 'knowledge') - expect(knowledge?.commands.map((command) => command.name())).not.toEqual( - expect.arrayContaining(['documents', 'uploads', 'parts', 'complete']) - ) + expect(root.commands.map((command) => command.name())).not.toContain('documents') - const documents = root.commands.find((command) => command.name() === 'documents') - expect(documents?.alias()).toBe('document') + const documents = knowledge?.commands.find((command) => command.name() === 'documents') expect(documents?.commands.map((command) => command.name())).toContain('upload') + expect(documents?.commands.map((command) => command.name())).not.toEqual( + expect.arrayContaining(['uploads', 'parts', 'complete']) + ) + expect( + documents?.commands.find((command) => command.name() === 'upload')?.helpInformation() + ).toContain(' ') }) it('uploads a local document and prints the created document without transfer secrets', async () => { @@ -133,11 +136,11 @@ describe('documents upload', () => { await program().parseAsync([ 'node', 'sim', + 'kb', 'documents', 'upload', - path, - '--kb', 'kb_1', + path, '--tag', 'customer', 'priority', @@ -191,11 +194,11 @@ describe('documents upload', () => { program().parseAsync([ 'node', 'sim', + 'kb', 'documents', 'upload', - path, - '--kb', 'kb_1', + path, '--tag', '1', '2', @@ -210,13 +213,13 @@ describe('documents upload', () => { expect(mockRequest).not.toHaveBeenCalled() }) - it('requires an explicit knowledge-base scope before reading the file', async () => { + it('requires the knowledge-base argument before reading the file', async () => { const path = join(dir, 'notes.txt') writeFileSync(path, 'hello') await expect( - program().parseAsync(['node', 'sim', 'documents', 'upload', path]) - ).rejects.toThrow(/required option '--kb '/) + program().parseAsync(['node', 'sim', 'kb', 'documents', 'upload']) + ).rejects.toThrow(/missing required argument 'knowledgeBaseId'/) expect(mockRequest).not.toHaveBeenCalled() }) }) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index 8abf8c60b43..1a459930628 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -10,7 +10,6 @@ import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' interface KnowledgeDocumentUploadOptions { - kb: string name?: string tag?: string[] recipe?: string @@ -38,54 +37,62 @@ function uploadMetadata(options: KnowledgeDocumentUploadOptions): Record') + .command('upload ') .description('Upload a document to a knowledge base') - .requiredOption('--kb ', 'Knowledge base ID (required)') .option('--name ', 'Store it under a different name') .option('--tag ', 'Document tags, in tag1 through tag7 order') .option('--recipe ', 'Document processing recipe') .option('--lang ', 'Document language code') - .action(async (path: string, options: KnowledgeDocumentUploadOptions, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - const created = await client.request( - `/api/v2/knowledge/${encodeURIComponent(options.kb)}/documents/uploads`, - { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), + .action( + async ( + knowledgeBaseId: string, + path: string, + options: KnowledgeDocumentUploadOptions, + command: Command + ) => { + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const { name, size } = await localFile(path, options.name) + const created = await client.request( + `/api/v2/knowledge/${encodeURIComponent(knowledgeBaseId)}/documents/uploads`, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...uploadMetadata(options), + }, + } + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession< + CompleteKnowledgeDocumentUploadResponse['data'] + >( + client, + workspaceId, + { + basePath: `/api/v2/knowledge/${encodeURIComponent( + knowledgeBaseId + )}/documents/uploads/${encodeURIComponent(session.id)}`, + uploadToken, + transfer, size, - ...uploadMetadata(options), }, - } - ) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession( - client, - workspaceId, - { - basePath: `/api/v2/knowledge/${encodeURIComponent( - options.kb - )}/documents/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, - size, - }, - path - ) + path + ) - if (!completed.document) { - throw new Error(`Knowledge upload ${session.id} completed without a document`) + if (!completed.document) { + throw new Error(`Knowledge upload ${session.id} completed without a document`) + } + printProtocolResult(profile.output, { + id: completed.document.id, + knowledgeBaseId: completed.document.knowledgeBaseId, + name: completed.document.filename, + size: completed.document.fileSize, + status: completed.document.processingStatus, + }) } - printProtocolResult(profile.output, { - id: completed.document.id, - knowledgeBaseId: completed.document.knowledgeBaseId, - name: completed.document.filename, - size: completed.document.fileSize, - status: completed.document.processingStatus, - }) - }) + ) } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 0ac23315923..fab31bd615b 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -18,13 +18,7 @@ const FOLDER_DELETE_FLAGS = { path: FOLDER_PATH_INPUT, recursive: { boolean: true, describe: 'Delete the folder and its descendants' }, } as const -const KNOWLEDGE_DOCUMENT_SCOPE = { - id: { - name: 'kb', - placeholder: 'knowledgeBaseId', - describe: 'Knowledge base ID', - }, -} as const +const KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS = { id: 'knowledgeBaseId' } as const const WORKFLOW_RUN_SCOPE = { id: { name: 'workflow', @@ -57,7 +51,7 @@ function moveResource(command: string, resource: string): CommandVariantSpec { * * Derived by default: * listTables → sim tables list - * getKnowledgeDocument → sim documents get --kb + * getKnowledgeDocument → sim knowledge documents get * upsertTableRow → sim tables upsert */ export const CLI_CONTRACT: CliContract = { @@ -138,8 +132,7 @@ export const CLI_CONTRACT: CliContract = { }, deleteKnowledgeBase: { confirm: 'This deletes the knowledge base and every document in it.' }, deleteKnowledgeDocument: { - command: 'documents delete', - pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, confirm: 'This deletes the document and its embeddings.', }, deleteFile: { confirm: 'This archives the file.' }, @@ -346,13 +339,9 @@ export const CLI_CONTRACT: CliContract = { { header: 'model', path: 'embeddingModel' }, ], }, - getKnowledgeDocument: { - command: 'documents get', - pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, - }, + getKnowledgeDocument: { pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS }, listKnowledgeDocuments: { - command: 'documents list', - pathFlags: KNOWLEDGE_DOCUMENT_SCOPE, + pathArgumentNames: KNOWLEDGE_DOCUMENT_PATH_ARGUMENTS, columns: [ { header: 'id' }, { header: 'filename' }, @@ -787,7 +776,7 @@ export const CLI_CONTRACT: CliContract = { }, // ─── Not a terminal-shaped operation ────────────────────────────────────── - // Multipart upload; `sim documents upload --kb ` needs its + // Multipart upload; `sim knowledge documents upload ` needs its // own file-reading command rather than a generated flag surface. uploadKnowledgeDocument: { hidden: true }, createKnowledgeDocumentUpload: { hidden: true }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index d009ed630aa..d7df31c341e 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -17,7 +17,9 @@ import type { V2OperationName } from '../generated/v2-api.js' * is `z.string()` that the route splits on commas; nothing in the schema says * "list". Also friendlier aliases (`conflictTarget` → `--on`). * - `pathFlags` — when a parent path segment is command context rather than the - * resource being acted on (`documents get --kb `). + * resource being acted on (`workflows runs get --workflow `). + * - `pathArgumentNames` — when a route's generic `[id]` needs a clearer CLI + * placeholder (``). * - `profileWorkspacePath` — when `[workspaceId]` is the active profile target, * not a resource argument (`workspaces get`). * - `columns` — which of a response's fields belong in a table. Editorial. @@ -121,6 +123,8 @@ export interface CommandSpec { aliases?: readonly string[] /** Route path parameters exposed as required named options instead of positionals. */ pathFlags?: Record + /** Friendly placeholders for route path parameters that remain positional. */ + pathArgumentNames?: Record /** Fill a `[workspaceId]` route segment from the active profile instead of an argument. */ profileWorkspacePath?: boolean /** Request fields exposed as required positional arguments, in order. */ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 7f0fe794e4f..1299292d08c 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -87,7 +87,6 @@ describe('commands parsed through commander', () => { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', - documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -146,52 +145,38 @@ describe('commands parsed through commander', () => { expect(knowledgePath).toBe('/api/v2/knowledge') }) - it('uses top-level document commands with a named knowledge-base scope', async () => { - expect(commandAt('knowledge').commands.map((command) => command.name())).not.toContain( - 'documents' - ) + it('nests document commands under their knowledge base', async () => { + expect(program().commands.map((command) => command.name())).not.toContain('documents') - const help = commandAt('documents', 'get').helpInformation() - expect(help).toContain('') - expect(help).toMatch(/--kb .*required/s) - expect(help).not.toContain(' ') + const help = commandAt('knowledge', 'documents', 'get').helpInformation() + expect(help).toContain(' ') + expect(help).not.toContain('--kb') - const [listPath, listOptions] = await run(['documents', 'list', '--kb', 'kb_1']) + const [listPath, listOptions] = await run(['kb', 'documents', 'list', 'kb_1']) expect(listPath).toBe('/api/v2/knowledge/kb_1/documents') expect(listOptions.query).toMatchObject({ workspaceId: 'ws_local' }) - const [getPathBefore, getOptionsBefore] = await run([ - 'documents', - 'get', - '--kb', - 'kb_1', - 'doc_1', - ]) - expect(getPathBefore).toBe('/api/v2/knowledge/kb_1/documents/doc_1') - expect(getOptionsBefore.query).toEqual({ workspaceId: 'ws_local' }) - - const [getPathAfter] = await run(['document', 'get', 'doc_1', '--kb', 'kb_1']) - expect(getPathAfter).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + const [getPath, getOptions] = await run(['kb', 'documents', 'get', 'kb_1', 'doc_1']) + expect(getPath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') + expect(getOptions.query).toEqual({ workspaceId: 'ws_local' }) - await expect(run(['documents', 'delete', 'doc_1', '--kb', 'kb_1'])).rejects.toThrow( + await expect(run(['kb', 'documents', 'delete', 'kb_1', 'doc_1'])).rejects.toThrow( /document and its embeddings/ ) expect(mockRequest).not.toHaveBeenCalled() const [deletePath, deleteOptions] = await run([ + 'kb', 'documents', 'delete', - 'doc_1', - '--kb', 'kb_1', + 'doc_1', '--yes', ]) expect(deletePath).toBe('/api/v2/knowledge/kb_1/documents/doc_1') expect(deleteOptions.query).toEqual({ workspaceId: 'ws_local' }) - await expect(run(['documents', 'get', 'doc_1'])).rejects.toThrow( - /required option '--kb '/ - ) + await expect(run(['kb', 'documents', 'get', 'kb_1'])).rejects.toThrow(/documentId/) expect(mockRequest).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index d080060f135..8acb4a84fc0 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -12,7 +12,6 @@ const GROUP_ALIASES: Readonly> = { 'audit-logs': 'audit-log', credentials: 'credential', 'custom-tools': 'custom-tool', - documents: 'document', files: 'file', knowledge: 'kb', logs: 'log', @@ -76,6 +75,15 @@ function configureOperation( } } + for (const param of Object.keys(spec.pathArgumentNames ?? {})) { + if (!operationSpec.pathParams.includes(param)) { + throw new Error(`${operation}.${param} is not a path parameter`) + } + if (spec.pathFlags?.[param]) { + throw new Error(`${operation}.${param} cannot be both a path argument and a path flag`) + } + } + if (spec.profileWorkspacePath) { if (!operationSpec.pathParams.includes(PROFILE_INJECTED_FIELD)) { throw new Error(`${operation}.profileWorkspacePath requires a workspaceId path parameter`) @@ -87,7 +95,7 @@ function configureOperation( for (const param of operationSpec.pathParams) { if (spec.pathFlags?.[param] || isProfileWorkspacePath(spec, param)) continue - command.argument(`<${param}>`) + command.argument(`<${spec.pathArgumentNames?.[param] ?? param}>`) } if (spec.allWorkspaces) { diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 816cdec5374..771f7f42708 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -72,8 +72,8 @@ describe('buildRequest', () => { ) }) - it('combines a named parent scope with a positional resource id in route order', () => { - expect(buildRequest('getKnowledgeDocument', ['doc_1'], { kb: 'kb_1' }, WORKSPACE)).toEqual({ + it('combines nested resource path arguments in route order', () => { + expect(buildRequest('getKnowledgeDocument', ['kb_1', 'doc_1'], {}, WORKSPACE)).toEqual({ path: '/api/v2/knowledge/kb_1/documents/doc_1', query: { workspaceId: WORKSPACE }, body: undefined, @@ -97,9 +97,9 @@ describe('buildRequest', () => { ) }) - it('rejects a missing named path scope', () => { - expect(() => buildRequest('getKnowledgeDocument', ['doc_1'], {}, WORKSPACE)).toThrow( - '--kb is required' + it('names a missing nested parent path argument clearly', () => { + expect(() => buildRequest('getKnowledgeDocument', [], {}, WORKSPACE)).toThrow( + 'Missing ' ) }) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 92a8bbe1bba..430348170b4 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -272,6 +272,7 @@ export function buildRequest( const pathFlag = commandSpec.pathFlags?.[param] const profileWorkspacePath = isProfileWorkspacePath(commandSpec, param) const flagName = pathFlagNameFor(commandSpec, param) + const argumentName = commandSpec.pathArgumentNames?.[param] ?? param const value = profileWorkspacePath ? workspaceId : pathFlag @@ -284,11 +285,11 @@ export function buildRequest( 0 ) } - throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${param}>`, 0) + throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${argumentName}>`, 0) } if (typeof value !== 'string' || value.length === 0) { throw new SimApiError( - pathFlag ? `--${flagName} cannot be empty` : `<${param}> cannot be empty`, + pathFlag ? `--${flagName} cannot be empty` : `<${argumentName}> cannot be empty`, 0 ) } From d4bdb87d0479cdd9621495e887bf45805d279c70 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 8 Aug 2026 10:21:27 -0700 Subject: [PATCH 45/46] feat(cli): add interactive Sim chat --- apps/docs/openapi-core.json | 743 ++++- apps/docs/openapi-v2-workflows.json | 6 + apps/sim/AGENTS.md | 10 + apps/sim/app/api/knowledge/utils.test.ts | 10 + apps/sim/app/api/knowledge/utils.ts | 37 +- apps/sim/app/api/v1/middleware.ts | 1 + apps/sim/app/api/v2/chat/activity.test.ts | 380 +++ apps/sim/app/api/v2/chat/activity.ts | 605 ++++ apps/sim/app/api/v2/chat/route.test.ts | 1549 ++++++++++ apps/sim/app/api/v2/chat/route.ts | 691 +++++ .../app/api/v2/chats/[chatId]/route.test.ts | 372 +++ apps/sim/app/api/v2/chats/[chatId]/route.ts | 158 + apps/sim/app/api/v2/chats/route.test.ts | 225 ++ apps/sim/app/api/v2/chats/route.ts | 139 + .../api/v2/workspaces/[workspaceId]/route.ts | 87 + apps/sim/app/cli/auth/cli-auth-view.test.tsx | 16 +- apps/sim/app/cli/auth/cli-auth-view.tsx | 45 +- .../app/workspace/[workspaceId]/home/types.ts | 22 +- apps/sim/blocks/blocks/browser_use.ts | 2 + apps/sim/blocks/blocks/codepipeline.ts | 1 + apps/sim/blocks/blocks/discord.ts | 1 + apps/sim/blocks/blocks/pi.ts | 1 + apps/sim/blocks/blocks/secrets_manager.ts | 1 + apps/sim/blocks/blocks/sftp.ts | 1 + apps/sim/blocks/blocks/ssh.ts | 1 + apps/sim/blocks/blocks/sts.ts | 3 + apps/sim/blocks/blocks/zoom.ts | 2 + .../lib/api/contracts/v1/tables/index.test.ts | 24 + apps/sim/lib/api/contracts/v1/tables/index.ts | 21 +- .../api/contracts/v2/__tests__/tables.test.ts | 51 +- apps/sim/lib/api/contracts/v2/chat.test.ts | 146 + apps/sim/lib/api/contracts/v2/chat.ts | 200 ++ apps/sim/lib/api/contracts/v2/chats.test.ts | 101 + apps/sim/lib/api/contracts/v2/chats.ts | 108 + apps/sim/lib/api/contracts/v2/tables.ts | 12 +- apps/sim/lib/api/contracts/v2/workspaces.ts | 41 + .../lib/copilot/async-runs/repository.test.ts | 22 + apps/sim/lib/copilot/async-runs/repository.ts | 5 +- apps/sim/lib/copilot/chat/lifecycle.test.ts | 54 + apps/sim/lib/copilot/chat/lifecycle.ts | 68 +- .../copilot/chat/persisted-message.test.ts | 28 + .../sim/lib/copilot/chat/persisted-message.ts | 30 + apps/sim/lib/copilot/chat/post.ts | 300 +- apps/sim/lib/copilot/chat/turn-persistence.ts | 246 ++ .../sim/lib/copilot/chat/workspace-context.ts | 34 +- .../lib/copilot/headless/attachments.test.ts | 239 ++ apps/sim/lib/copilot/headless/attachments.ts | 181 ++ .../headless/continuation-token.test.ts | 111 + .../copilot/headless/continuation-token.ts | 140 + .../copilot/headless/workspace-chat.test.ts | 544 ++++ .../lib/copilot/headless/workspace-chat.ts | 262 ++ .../request/context/request-context.ts | 1 + .../sim/lib/copilot/request/go/stream.test.ts | 36 + apps/sim/lib/copilot/request/go/stream.ts | 4 + .../copilot/request/handlers/handlers.test.ts | 113 +- .../lib/copilot/request/lifecycle/headless.ts | 9 +- .../lifecycle/resume-leg-context.test.ts | 1 + .../lib/copilot/request/lifecycle/run.test.ts | 300 ++ apps/sim/lib/copilot/request/lifecycle/run.ts | 236 +- .../copilot/request/lifecycle/start.test.ts | 134 +- .../lib/copilot/request/lifecycle/start.ts | 24 +- .../copilot/request/session/abort-reason.ts | 2 + .../lib/copilot/request/session/abort.test.ts | 65 +- apps/sim/lib/copilot/request/session/abort.ts | 116 +- .../request/session/explicit-abort.test.ts | 24 +- .../copilot/request/session/explicit-abort.ts | 6 +- .../copilot/request/tools/executor.test.ts | 95 +- .../sim/lib/copilot/request/tools/executor.ts | 320 +- .../copilot/request/tools/permission.test.ts | 38 + .../lib/copilot/request/tools/permission.ts | 6 +- .../lib/copilot/request/tools/tables.test.ts | 14 + apps/sim/lib/copilot/request/tools/tables.ts | 16 + .../request/tools/workflow-context.test.ts | 16 + .../copilot/request/tools/workflow-context.ts | 5 +- apps/sim/lib/copilot/request/types.ts | 14 + .../copilot/tool-executor/executor.test.ts | 203 ++ .../sim/lib/copilot/tool-executor/executor.ts | 95 +- apps/sim/lib/copilot/tool-executor/types.ts | 4 + .../lib/copilot/tools/client/store-utils.ts | 84 +- .../lib/copilot/tools/handlers/access.test.ts | 72 + apps/sim/lib/copilot/tools/handlers/access.ts | 21 +- .../handlers/deployment/custom-block.test.ts | 2 +- .../tools/handlers/deployment/custom-block.ts | 2 +- .../tools/handlers/deployment/deploy.ts | 14 +- .../tools/handlers/deployment/manage.test.ts | 92 +- .../tools/handlers/deployment/manage.ts | 49 +- .../tools/handlers/deployment/state-refs.ts | 6 +- .../tools/handlers/function-execute.test.ts | 20 + .../tools/handlers/function-execute.ts | 4 +- .../management/manage-custom-tool.test.ts | 111 + .../handlers/management/manage-custom-tool.ts | 63 +- .../management/manage-mcp-tool.test.ts | 88 + .../handlers/management/manage-mcp-tool.ts | 2 +- .../tools/handlers/materialize-file.test.ts | 2 +- .../tools/handlers/materialize-file.ts | 2 +- apps/sim/lib/copilot/tools/handlers/oauth.ts | 6 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 2 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 12 +- .../lib/copilot/tools/handlers/vfs.test.ts | 27 + apps/sim/lib/copilot/tools/handlers/vfs.ts | 43 +- .../tools/handlers/workflow/mutations.test.ts | 152 +- .../tools/handlers/workflow/mutations.ts | 70 +- .../tools/handlers/workflow/queries.test.ts | 65 +- .../tools/handlers/workflow/queries.ts | 41 +- .../registry/server-tool-adapter.test.ts | 40 + .../tools/registry/server-tool-adapter.ts | 3 + .../sim/lib/copilot/tools/server/base-tool.ts | 2 + .../server/docs/search-documentation.test.ts | 18 +- .../tools/server/docs/search-documentation.ts | 12 +- .../copilot/tools/server/files/create-file.ts | 2 +- .../files/download-to-workspace-file.ts | 2 +- .../tools/server/files/file-folders.ts | 4 +- .../copilot/tools/server/files/rename-file.ts | 2 +- .../copilot/tools/server/files/share-file.ts | 2 +- .../tools/server/files/workspace-file.ts | 2 +- .../server/knowledge/knowledge-base.test.ts | 107 +- .../tools/server/knowledge/knowledge-base.ts | 88 +- .../tools/server/table/user-table.test.ts | 182 +- .../copilot/tools/server/table/user-table.ts | 75 +- .../user/set-environment-variables.test.ts | 6 +- .../server/user/set-environment-variables.ts | 13 +- .../workflow/edit-workflow/index.test.ts | 186 ++ .../server/workflow/edit-workflow/index.ts | 27 +- .../tools/server/workflow/query-logs.ts | 2 +- .../tools/shared/workflow-utils.test.ts | 41 + .../copilot/tools/shared/workflow-utils.ts | 22 +- .../sim/lib/copilot/tools/subagent-display.ts | 29 + .../lib/copilot/tools/tool-display.test.ts | 15 + apps/sim/lib/copilot/tools/tool-display.ts | 66 +- apps/sim/lib/copilot/vfs/serializers.test.ts | 15 + apps/sim/lib/copilot/vfs/serializers.ts | 2 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 68 +- apps/sim/lib/table/types.ts | 12 +- .../lib/table/workflow-groups/service.test.ts | 192 ++ apps/sim/lib/table/workflow-groups/service.ts | 13 +- .../lib/workflows/credentials/constants.ts | 8 + .../credential-extractor.secretless.test.ts | 293 ++ .../credentials/credential-extractor.ts | 242 +- .../lib/workflows/custom-tools/operations.ts | 24 + apps/sim/lib/workflows/persistence/utils.ts | 9 +- bun.lock | 1 + packages/sim-cli/README.md | 119 +- packages/sim-cli/package.json | 1 + .../protocol/chat-attachment-tag.test.ts | 64 + .../protocol/chat-attachments.test.ts | 134 + .../src/commands/protocol/chat-attachments.ts | 324 ++ .../commands/protocol/chat-markdown.test.ts | 99 + .../src/commands/protocol/chat-markdown.ts | 244 ++ .../commands/protocol/chat-mentions.test.ts | 121 + .../src/commands/protocol/chat-paste.test.ts | 78 + .../commands/protocol/chat-structured.test.ts | 352 +++ .../src/commands/protocol/chat-structured.ts | 748 +++++ .../protocol/chat-suggestions.test.ts | 179 ++ .../src/commands/protocol/chat-suggestions.ts | 223 ++ .../commands/protocol/chat-terminal.test.ts | 2148 +++++++++++++ .../src/commands/protocol/chat-terminal.ts | 2682 +++++++++++++++++ .../src/commands/protocol/chat-wrap.test.ts | 81 + .../src/commands/protocol/chat.test.ts | 2646 ++++++++++++++++ .../sim-cli/src/commands/protocol/chat.ts | 1564 ++++++++++ .../commands/protocol/files-download.test.ts | 19 +- .../src/commands/protocol/files-download.ts | 26 +- .../src/commands/protocol/files-upload.ts | 24 +- .../sim-cli/src/commands/protocol/index.ts | 3 + .../commands/protocol/resource-directory.ts | 21 +- .../src/commands/protocol/tables-import.ts | 30 +- packages/sim-cli/src/generated/v2-api.ts | 268 +- packages/sim-cli/src/http/client.test.ts | 121 +- packages/sim-cli/src/http/client.ts | 87 +- packages/sim-cli/src/index.ts | 11 +- packages/sim-cli/src/output/render.test.ts | 8 + packages/sim-cli/src/output/render.ts | 33 +- packages/sim-cli/src/output/terminal-text.ts | 106 + packages/sim-cli/src/runtime/types.ts | 2 +- packages/sim-cli/src/transfer/local-file.ts | 2 +- scripts/check-openapi-specs.ts | 4 +- 175 files changed, 24179 insertions(+), 1246 deletions(-) create mode 100644 apps/sim/app/api/v2/chat/activity.test.ts create mode 100644 apps/sim/app/api/v2/chat/activity.ts create mode 100644 apps/sim/app/api/v2/chat/route.test.ts create mode 100644 apps/sim/app/api/v2/chat/route.ts create mode 100644 apps/sim/app/api/v2/chats/[chatId]/route.test.ts create mode 100644 apps/sim/app/api/v2/chats/[chatId]/route.ts create mode 100644 apps/sim/app/api/v2/chats/route.test.ts create mode 100644 apps/sim/app/api/v2/chats/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts create mode 100644 apps/sim/lib/api/contracts/v1/tables/index.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chat.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chat.ts create mode 100644 apps/sim/lib/api/contracts/v2/chats.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/chats.ts create mode 100644 apps/sim/lib/api/contracts/v2/workspaces.ts create mode 100644 apps/sim/lib/copilot/chat/turn-persistence.ts create mode 100644 apps/sim/lib/copilot/headless/attachments.test.ts create mode 100644 apps/sim/lib/copilot/headless/attachments.ts create mode 100644 apps/sim/lib/copilot/headless/continuation-token.test.ts create mode 100644 apps/sim/lib/copilot/headless/continuation-token.ts create mode 100644 apps/sim/lib/copilot/headless/workspace-chat.test.ts create mode 100644 apps/sim/lib/copilot/headless/workspace-chat.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/access.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts create mode 100644 apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts create mode 100644 apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts create mode 100644 apps/sim/lib/copilot/tools/subagent-display.ts create mode 100644 apps/sim/lib/table/workflow-groups/service.test.ts create mode 100644 apps/sim/lib/workflows/credentials/constants.ts create mode 100644 apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-attachments.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-attachments.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-markdown.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-markdown.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-mentions.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-paste.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-structured.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-structured.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-suggestions.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-terminal.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-terminal.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat-wrap.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/chat.ts create mode 100644 packages/sim-cli/src/output/terminal-text.ts diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index b7020ae27f9..a7acbed3cde 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -1,8 +1,8 @@ { "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.", + "title": "Sim API — Execution, Chat & Usage", + "description": "Run workflows, chat with a workspace, poll and cancel executions, resume Human-in-the-Loop pauses, and check usage limits.", "version": "1.0.0", "contact": { "name": "Sim Support", @@ -36,6 +36,14 @@ { "name": "Billing", "description": "Inspect billing status and credit-denominated ledger events" + }, + { + "name": "Chat", + "description": "Chat with a workspace through Mothership" + }, + { + "name": "Workspaces", + "description": "Resolve workspace metadata available to the authenticated credential" } ], "security": [ @@ -1018,6 +1026,677 @@ "parameters": [] } }, + "/api/v2/workspaces/{workspaceId}": { + "get": { + "operationId": "getWorkspace", + "summary": "Get Workspace", + "description": "Resolve a workspace ID to the display metadata available to the authenticated credential. The credential must have read access to the workspace.", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Workspace to resolve." + } + ], + "responses": { + "200": { + "description": "The workspace's display metadata.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["workspace"], + "properties": { + "workspace": { + "type": "object", + "required": ["id", "name", "color", "logoUrl", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "color": { "type": "string" }, + "logoUrl": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } + } + } + } + } + }, + "example": { + "data": { + "workspace": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Product Operations", + "color": "#7C3AED", + "logoUrl": null, + "createdAt": "2026-08-07T18:00:00.000Z", + "updatedAt": "2026-08-07T18:30:00.000Z" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + } + } + } + }, + "/api/v2/chats": { + "get": { + "operationId": "listChats", + "summary": "List Sim Chats", + "description": "List a bounded page of the authenticated user's active workspace chats in the same pinned-first, recently-updated order used by the Sim Home UI. This personal history surface requires a personal API key; shared workspace keys cannot read their creator's private chats. Pass `nextCursor` back as `cursor` to load another page.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace whose chats should be listed." + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { "type": "string", "maxLength": 200 }, + "description": "Case-insensitive title substring." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 30 }, + "description": "Maximum chats to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { "type": "string" }, + "description": "Opaque cursor returned by the previous page." + } + ], + "responses": { + "200": { + "description": "A bounded page of chat summaries.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "updatedAt", "pinned", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "updatedAt": { "type": "string", "format": "date-time" }, + "pinned": { "type": "boolean" }, + "active": { "type": "boolean" } + } + } + }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "example": { + "data": [ + { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "updatedAt": "2026-08-07T18:30:00.000Z", + "pinned": true, + "active": false + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chats/{chatId}": { + "get": { + "operationId": "getChat", + "summary": "Open Sim Chat", + "description": "Load one owned workspace chat as a display-safe user/assistant transcript and mint a fresh opaque continuation token for the requested safety mode. Internal tool payloads, stream IDs, resources, and replay metadata are not exposed. The subsequent chat POST still accepts only the continuation token, never this resource ID.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "Workspace the chat must belong to." + }, + { + "name": "readOnly", + "in": "query", + "required": false, + "schema": { "type": "boolean", "default": false }, + "description": "Mint a continuation token for the secretless read-only chat mode." + } + ], + "responses": { + "200": { + "description": "The chat transcript and a fresh continuation token.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title", "messages", "continuationToken", "active"], + "properties": { + "id": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "role", "content", "timestamp"], + "properties": { + "id": { "type": "string" }, + "role": { "type": "string", "enum": ["user", "assistant"] }, + "content": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + } + }, + "continuationToken": { "type": "string" }, + "active": { "type": "boolean" } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Review release workflow", + "messages": [ + { + "id": "msg_1", + "role": "user", + "content": "Review the release workflow", + "timestamp": "2026-08-07T18:29:00.000Z" + }, + { + "id": "msg_2", + "role": "assistant", + "content": "The workflow is ready to release.", + "timestamp": "2026-08-07T18:30:00.000Z" + } + ], + "continuationToken": "sim-v2-chat-v1.opaque.refreshed", + "active": false + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + }, + "patch": { + "operationId": "renameChat", + "summary": "Rename Sim Chat", + "description": "Rename an owned Sim Chat and synchronize the new title with the Sim Home chat list. This private history operation requires a personal API key; shared workspace keys cannot rename a creator's chats.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "parameters": [ + { + "name": "chatId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Chat resource ID returned by List Sim Chats." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "title"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace the chat must belong to." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "New chat title. Leading and trailing whitespace is removed." + } + } + }, + "example": { + "workspaceId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "title": "Incident investigation" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed chat.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 } + } + } + } + }, + "example": { + "data": { + "id": "80a47295-040e-46f9-9ea8-ad78eff3bcab", + "title": "Incident investigation" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + } + } + } + }, + "/api/v2/chat": { + "post": { + "operationId": "chat", + "summary": "Ask Sim Chat", + "description": "Chat with the Mothership agent for a workspace. Personal API keys use their owner's current workspace permission, integrations, credentials, environment context, and memory, and their conversations are synchronized with the Sim Home chat history. Shared workspace keys retain normal workspace capabilities but do not inherit a human owner's personal integrations, secrets, environment, memory, or private chat history. Set `readOnly` to select the subtractive, secretless workspace-query policy. Omit `continuationToken` for a one-shot or first interactive turn, then send the latest opaque token returned by the stream to continue the same conversation. Tokens are bound to the workspace, authorization principal, credential type, and read-only mode, and expire on a rolling 24-hour window; this chat POST never accepts a raw chat ID. The response is a Server-Sent Events stream. `text` events contain incremental assistant output and `complete` contains the authoritative final result. Comment frames are heartbeats and `data: [DONE]` closes a successful stream. The caller's Sim API key selects and authorizes the local workspace but is never forwarded to Mothership. Workspace keys use the workspace billing account as their system actor while local tool authorization remains bound to the key owner; personal keys use their owner. Inline attachments are base64-only: paths and URLs are not accepted or resolved.", + "tags": ["Chat"], + "security": [ + { + "apiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["workspaceId", "prompt"], + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace Sim Chat should operate in." + }, + "prompt": { + "type": "string", + "maxLength": 10485760, + "x-maxUtf8Bytes": 10485760, + "description": "The instruction or question for Sim Chat. UTF-8 input is limited to 10 MiB. It may be empty or whitespace only when at least one attachment is present; the server supplies a neutral inspect-the-attachments instruction in that case." + }, + "continuationToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Latest opaque continuation token returned by a prior `session` or `complete` event. Never send a raw chat or conversation ID." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Use the secretless, read-only workspace-query policy. The default keeps normal workspace capabilities; shared workspace credentials still exclude personal integrations, secrets, environment, and persistent memory." + }, + "attachments": { + "type": "array", + "maxItems": 5, + "description": "Optional inline attachments, accepted on initial and continuation turns. Decoded aggregate size is limited to 10 MiB. Images and PDFs are limited to 5 MiB each; UTF-8 text is limited to 200 KiB each. Each image may be at most 8192 pixels on either axis and 16,000,000 total pixels; all images in one request may total at most 32,000,000 decoded pixels.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "mediaType", "data"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File basename only. Directory separators and control characters are rejected." + }, + "mediaType": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "text/tab-separated-values", + "text/html", + "text/css", + "text/javascript", + "text/typescript", + "text/xml", + "text/yaml", + "application/json", + "application/jsonl", + "application/x-ndjson", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml" + ], + "description": "Declared MIME type. Image and PDF bytes are sniffed; text must decode as UTF-8." + }, + "data": { + "type": "string", + "minLength": 4, + "maxLength": 13981016, + "contentEncoding": "base64", + "description": "Canonical standard base64 bytes. Data URLs and base64url are not accepted." + } + } + } + }, + "contexts": { + "type": "array", + "maxItems": 50, + "description": "Optional identity-bearing workspace resources, skills, and MCP servers to inject for this turn. Resource kinds correspond to `@` tags; `skill` and `mcp` correspond to `/` tags. MCP contexts are ignored for read-only requests and shared workspace API keys.", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "workflowId", "label"], + "properties": { + "kind": { "type": "string", "const": "workflow" }, + "workflowId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "tableId", "label"], + "properties": { + "kind": { "type": "string", "const": "table" }, + "tableId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "fileId", "label"], + "properties": { + "kind": { "type": "string", "const": "file" }, + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "knowledgeId", "label"], + "properties": { + "kind": { "type": "string", "const": "knowledge" }, + "knowledgeId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "executionId", "label"], + "properties": { + "kind": { "type": "string", "const": "logs" }, + "executionId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "skillId", "label"], + "properties": { + "kind": { "type": "string", "const": "skill" }, + "skillId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "serverId", "label"], + "properties": { + "kind": { "type": "string", "const": "mcp" }, + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "label": { "type": "string", "minLength": 1, "maxLength": 255 } + } + } + ] + } + } + } + }, + "example": { + "workspaceId": "ws_abc123", + "prompt": "Summarize the attached notes and compare them with this workspace.", + "attachments": [ + { + "name": "notes.md", + "mediaType": "text/markdown", + "data": "IyBOb3Rlcwo=" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "A Sim Chat SSE stream.", + "headers": { + "X-RateLimit-Limit": { + "description": "API request bucket capacity.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current bucket.", + "schema": { "type": "integer" } + }, + "X-RateLimit-Reset": { + "description": "When the current API request bucket resets.", + "schema": { "type": "string", "format": "date-time" } + } + }, + "content": { + "text/event-stream": { + "schema": { "type": "string" }, + "example": "data: {\"type\":\"session\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"requestId\":\"req_123\",\"chatId\":\"80a47295-040e-46f9-9ea8-ad78eff3bcab\"}\n\ndata: {\"type\":\"text\",\"delta\":\"Two workflows...\"}\n\ndata: {\"type\":\"complete\",\"data\":{\"content\":\"Two workflows...\",\"continuationToken\":\"sim-v2-chat-v1.opaque.refreshed\",\"usage\":{\"prompt\":120,\"completion\":18,\"total\":138}}}\n\ndata: [DONE]\n\n" + } + } + }, + "400": { + "$ref": "#/components/responses/V2BadRequest" + }, + "401": { + "$ref": "#/components/responses/V2Unauthorized" + }, + "402": { + "$ref": "#/components/responses/V2UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/V2Forbidden" + }, + "404": { + "$ref": "#/components/responses/V2NotFound" + }, + "409": { + "$ref": "#/components/responses/V2Conflict" + }, + "413": { + "$ref": "#/components/responses/V2PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/V2UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/V2RateLimited" + }, + "503": { + "$ref": "#/components/responses/V2ServiceUnavailable" + } + } + } + }, "/api/v2/billing/status": { "get": { "operationId": "getBillingStatus", @@ -2248,6 +2927,56 @@ } } }, + "V2NotFound": { + "description": "The requested resource does not exist or is not visible to the credential.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2Conflict": { + "description": "The chat already has a response in progress. Retry after that response finishes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UsageLimitExceeded": { + "description": "The resolved workspace payer or organization member has reached a usage limit.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2PayloadTooLarge": { + "description": "The request body or decoded attachment limits were exceeded.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, + "V2UnsupportedMediaType": { + "description": "An attachment media type or its decoded bytes are unsupported.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } + }, "V2RateLimited": { "description": "Rate limit exceeded; retry after the window resets.", "content": { @@ -2257,6 +2986,16 @@ } } } + }, + "V2ServiceUnavailable": { + "description": "Sim Chat is not configured or temporarily unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + } + } + } } } } diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 957b4da2497..fbcc75766c1 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1141,6 +1141,12 @@ "default": false, "description": "Queue the run; poll the returned statusUrl. Not combinable with stream/output options; requires an API key." }, + "executionTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 604800, + "description": "Optional server-side timeout for an async run, in seconds. Requires async=true and cannot extend the account policy." + }, "stream": { "type": "boolean", "default": false, diff --git a/apps/sim/AGENTS.md b/apps/sim/AGENTS.md index 6c52c2df02d..6366615da3c 100644 --- a/apps/sim/AGENTS.md +++ b/apps/sim/AGENTS.md @@ -229,3 +229,13 @@ export function useEntityList(workspaceId?: string) { - **Check existing sources** before duplicating (`lib/` has many utilities) - **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index d7d0ea2999d..df3dc0b9c40 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -234,6 +234,16 @@ describe('Knowledge Utils', () => { expect(result.hasAccess).toBe(false) expect('notFound' in result && result.notFound).toBe(true) }) + + it('treats a knowledge base outside the trusted workspace as not found', async () => { + queueTableRows(schemaMock.knowledgeBase, [ + { id: 'kb1', userId: 'user1', workspaceId: 'workspace-2' }, + ]) + + const result = await checkKnowledgeBaseAccess('kb1', 'user1', 'workspace-1') + + expect(result).toEqual({ hasAccess: false, notFound: true }) + }) }) describe('checkDocumentAccess', () => { diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index e92dc49f419..11fac039123 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -163,7 +163,8 @@ export type ChunkAccessCheck = ChunkAccessResult | ChunkAccessDenied async function resolveKnowledgeBaseAccess( knowledgeBaseId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { const kb = await db .select({ @@ -183,6 +184,10 @@ async function resolveKnowledgeBaseAccess( const kbData = kb[0] + if (workspaceId && kbData.workspaceId !== workspaceId) { + return { hasAccess: false, notFound: true } + } + if (kbData.workspaceId) { // Workspace KB: use workspace permissions only const userPermission = await getUserEntityPermissions(userId, 'workspace', kbData.workspaceId) @@ -205,9 +210,10 @@ async function resolveKnowledgeBaseAccess( */ export async function checkKnowledgeBaseAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, false, workspaceId) } /** @@ -219,9 +225,10 @@ export async function checkKnowledgeBaseAccess( */ export async function checkKnowledgeBaseWriteAccess( knowledgeBaseId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true) + return resolveKnowledgeBaseAccess(knowledgeBaseId, userId, true, workspaceId) } /** @@ -232,9 +239,15 @@ async function resolveDocumentAccess( knowledgeBaseId: string, documentId: string, userId: string, - requireWrite: boolean + requireWrite: boolean, + workspaceId?: string ): Promise { - const kbAccess = await resolveKnowledgeBaseAccess(knowledgeBaseId, userId, requireWrite) + const kbAccess = await resolveKnowledgeBaseAccess( + knowledgeBaseId, + userId, + requireWrite, + workspaceId + ) if (!kbAccess.hasAccess) { return { @@ -262,9 +275,10 @@ async function resolveDocumentAccess( export async function checkDocumentAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, false, workspaceId) } /** @@ -274,9 +288,10 @@ export async function checkDocumentAccess( export async function checkDocumentWriteAccess( knowledgeBaseId: string, documentId: string, - userId: string + userId: string, + workspaceId?: string ): Promise { - return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true) + return resolveDocumentAccess(knowledgeBaseId, documentId, userId, true, workspaceId) } /** diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 4182a4ea4a1..d9d795b618c 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -34,6 +34,7 @@ export type ApiEndpoint = | 'workflow-version-detail' | 'workflow-export' | 'workflow-import' + | 'workspace' | 'audit-logs' | 'tables' | 'table-detail' diff --git a/apps/sim/app/api/v2/chat/activity.test.ts b/apps/sim/app/api/v2/chat/activity.test.ts new file mode 100644 index 00000000000..feed95c1305 --- /dev/null +++ b/apps/sim/app/api/v2/chat/activity.test.ts @@ -0,0 +1,380 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import type { MothershipStreamV1StreamScope } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { ChatActivityProjector } from '@/app/api/v2/chat/activity' + +vi.mock('@/lib/copilot/tools/client/read-block', () => ({ + getReadTargetBlock: vi.fn((path: string | undefined) => + path?.startsWith('components/') ? { name: 'Gmail' } : undefined + ), +})) + +const call = (over: Record = {}) => ({ + toolCallId: 'private-call-id', + toolName: 'read', + phase: 'call', + arguments: { secret: 'never-forward-me' }, + executor: 'go', + mode: 'sync', + ...over, +}) + +const result = (over: Record = {}) => + call({ + phase: 'result', + success: true, + output: { secret: 'never-forward-me' }, + arguments: undefined, + ...over, + }) + +const tool = (payload: Record, scope?: MothershipStreamV1StreamScope) => + ({ type: 'tool', payload, ...(scope ? { scope } : {}) }) as StreamEvent + +const span = ( + event: 'start' | 'end', + scope: MothershipStreamV1StreamScope, + over: Record = {} +) => + ({ + type: 'span', + scope, + payload: { kind: 'subagent', event, agent: scope.agentId, ...over }, + }) as StreamEvent + +const text = ( + channel: 'assistant' | 'thinking', + value: string, + scope?: MothershipStreamV1StreamScope +) => + ({ + type: 'text', + payload: { channel, text: value }, + ...(scope ? { scope } : {}), + }) as StreamEvent + +const researchScope: MothershipStreamV1StreamScope = { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch-id', + spanId: 'private-research-span', + parentSpanId: 'main', +} + +describe('ChatActivityProjector', () => { + it('correlates a visible root call and result without exposing their raw payload', () => { + const projector = new ChatActivityProjector() + + const [running] = projector.project(tool(call())) + const [complete] = projector.project(tool(result())) + + expect(running).toEqual({ + kind: 'tool', + id: 'tool-1', + label: 'Reading file', + state: 'running', + }) + expect(complete).toEqual({ ...running, label: 'Read file', state: 'complete' }) + expect(JSON.stringify([running, complete])).not.toContain('private-call-id') + expect(JSON.stringify([running, complete])).not.toContain('never-forward-me') + }) + + it.each([ + ['workflows/forceful-arm/state.json', 'forceful-arm'], + ['components/blocks/gmail_v2.json', 'Gmail'], + ['components/integrations/gmail/send.json', 'Gmail'], + ])('uses the web read label for %s without forwarding arguments', (path, target) => { + const projector = new ChatActivityProjector() + const activities = [ + ...projector.project(tool(call({ arguments: { path, secret: 'never-forward-me' } }))), + ...projector.project(tool(result())), + ] + + expect(activities).toEqual([ + { kind: 'tool', id: 'tool-1', label: `Reading ${target}`, state: 'running' }, + { kind: 'tool', id: 'tool-1', label: `Read ${target}`, state: 'complete' }, + ]) + expect(JSON.stringify(activities)).not.toContain(path) + expect(JSON.stringify(activities)).not.toContain('never-forward-me') + }) + + it('maps failed and skipped terminal outcomes', () => { + const failed = new ChatActivityProjector() + failed.project(tool(call())) + expect(failed.project(tool(result({ success: false, error: 'private failure' })))).toEqual([ + expect.objectContaining({ label: 'Reading file', state: 'error' }), + ]) + + expect( + new ChatActivityProjector().project(tool(call({ status: 'skipped', success: false }))) + ).toEqual([expect.objectContaining({ label: 'Reading file', state: 'complete' })]) + + for (const status of ['cancelled', 'rejected']) { + const projector = new ChatActivityProjector() + projector.project(tool(call())) + expect(projector.project(tool(result({ status, success: true })))[0]).toMatchObject({ + state: 'error', + }) + } + }) + + it('waits for an authoritative call and holds an early result', () => { + const generating = new ChatActivityProjector() + expect(generating.project(tool(call({ partial: true, status: 'generating' })))).toEqual([]) + expect(generating.project(tool(call({ partial: false, status: 'executing' })))).toEqual([ + { + kind: 'tool', + id: 'tool-1', + label: 'Reading file', + state: 'running', + }, + ]) + + const reordered = new ChatActivityProjector() + expect(reordered.project(tool(result()))).toEqual([]) + expect(reordered.project(tool(call()))).toEqual([ + { + kind: 'tool', + id: 'tool-1', + label: 'Read file', + state: 'complete', + }, + ]) + }) + + it('suppresses hidden, internal, and internal-result calls without id gaps', () => { + const projector = new ChatActivityProjector() + + for (const payload of [ + call({ toolCallId: 'hidden', ui: { hidden: true } }), + call({ toolCallId: 'internal', ui: { internal: true } }), + call({ toolCallId: 'legacy', toolName: 'load_skill' }), + call({ + toolCallId: 'tool-result-read', + arguments: { path: 'internal/tool-results/private' }, + }), + ]) { + expect(projector.project(tool(payload))).toEqual([]) + } + + expect(projector.project(tool(call({ toolCallId: 'visible' })))[0]).toMatchObject({ + id: 'tool-1', + }) + }) + + it('provisions root and nested subagent lanes from dispatch calls before span start', () => { + const root = new ChatActivityProjector() + expect( + root.project(tool(call({ toolCallId: 'workflow-dispatch', toolName: 'workflow' }))) + ).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Workflow Agent', + state: 'running', + }, + ]) + const workflowScope = { + lane: 'subagent' as const, + agentId: 'workflow', + spanId: 'workflow-span', + parentSpanId: 'main', + parentToolCallId: 'workflow-dispatch', + } + expect(root.project(span('start', workflowScope))).toEqual([]) + + const nested = new ChatActivityProjector() + nested.project(span('start', researchScope)) + expect( + nested.project( + tool(call({ toolCallId: 'deploy-dispatch', toolName: 'deploy' }), researchScope) + ) + ).toEqual([ + { + kind: 'subagent', + id: 'agent-2', + parentId: 'agent-1', + label: 'Deploy Agent', + state: 'running', + }, + ]) + expect( + nested.project( + span('start', { + lane: 'subagent', + agentId: 'deploy', + spanId: 'deploy-span', + parentSpanId: researchScope.spanId, + parentToolCallId: 'deploy-dispatch', + }) + ) + ).toEqual([]) + }) + + it('projects subagent lifecycle, scoped tools, and narration as an opaque tree', () => { + const projector = new ChatActivityProjector() + const activities = [ + ...projector.project(span('start', researchScope)), + ...projector.project(tool(call({ toolCallId: 'private-child-tool' }), researchScope)), + ...projector.project(text('assistant', 'I found the answer.', researchScope)), + ...projector.project(text('thinking', 'private chain of thought', researchScope)), + // Sim/client tool results are synthesized without their original scope. + ...projector.project(tool(result({ toolCallId: 'private-child-tool' }))), + ...projector.project(span('end', researchScope)), + ] + + expect(activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Reading file', + state: 'running', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'I found the answer.' }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + ]) + const serialized = JSON.stringify(activities) + for (const privateValue of [ + 'private-child-tool', + 'private-dispatch-id', + 'private-research-span', + 'never-forward-me', + 'private chain of thought', + ]) { + expect(serialized).not.toContain(privateValue) + } + }) + + it('nests subagents by opaque span parent ids and keeps parallel same-name runs distinct', () => { + const projector = new ChatActivityProjector() + const parent = { ...researchScope, spanId: 'parent', parentToolCallId: 'parent-call' } + const child = { + ...researchScope, + spanId: 'child', + parentSpanId: 'parent', + parentToolCallId: 'child-call', + } + const sibling = { + ...researchScope, + spanId: 'sibling', + parentToolCallId: 'sibling-call', + } + + expect(projector.project(span('start', parent))).toEqual([ + expect.objectContaining({ id: 'agent-1', label: 'Research Agent' }), + ]) + expect(projector.project(span('start', child))).toEqual([ + expect.objectContaining({ id: 'agent-2', parentId: 'agent-1' }), + ]) + expect(projector.project(span('start', sibling))).toEqual([ + expect.objectContaining({ id: 'agent-3', label: 'Research Agent' }), + ]) + }) + + it('reconciles a pre-start lane to the authoritative agent without changing its id', () => { + const projector = new ChatActivityProjector() + const provisional = { ...researchScope, agentId: 'superagent' } + + expect(projector.project(text('assistant', 'Starting.', provisional))).toEqual([ + expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'Superagent' }), + { kind: 'narration', parentId: 'agent-1', delta: 'Starting.' }, + ]) + expect(projector.project(span('start', provisional, { agent: 'file' }))).toEqual([ + expect.objectContaining({ kind: 'subagent', id: 'agent-1', label: 'File Agent' }), + ]) + }) + + it('keeps pending span ends open and exposes terminal errors without their details', () => { + const projector = new ChatActivityProjector() + projector.project(span('start', researchScope)) + + expect(projector.project(span('end', researchScope, { data: { pending: true } }))).toEqual([]) + const terminal = projector.project( + span('end', researchScope, { data: { error: 'private backend failure' } }) + ) + expect(terminal).toEqual([ + expect.objectContaining({ id: 'agent-1', state: 'error', label: 'Research Agent' }), + ]) + expect(JSON.stringify(terminal)).not.toContain('private backend failure') + }) + + it('settles open tools and agents, using past tense only on success', () => { + const successful = new ChatActivityProjector() + successful.project(span('start', researchScope)) + successful.project(tool(call(), researchScope)) + expect(successful.finish('complete')).toEqual([ + expect.objectContaining({ kind: 'tool', label: 'Read file', state: 'complete' }), + expect.objectContaining({ kind: 'subagent', state: 'complete' }), + ]) + expect(successful.finish('complete')).toEqual([]) + + const failed = new ChatActivityProjector() + failed.project(span('start', researchScope)) + failed.project(tool(call(), researchScope)) + expect(failed.finish('error')).toEqual([ + expect.objectContaining({ kind: 'tool', label: 'Reading file', state: 'error' }), + expect.objectContaining({ kind: 'subagent', state: 'error' }), + ]) + }) + + it('absorbs a workspace_file dispatch into its matching file subagent', () => { + const projector = new ChatActivityProjector() + const workspaceCall = call({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' }) + const fileScope = { + lane: 'subagent' as const, + agentId: 'file', + spanId: 'file-span', + parentSpanId: 'main', + parentToolCallId: 'workspace-dispatch', + } + + expect(projector.project(tool(workspaceCall))).toEqual([]) + expect( + projector.project( + tool(result({ toolCallId: 'workspace-dispatch', toolName: 'workspace_file' })) + ) + ).toEqual([]) + expect(projector.project(span('start', fileScope))).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'File Agent', + state: 'running', + }, + ]) + expect(projector.project(tool(call({ toolCallId: 'visible-root' })))[0]).toMatchObject({ + id: 'tool-1', + }) + }) + + it('drops argument deltas, synthetic preview frames, and malformed events', () => { + const projector = new ChatActivityProjector() + + expect(projector.project(tool(call({ phase: 'args_delta' })))).toEqual([]) + expect(projector.project(tool(call({ phase: undefined })))).toEqual([]) + expect(projector.project(tool(call({ toolCallId: '' })))).toEqual([]) + expect(projector.project(tool(call({ toolName: undefined })))).toEqual([]) + }) +}) diff --git a/apps/sim/app/api/v2/chat/activity.ts b/apps/sim/app/api/v2/chat/activity.ts new file mode 100644 index 00000000000..fde09002790 --- /dev/null +++ b/apps/sim/app/api/v2/chat/activity.ts @@ -0,0 +1,605 @@ +import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import { + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + type MothershipStreamV1StreamScope, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, + MothershipStreamV1ToolStatus, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { getToolEntry } from '@/lib/copilot/tool-executor/router' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' +import { getSubagentDisplayTitle } from '@/lib/copilot/tools/subagent-display' +import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' + +type ActivityState = 'running' | 'complete' | 'error' + +/** A display-safe node in the public v2 chat activity tree. */ +export interface V2ChatNodeActivity { + kind: 'subagent' | 'tool' + id: string + parentId?: string + label: string + state: ActivityState +} + +/** Display-safe assistant narration authored inside a subagent lane. */ +export interface V2ChatNarrationActivity { + kind: 'narration' + parentId: string + delta: string +} + +export type V2ChatActivity = V2ChatNodeActivity | V2ChatNarrationActivity + +interface ToolEventPayload { + toolCallId?: unknown + toolName?: unknown + arguments?: unknown + output?: unknown + partial?: unknown + phase?: unknown + status?: unknown + success?: unknown + ui?: { hidden?: unknown; internal?: unknown } | null +} + +interface ToolProjection { + id?: string + label?: string + parentId?: string + state?: ActivityState + status?: string + visibility: 'pending' | 'visible' | 'hidden' + pendingState?: ActivityState + pendingStatus?: string +} + +interface AgentProjection { + id: string + label: string + parentId?: string + state: ActivityState + emitted: boolean +} + +interface ProjectedToolState { + state: ActivityState + status: string +} + +interface DeferredWorkspaceFile { + call: ToolEventPayload + result?: ToolEventPayload +} + +const ERROR_STATUSES = new Set([ + MothershipStreamV1ToolStatus.error, + MothershipStreamV1ToolStatus.cancelled, + MothershipStreamV1ToolStatus.rejected, +]) +const MAIN_SPAN = 'main' +const WORKSPACE_FILE_TOOL = 'workspace_file' +const FILE_SUBAGENT = 'file' + +/** + * Request-local projection of the private Mothership stream onto the public + * activity tree. Raw span/tool ids, arguments, results, errors, and thinking + * never cross this boundary. + */ +export class ChatActivityProjector { + private readonly calls = new Map() + private readonly agentsByKey = new Map() + private readonly agents: AgentProjection[] = [] + private deferredWorkspaceFile?: DeferredWorkspaceFile + private nextToolId = 1 + private nextAgentId = 1 + + project(event: StreamEvent): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + + if (this.captureDeferredWorkspaceFileResult(event)) return activities + + const absorbsWorkspaceFile = this.absorbsDeferredWorkspaceFile(event) + if (this.deferredWorkspaceFile && !absorbsWorkspaceFile && this.breaksDeferral(event)) { + activities.push(...this.flushDeferredWorkspaceFile()) + } + if (absorbsWorkspaceFile) this.hideDeferredWorkspaceFile() + + if (this.deferWorkspaceFileCall(event)) return activities + + switch (event.type) { + case MothershipStreamV1EventType.span: + activities.push(...this.projectSpan(event.payload, event.scope)) + break + case MothershipStreamV1EventType.text: + activities.push(...this.projectText(event.payload, event.scope)) + break + case MothershipStreamV1EventType.tool: + activities.push(...this.projectTool(event.payload, event.scope)) + break + } + + return activities + } + + /** Settle every public row before the route sends its terminal envelope. */ + finish(outcome: 'complete' | 'error'): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + + if (this.deferredWorkspaceFile) { + const deferred = this.flushDeferredWorkspaceFile() + const last = deferred.at(-1) + // A deferred call was never visible. If it already completed, expose only + // its terminal snapshot; otherwise the normal settlement below closes it. + if (last?.kind === 'tool' && last.state !== 'running') activities.push(last) + } + + for (const projection of this.calls.values()) { + if ( + projection.visibility !== 'visible' || + !projection.id || + !projection.label || + projection.state !== 'running' + ) { + continue + } + activities.push( + this.toolActivity(projection, { + state: outcome === 'complete' ? 'complete' : 'error', + status: + outcome === 'complete' + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error, + }) + ) + } + + // Children close before their parents, matching the visible activity tree. + for (const agent of [...this.agents].reverse()) { + if (!agent.emitted || agent.state !== 'running') continue + agent.state = outcome + activities.push(this.agentActivity(agent)) + } + + return activities + } + + private projectSpan(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + const span = record(payload) + if (span?.kind !== MothershipStreamV1SpanPayloadKind.subagent) return [] + if ( + span.event !== MothershipStreamV1SpanLifecycleEvent.start && + span.event !== MothershipStreamV1SpanLifecycleEvent.end + ) { + return [] + } + + const data = record(span.data) + const triggerToolCallId = + stringValue(scope?.parentToolCallId) ?? + stringValue(data?.tool_call_id) ?? + stringValue(data?.toolCallId) + const authoritativeAgent = stringValue(span.agent) + const resolved = this.ensureAgent(scope, authoritativeAgent, triggerToolCallId, false) + if (!resolved) return [] + const { agent, changed } = resolved + + if (span.event === MothershipStreamV1SpanLifecycleEvent.start) { + const stateChanged = agent.state !== 'running' + agent.state = 'running' + if (!agent.emitted || changed || stateChanged) { + agent.emitted = true + return [this.agentActivity(agent)] + } + return [] + } + + // A checkpoint pause is resumable, not a completed subagent run. + if (data?.pending === true) return [] + agent.state = stringValue(data?.error) ? 'error' : 'complete' + agent.emitted = true + return [this.agentActivity(agent)] + } + + private projectText(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + const text = record(payload) + if ( + !scope || + text?.channel !== MothershipStreamV1TextChannel.assistant || + typeof text.text !== 'string' || + !text.text + ) { + return [] + } + + const resolved = this.ensureAgent(scope, undefined, undefined, true) + if (!resolved) return [] + return [ + ...resolved.activities, + { kind: 'narration', parentId: resolved.agent.id, delta: text.text }, + ] + } + + private projectTool(payload: unknown, scope?: MothershipStreamV1StreamScope): V2ChatActivity[] { + if (!payload || typeof payload !== 'object') return [] + const tool = payload as ToolEventPayload + if (tool.phase === MothershipStreamV1ToolPhase.args_delta) return [] + if ( + tool.phase !== MothershipStreamV1ToolPhase.call && + tool.phase !== MothershipStreamV1ToolPhase.result + ) { + return [] + } + + const callId = stringValue(tool.toolCallId) + const toolName = stringValue(tool.toolName) + if (!callId || !toolName) return [] + + const catalog = getToolEntry(toolName) + if (catalog?.route === 'subagent') { + this.calls.set(callId, { visibility: 'hidden' }) + if ( + tool.phase !== MothershipStreamV1ToolPhase.call || + tool.partial === true || + tool.status === MothershipStreamV1ToolStatus.generating + ) { + return [] + } + return this.projectSubagentDispatch(callId, catalog.subagentId ?? toolName, scope) + } + + const existing = this.calls.get(callId) + if (existing?.visibility === 'hidden') return [] + + if (this.isHidden(toolName, tool)) { + this.calls.set(callId, { visibility: 'hidden' }) + return [] + } + + const activities: V2ChatActivity[] = [] + let parentId = existing?.parentId + if (scope) { + const resolved = this.ensureAgent(scope, undefined, undefined, true) + if (!resolved) { + this.calls.set(callId, { visibility: 'hidden' }) + return [] + } + activities.push(...resolved.activities) + parentId = resolved.agent.id + } + + if (tool.phase === MothershipStreamV1ToolPhase.result) { + const projectedState = toolState(tool) + if (!existing || existing.visibility !== 'visible' || !existing.label) { + this.calls.set(callId, { + label: existing?.label, + parentId, + visibility: 'pending', + pendingState: projectedState.state, + pendingStatus: projectedState.status, + }) + return activities + } + existing.parentId ??= parentId + existing.pendingState = projectedState.state + existing.pendingStatus = projectedState.status + activities.push(this.toolActivity(existing, projectedState)) + return activities + } + + const projection = existing ?? { visibility: 'pending' as const } + const toolArguments = record(tool.arguments) + const resolvedReadTargetName = + toolName === 'read' ? getReadTargetBlock(stringValue(toolArguments?.path))?.name : undefined + projection.label = getToolDisplayTitle(toolName, toolArguments, resolvedReadTargetName) + projection.parentId ??= parentId + + // Generating calls can later resolve to a hidden/internal tool. Wait for + // the authoritative call so the terminal never paints an orphan row. + if (tool.partial === true || tool.status === MothershipStreamV1ToolStatus.generating) { + this.calls.set(callId, projection) + return activities + } + + projection.visibility = 'visible' + projection.id ??= this.publicToolId() + this.calls.set(callId, projection) + const projectedState = toolState(tool) + activities.push( + this.toolActivity(projection, { + state: projection.pendingState ?? projectedState.state, + status: projection.pendingStatus ?? projectedState.status, + }) + ) + return activities + } + + private ensureAgent( + scope: MothershipStreamV1StreamScope | undefined, + authoritativeAgent?: string, + triggerToolCallId?: string, + emit = true + ): + | { + agent: AgentProjection + activities: V2ChatActivity[] + changed: boolean + } + | undefined { + if (!scope || scope.lane !== 'subagent') return undefined + const spanId = stringValue(scope.spanId) + const triggerId = triggerToolCallId ?? stringValue(scope.parentToolCallId) + const spanKey = spanId ? `span:${spanId}` : undefined + const callKey = triggerId ? `call:${triggerId}` : undefined + if (!spanKey && !callKey) return undefined + + let agent = + (spanKey ? this.agentsByKey.get(spanKey) : undefined) ?? + (callKey ? this.agentsByKey.get(callKey) : undefined) + if (!agent) { + agent = { + id: this.publicAgentId(), + label: getSubagentDisplayTitle(authoritativeAgent ?? scope.agentId ?? ''), + parentId: this.parentAgentId(scope, spanId), + state: 'running', + emitted: false, + } + this.agents.push(agent) + } + if (spanKey) this.agentsByKey.set(spanKey, agent) + if (callKey) this.agentsByKey.set(callKey, agent) + + let changed = false + if (authoritativeAgent) { + const label = getSubagentDisplayTitle(authoritativeAgent) + if (label !== agent.label) { + agent.label = label + changed = true + } + } + const parentId = this.parentAgentId(scope, spanId) + if (parentId && parentId !== agent.parentId) { + agent.parentId = parentId + changed = true + } + + const activities: V2ChatActivity[] = [] + if (emit && (!agent.emitted || changed)) { + agent.emitted = true + activities.push(this.agentActivity(agent)) + } + return { agent, activities, changed } + } + + private projectSubagentDispatch( + callId: string, + agentId: string, + scope?: MothershipStreamV1StreamScope + ): V2ChatActivity[] { + const activities: V2ChatActivity[] = [] + let parentId: string | undefined + if (scope) { + const parent = this.ensureAgent(scope, undefined, undefined, true) + if (parent) { + activities.push(...parent.activities) + parentId = parent.agent.id + } + } + + const key = `call:${callId}` + let agent = this.agentsByKey.get(key) + const label = getSubagentDisplayTitle(agentId) + if (!agent) { + agent = { + id: this.publicAgentId(), + label, + ...(parentId ? { parentId } : {}), + state: 'running', + emitted: false, + } + this.agentsByKey.set(key, agent) + this.agents.push(agent) + } + const changed = agent.label !== label || (!!parentId && agent.parentId !== parentId) + agent.label = label + agent.parentId ??= parentId + agent.state = 'running' + if (!agent.emitted || changed) { + agent.emitted = true + activities.push(this.agentActivity(agent)) + } + return activities + } + + private parentAgentId( + scope: MothershipStreamV1StreamScope, + ownSpanId?: string + ): string | undefined { + const parentSpanId = stringValue(scope.parentSpanId) + if (!parentSpanId || parentSpanId === MAIN_SPAN || parentSpanId === ownSpanId) return undefined + const key = `span:${parentSpanId}` + let parent = this.agentsByKey.get(key) + if (!parent) { + parent = { + id: this.publicAgentId(), + label: getSubagentDisplayTitle(''), + state: 'running', + emitted: false, + } + this.agentsByKey.set(key, parent) + this.agents.push(parent) + } + return parent.id + } + + private isHidden(toolName: string, tool: ToolEventPayload): boolean { + const catalog = getToolEntry(toolName) + return ( + tool.ui?.hidden === true || + tool.ui?.internal === true || + catalog?.hidden === true || + catalog?.internal === true || + isToolHiddenInUi(toolName) || + (toolName === 'read' && + stringValue(record(tool.arguments)?.path)?.startsWith('internal/tool-results/') === true) + ) + } + + private deferWorkspaceFileCall(event: StreamEvent): boolean { + if (event.type !== MothershipStreamV1EventType.tool || event.scope) return false + const tool = event.payload as ToolEventPayload + if ( + tool.phase !== MothershipStreamV1ToolPhase.call || + tool.toolName !== WORKSPACE_FILE_TOOL || + tool.partial === true || + tool.status === MothershipStreamV1ToolStatus.generating || + this.isHidden(WORKSPACE_FILE_TOOL, tool) + ) { + return false + } + this.deferredWorkspaceFile = { call: tool } + return true + } + + private captureDeferredWorkspaceFileResult(event: StreamEvent): boolean { + const deferred = this.deferredWorkspaceFile + if (!deferred || event.type !== MothershipStreamV1EventType.tool) return false + const tool = event.payload as ToolEventPayload + if ( + tool.phase !== MothershipStreamV1ToolPhase.result || + tool.toolName !== WORKSPACE_FILE_TOOL || + tool.toolCallId !== deferred.call.toolCallId + ) { + return false + } + deferred.result = tool + return true + } + + private absorbsDeferredWorkspaceFile(event: StreamEvent): boolean { + const deferred = this.deferredWorkspaceFile + if ( + !deferred || + event.type !== MothershipStreamV1EventType.span || + event.payload.kind !== MothershipStreamV1SpanPayloadKind.subagent || + event.payload.event !== MothershipStreamV1SpanLifecycleEvent.start + ) { + return false + } + const data = record(event.payload.data) + const agent = stringValue(event.payload.agent) ?? stringValue(event.scope?.agentId) + const triggerId = + stringValue(event.scope?.parentToolCallId) ?? + stringValue(data?.tool_call_id) ?? + stringValue(data?.toolCallId) + return agent === FILE_SUBAGENT && triggerId === deferred.call.toolCallId + } + + private hideDeferredWorkspaceFile(): void { + const deferred = this.deferredWorkspaceFile + if (!deferred) return + const callId = stringValue(deferred.call.toolCallId) + if (callId) this.calls.set(callId, { visibility: 'hidden' }) + this.deferredWorkspaceFile = undefined + } + + private flushDeferredWorkspaceFile(): V2ChatActivity[] { + const deferred = this.deferredWorkspaceFile + if (!deferred) return [] + this.deferredWorkspaceFile = undefined + return [ + ...this.projectTool(deferred.call), + ...(deferred.result ? this.projectTool(deferred.result) : []), + ] + } + + private breaksDeferral(event: StreamEvent): boolean { + if (event.type === MothershipStreamV1EventType.tool) { + const tool = event.payload as ToolEventPayload + return tool.phase !== MothershipStreamV1ToolPhase.args_delta + } + if (event.type === MothershipStreamV1EventType.text) { + return ( + event.payload.channel === MothershipStreamV1TextChannel.assistant && !!event.payload.text + ) + } + if (event.type === MothershipStreamV1EventType.span) { + return event.payload.kind === MothershipStreamV1SpanPayloadKind.subagent + } + return ( + event.type === MothershipStreamV1EventType.error || + event.type === MothershipStreamV1EventType.complete + ) + } + + private publicToolId(): string { + return `tool-${this.nextToolId++}` + } + + private publicAgentId(): string { + return `agent-${this.nextAgentId++}` + } + + private agentActivity(agent: AgentProjection): V2ChatNodeActivity { + return { + kind: 'subagent', + id: agent.id, + ...(agent.parentId ? { parentId: agent.parentId } : {}), + label: agent.label, + state: agent.state, + } + } + + private toolActivity( + projection: ToolProjection, + projectedState: ProjectedToolState + ): V2ChatNodeActivity { + projection.state = projectedState.state + projection.status = projectedState.status + return { + kind: 'tool', + id: projection.id!, + ...(projection.parentId ? { parentId: projection.parentId } : {}), + label: getToolStatusDisplayTitle(projection.label!, projectedState.status), + state: projectedState.state, + } + } +} + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined +} + +function toolState(tool: ToolEventPayload): ProjectedToolState { + if (tool.phase === MothershipStreamV1ToolPhase.result) { + const outcome = resolveStreamToolOutcome({ + output: tool.output, + ...(typeof tool.status === 'string' ? { status: tool.status } : {}), + ...(typeof tool.success === 'boolean' ? { success: tool.success } : {}), + }) + return { + state: + outcome === MothershipStreamV1ToolOutcome.success || + outcome === MothershipStreamV1ToolOutcome.skipped + ? 'complete' + : 'error', + status: outcome, + } + } + const status = typeof tool.status === 'string' ? tool.status : 'running' + if (tool.status === MothershipStreamV1ToolStatus.success) return { state: 'complete', status } + if (tool.status === MothershipStreamV1ToolStatus.skipped) return { state: 'complete', status } + if (ERROR_STATUSES.has(status)) return { state: 'error', status } + return { state: 'running', status } +} diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts new file mode 100644 index 00000000000..09fd941b55a --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -0,0 +1,1549 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAcquirePendingChatStream, + mockCheckAttributedUsageLimits, + mockCheckRateLimit, + mockClearFilePreviewSessions, + mockCleanupAbortMarker, + mockCreateRunSegment, + mockEnv, + mockEnvFlags, + mockFinalizeStream, + mockFireTitleGeneration, + mockGenerateId, + mockGetAccessibleCopilotChatContinuationMetadata, + mockIssueV2ChatContinuationToken, + mockPersistCopilotUserMessage, + mockPrepareV2ChatAttachments, + mockPublishStatusChanged, + mockPublisherClose, + mockPublisherFlush, + mockPublisherPublish, + mockRegisterActiveStream, + mockReleasePendingChatStream, + mockResetBuffer, + mockResolveOrCreateChat, + mockRequestExplicitStreamAbort, + mockResolveBillingAttribution, + mockResolveSystemBillingAttribution, + mockResolveWorkspaceAccess, + mockRunWorkspaceChat, + mockScheduleBufferCleanup, + mockScheduleFilePreviewSessionCleanup, + mockStartAbortPoller, + mockStreamWriter, + mockTurnOnComplete, + mockTurnOnError, + mockUnregisterActiveStream, + mockVerifyV2ChatContinuationToken, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockAcquirePendingChatStream: vi.fn(), + mockCheckAttributedUsageLimits: vi.fn(), + mockCheckRateLimit: vi.fn(), + mockClearFilePreviewSessions: vi.fn(), + mockCleanupAbortMarker: vi.fn(), + mockCreateRunSegment: vi.fn(), + mockEnv: { COPILOT_API_KEY: 'deployment-mothership-key' as string | undefined }, + mockEnvFlags: { isAuthDisabled: false }, + mockFinalizeStream: vi.fn(), + mockFireTitleGeneration: vi.fn(), + mockGenerateId: vi.fn(), + mockGetAccessibleCopilotChatContinuationMetadata: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPersistCopilotUserMessage: vi.fn(), + mockPrepareV2ChatAttachments: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockPublisherClose: vi.fn(), + mockPublisherFlush: vi.fn(), + mockPublisherPublish: vi.fn(), + mockRegisterActiveStream: vi.fn(), + mockReleasePendingChatStream: vi.fn(), + mockResetBuffer: vi.fn(), + mockResolveOrCreateChat: vi.fn(), + mockRequestExplicitStreamAbort: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockResolveSystemBillingAttribution: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockRunWorkspaceChat: vi.fn(), + mockScheduleBufferCleanup: vi.fn(), + mockScheduleFilePreviewSessionCleanup: vi.fn(), + mockStartAbortPoller: vi.fn(), + mockStreamWriter: vi.fn(), + mockTurnOnComplete: vi.fn(), + mockTurnOnError: vi.fn(), + mockUnregisterActiveStream: vi.fn(), + mockVerifyV2ChatContinuationToken: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + checkAttributedUsageLimits: mockCheckAttributedUsageLimits, + resolveBillingAttribution: mockResolveBillingAttribution, + resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + createRunSegment: mockCreateRunSegment, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatContinuationMetadata: mockGetAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat: mockResolveOrCreateChat, +})) + +vi.mock('@/lib/copilot/chat/turn-persistence', () => ({ + buildCopilotTurnOnComplete: () => mockTurnOnComplete, + buildCopilotTurnOnError: () => mockTurnOnError, + persistCopilotUserMessage: mockPersistCopilotUserMessage, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/copilot/headless/workspace-chat', () => ({ + runWorkspaceChat: mockRunWorkspaceChat, + publicChatUsageLimitMessage: (content: string) => { + const match = /^(.+)<\/usage_upgrade>$/.exec(content) + if (!match) return null + return (JSON.parse(match[1]) as { message: string }).message + }, + toPublicChatResult: ( + result: { content: string; usage?: { prompt: number; completion: number } }, + continuationToken: string + ) => ({ + content: result.content, + continuationToken, + usage: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: result.usage.prompt + result.usage.completion, + } + : {}, + }), +})) + +vi.mock('@/lib/copilot/headless/attachments', () => ({ + prepareV2ChatAttachments: mockPrepareV2ChatAttachments, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, + verifyV2ChatContinuationToken: mockVerifyV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ + requestExplicitStreamAbort: mockRequestExplicitStreamAbort, +})) + +vi.mock('@/lib/copilot/request/lifecycle/finalize', () => ({ + finalizeStream: mockFinalizeStream, +})) + +vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ + fireTitleGeneration: mockFireTitleGeneration, +})) + +vi.mock('@/lib/copilot/request/session', () => ({ + AbortReason: { UserStop: 'user_stop:abortActiveStream' }, + StreamWriter: mockStreamWriter, + acquirePendingChatStream: mockAcquirePendingChatStream, + clearFilePreviewSessions: mockClearFilePreviewSessions, + cleanupAbortMarker: mockCleanupAbortMarker, + encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), + encodeSSEEnvelope: (value: unknown) => + new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`), + registerActiveStream: mockRegisterActiveStream, + releasePendingChatStream: mockReleasePendingChatStream, + resetBuffer: mockResetBuffer, + scheduleBufferCleanup: mockScheduleBufferCleanup, + scheduleFilePreviewSessionCleanup: mockScheduleFilePreviewSessionCleanup, + SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' }, + startAbortPoller: mockStartAbortPoller, + unregisterActiveStream: mockUnregisterActiveStream, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { MAX_V2_CHAT_BODY_BYTES } from '@/lib/api/contracts/v2/chat' +import { POST } from '@/app/api/v2/chat/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'key-owner-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-05T12:00:00.000Z'), +} + +const personalAttribution = { + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: null, + billingEntity: { type: 'user' as const, id: 'payer-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +const systemAttribution = { + ...personalAttribution, + actorUserId: 'workspace-billed-account', +} + +function callChat(body: Record, headers: Record = {}) { + return POST( + createMockRequest( + 'POST', + body, + { 'Content-Type': 'application/json', 'x-api-key': 'caller-platform-key', ...headers }, + 'http://localhost:3000/api/v2/chat' + ) + ) +} + +function parseSse(stream: string): Record[] { + return stream + .split('\n') + .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice('data: '.length)) as Record) +} + +describe('POST /api/v2/chat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnv.COPILOT_API_KEY = 'deployment-mothership-key' + mockEnvFlags.isAuthDisabled = false + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-1') + .mockReturnValueOnce('execution-1') + .mockReturnValueOnce('run-1') + .mockReturnValue('generated-extra') + mockResolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1', type: 'mothership', title: null }, + conversationHistory: [], + isNew: true, + }) + mockStreamWriter.mockImplementation(function MockStreamWriter() { + return { + close: mockPublisherClose, + flush: mockPublisherFlush, + publish: mockPublisherPublish, + sawComplete: false, + } + }) + mockIssueV2ChatContinuationToken.mockReturnValue('continuation-new') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValue(null) + mockVerifyV2ChatContinuationToken.mockReturnValue({ valid: false }) + mockPrepareV2ChatAttachments.mockReturnValue({ success: true, attachments: [] }) + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockResolveBillingAttribution.mockResolvedValue(personalAttribution) + mockResolveSystemBillingAttribution.mockResolvedValue(systemAttribution) + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) + mockAcquirePendingChatStream.mockResolvedValue(true) + mockClearFilePreviewSessions.mockResolvedValue(undefined) + mockCleanupAbortMarker.mockResolvedValue(undefined) + mockCreateRunSegment.mockResolvedValue({ id: 'run-1' }) + mockFinalizeStream.mockResolvedValue(undefined) + mockPersistCopilotUserMessage.mockResolvedValue(undefined) + mockPublisherClose.mockResolvedValue(undefined) + mockPublisherFlush.mockResolvedValue(undefined) + mockReleasePendingChatStream.mockResolvedValue(undefined) + mockResetBuffer.mockResolvedValue(undefined) + mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockScheduleBufferCleanup.mockResolvedValue(undefined) + mockScheduleFilePreviewSessionCleanup.mockResolvedValue(undefined) + mockStartAbortPoller.mockReturnValue(0) + mockRunWorkspaceChat.mockImplementation(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + return { + success: true, + content: 'Hello from Sim', + contentBlocks: [], + toolCalls: [], + usage: { prompt: 8, completion: 3 }, + } + }) + }) + + it('streams a personal-key chat and bills its authenticated actor', async () => { + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + expect(response.headers.get('x-ratelimit-remaining')).toBe('99') + expect(stream).toContain('"type":"session"') + expect(stream).toContain('"continuationToken":"continuation-new"') + expect(stream).toContain('"chatId":"chat-1"') + expect(stream).toContain('"delta":"Hello from Sim"') + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('data: [DONE]') + + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + }) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'key-owner-1', + workspaceId: 'workspace-1', + billingAttribution: personalAttribution, + readOnly: false, + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentialType: 'personal', + readOnly: false, + persistence: 'sim', + }) + ) + expect(mockRunWorkspaceChat.mock.calls[0][0]).not.toHaveProperty('apiKey') + expect(mockAcquirePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockRegisterActiveStream).toHaveBeenCalledWith( + 'message-1', + expect.any(AbortController), + expect.any(AbortController) + ) + expect(mockStartAbortPoller).toHaveBeenCalledWith('message-1', expect.any(AbortController), { + requestId: 'request-1', + chatId: 'chat-1', + userStopController: expect.any(AbortController), + }) + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + expect(mockResolveOrCreateChat).toHaveBeenCalledWith({ + userId: 'key-owner-1', + workspaceId: 'workspace-1', + model: 'claude-opus-4-8', + type: 'mothership', + }) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'created', + }) + expect(mockCreateRunSegment).toHaveBeenCalledWith({ + id: 'run-1', + executionId: 'execution-1', + chatId: 'chat-1', + userId: 'key-owner-1', + workspaceId: 'workspace-1', + streamId: 'message-1', + model: null, + requestContext: { requestId: 'request-1', source: 'v2_chat' }, + }) + expect(mockResetBuffer).toHaveBeenCalledWith('message-1') + expect(mockClearFilePreviewSessions).toHaveBeenCalledWith('message-1') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith({ + chatId: 'chat-1', + userMessageId: 'message-1', + message: 'What is here?', + contexts: undefined, + workspaceId: 'workspace-1', + notifyWorkspaceStatus: true, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'chat', chatId: 'chat-1' }, + }) + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'text', + payload: { channel: 'assistant', text: 'Hello from Sim' }, + }) + expect(mockFinalizeStream).toHaveBeenCalledWith( + expect.objectContaining({ success: true, content: 'Hello from Sim' }), + expect.any(Object), + 'run-1', + 'success', + 'request-1' + ) + expect(mockFireTitleGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + isNewChat: true, + message: 'What is here?', + workspaceId: 'workspace-1', + }) + ) + expect(mockPublisherClose).toHaveBeenCalledTimes(1) + expect(mockScheduleBufferCleanup).toHaveBeenCalledWith('message-1') + expect(mockScheduleFilePreviewSessionCleanup).toHaveBeenCalledWith('message-1') + }) + + it('passes validated resource and slash contexts to workspace chat', async () => { + const contexts = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use @Release and /review with /Docs', + contexts, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ contexts })) + }) + + it('rejects malformed or unsupported public context variants', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Use this', + contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Private folder' }], + }) + + expect(response.status).toBe(400) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails with a retryable conflict before exposing a session when the chat lease is busy', async () => { + mockAcquirePendingChatStream.mockResolvedValueOnce(false) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A response is already in progress for this chat', + }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRegisterActiveStream).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + }) + + it('does not issue a session token or start Mothership before the chat lease is acquired', async () => { + let acquire!: (value: boolean) => void + mockAcquirePendingChatStream.mockReturnValueOnce( + new Promise((resolve) => { + acquire = resolve + }) + ) + + const pendingResponse = callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + + acquire(true) + const response = await pendingResponse + const stream = await response.text() + expect(stream).toContain('"type":"session"') + expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1) + }) + + it('does not expose the continuation token until Go accepts the initial stream', async () => { + let accept!: () => void + let settle!: () => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publisher.publish({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + } + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + accept = () => input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const reader = response.body!.getReader() + let firstReadSettled = false + const firstRead = reader.read().then((result) => { + firstReadSettled = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(firstReadSettled).toBe(false) + + accept() + const first = await firstRead + const acceptedSession = new TextDecoder().decode(first.value) + expect(acceptedSession).toContain('"type":"session"') + expect(acceptedSession).toContain('"continuationToken":"continuation-new"') + expect(acceptedSession).toContain('"title":"Release investigation"') + expect(mockPublisherPublish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'title', title: 'Release investigation' }, + }) + + settle() + while (!(await reader.read()).done) { + // Drain the completion so route cleanup can release its lease. + } + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) + + it('projects a title generated after session acceptance onto the public stream', async () => { + let publishTitle!: (event: unknown) => void + mockFireTitleGeneration.mockImplementationOnce( + ({ publisher }: { publisher: { publish: (event: unknown) => void } }) => { + publishTitle = publisher.publish + } + ) + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + publishTitle({ + type: 'session', + payload: { kind: 'title', title: 'Deployment failure' }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What failed?' }) + const events = parseSse(await response.text()) + + expect(events).toContainEqual({ + type: 'session', + chatId: 'chat-1', + title: 'Deployment failure', + }) + }) + + it('does not hold the Go leg on run-segment creation but waits before finalizing it', async () => { + let resolveRunSegment!: () => void + let resolveChat!: () => void + mockCreateRunSegment.mockReturnValueOnce( + new Promise((resolve) => { + resolveRunSegment = () => resolve({ id: 'run-1' }) + }) + ) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + input.onInitialStreamAccepted?.() + resolveChat = () => + resolve({ + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + await vi.waitFor(() => expect(mockRunWorkspaceChat).toHaveBeenCalledTimes(1)) + + resolveChat() + await new Promise((resolve) => setImmediate(resolve)) + expect(mockFinalizeStream).not.toHaveBeenCalled() + + resolveRunSegment() + expect(await response.text()).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('keeps a synced turn working when run-segment creation fails', async () => { + mockCreateRunSegment.mockRejectedValueOnce(new Error('run table unavailable')) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Continue' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"type":"complete"') + expect(mockFinalizeStream).toHaveBeenCalledTimes(1) + }) + + it('surfaces a pre-acceptance failure without exposing a continuation token', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'workspace setup failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Tell me more' }) + const stream = await response.text() + + expect(stream).not.toContain('"type":"session"') + expect(stream).toContain('"code":"INTERNAL_ERROR"') + }) + + it('enables the subtractive query policy only when explicitly requested', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Only inspect this workspace', + readOnly: true, + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'personal', readOnly: true }) + ) + }) + + it('continues a legacy Go-only chat without exposing or partially persisting it', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'private-chat-id', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGenerateId.mockReset().mockReturnValue('message-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Tell me more', + continuationToken: 'continuation-old', + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockVerifyV2ChatContinuationToken).toHaveBeenCalledWith('continuation-old', { + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'private-chat-id', + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal', + readOnly: false, + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'private-chat-id', + messageId: 'message-followup', + }) + ) + expect(stream).toContain('"continuationToken":"continuation-refreshed"') + expect(stream).not.toContain('private-chat-id') + expect(mockGetAccessibleCopilotChatContinuationMetadata).toHaveBeenCalledWith( + 'private-chat-id', + 'key-owner-1' + ) + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('continues an existing persisted personal chat with UI replay enabled', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'shared-chat-1', + }) + mockIssueV2ChatContinuationToken.mockReturnValueOnce('continuation-refreshed') + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce({ + id: 'shared-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Existing chat', + hasMessages: true, + mcpServerIds: ['mcp-history'], + }) + mockGenerateId + .mockReset() + .mockReturnValueOnce('message-followup') + .mockReturnValueOnce('execution-followup') + .mockReturnValueOnce('run-followup') + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-old', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"chatId":"shared-chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'shared-chat-1', + userMessageId: 'message-followup', + message: 'Continue', + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + mcpServerIds: ['mcp-history'], + contexts: [{ kind: 'mcp', serverId: 'mcp-current', label: 'Current' }], + }) + ) + expect(mockCreateRunSegment).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'run-followup', + executionId: 'execution-followup', + chatId: 'shared-chat-1', + }) + ) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'shared-chat-1', persistence: 'sim' }) + ) + }) + + it.each([ + ['missing or deleted', null], + [ + 'the wrong type', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + type: 'copilot', + title: 'Workflow chat', + hasMessages: true, + }, + ], + [ + 'from another workspace', + { + id: 'synced-chat-1', + userId: 'key-owner-1', + workflowId: null, + workspaceId: 'workspace-2', + type: 'mothership', + title: 'Other workspace', + hasMessages: true, + }, + ], + ])('rejects an explicitly Sim-persisted continuation when its row is %s', async (_case, chat) => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ + valid: true, + chatId: 'synced-chat-1', + persistence: 'sim', + }) + mockGetAccessibleCopilotChatContinuationMetadata.mockResolvedValueOnce(chat) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Continue', + continuationToken: 'continuation-sim', + }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('fails closed before billing or Mothership for an invalid continuation token', async () => { + mockVerifyV2ChatContinuationToken.mockReturnValueOnce({ valid: false }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'steal history', + continuationToken: 'tampered-or-cross-owner-token', + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid or expired continuation token' }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('validates inline attachments and forwards only the server-mapped Mothership shape', async () => { + const publicAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + } + const mothershipAttachment = { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + } + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [mothershipAttachment], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Read this', + attachments: [publicAttachment], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockPrepareV2ChatAttachments).toHaveBeenCalledWith([publicAttachment]) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ fileAttachments: [mothershipAttachment] }) + ) + }) + + it('normalizes an attachment-only turn to a neutral upstream prompt', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: true, + attachments: [ + { + type: 'document', + filename: 'notes.txt', + source: { type: 'base64', media_type: 'text/plain', data: 'aGk=' }, + }, + ], + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: ' ', + attachments: [{ name: 'notes.txt', mediaType: 'text/plain', data: 'aGk=' }], + }) + await response.text() + + expect(response.status).toBe(200) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'Please inspect the attached file(s).' }) + ) + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Please inspect the attached file(s).' }) + ) + }) + + it('returns a typed HTTP error before billing when attachment validation fails', async () => { + mockPrepareV2ChatAttachments.mockReturnValueOnce({ + success: false, + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Attachment "clip.mp4" has unsupported media type video/mp4', + }, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'Watch this', + attachments: [{ name: 'clip.mp4', mediaType: 'video/mp4', data: 'AAAA' }], + }) + + expect(response.status).toBe(415) + expect((await response.json()).error.code).toBe('UNSUPPORTED_MEDIA_TYPE') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the v2 payload-too-large envelope for an oversized raw body', async () => { + const response = await callChat( + { workspaceId: 'workspace-1', prompt: 'hello' }, + { 'Content-Length': String(MAX_V2_CHAT_BODY_BYTES + 1) } + ) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('forwards Mothership text events as deltas without prefix guessing', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'a' }, + }) + // This delta starts with all prior output. Treating events as possibly + // cumulative would incorrectly emit only "bc" here. + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'abc' }, + }) + return { + success: true, + content: 'aabc', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"delta":"a"') + expect(stream).toContain('"delta":"abc"') + expect(stream).not.toContain('"delta":"bc"') + }) + + it('projects scoped assistant narration without merging it into the public answer', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { channel: 'assistant', text: 'Scoped progress.' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-dispatch', + spanId: 'private-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: 'public final delta' }, + }) + return { + success: true, + content: 'public final delta', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + const events = parseSse(stream) + const activities = events.filter((event) => event.type === 'activity') + const answerText = events.filter((event) => event.type === 'text') + + expect(answerText).toEqual([{ type: 'text', delta: 'public final delta' }]) + expect(activities).toEqual([ + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'running', + }, + }, + { + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'Scoped progress.' }, + }, + { + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research Agent', + state: 'complete', + }, + }, + ]) + expect(stream).not.toContain('private-dispatch') + expect(stream).not.toContain('private-span') + }) + + it('projects a display-safe nested activity tree without private stream data', async () => { + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + input.onInitialStreamAccepted?.() + await input.onEvent?.({ + type: 'text', + payload: { channel: 'thinking', text: 'Inspecting the workspace' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'private-tool-id', + toolName: 'read', + arguments: { secret: 'never-forward-me' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'call', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + arguments: { secret: 'hidden-call-secret' }, + executor: 'sim', + mode: 'async', + ui: { hidden: true }, + }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'hidden-tool-id', + toolName: 'private_hidden_tool', + output: { secret: 'hidden-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'call', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + arguments: { secret: 'scoped-secret' }, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'tool', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { + phase: 'result', + toolCallId: 'scoped-tool-id', + toolName: 'private_scoped_tool', + output: { secret: 'scoped-result-secret' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'start', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'text', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { channel: 'thinking', text: 'private subagent reasoning' }, + }) + await input.onEvent?.({ + type: 'span', + scope: { + lane: 'subagent', + agentId: 'research', + parentToolCallId: 'private-tool-id', + spanId: 'private-research-span', + parentSpanId: 'main', + }, + payload: { kind: 'subagent', event: 'end', agent: 'research' }, + }) + await input.onEvent?.({ + type: 'tool', + payload: { + phase: 'result', + toolCallId: 'private-tool-id', + toolName: 'read', + output: { secret: 'never-forward-me' }, + success: true, + executor: 'sim', + mode: 'async', + }, + }) + return { + success: true, + content: 'Done', + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"type":"complete"') + expect(stream).toContain('Done') + expect(stream).toContain('"type":"activity"') + expect(stream).toContain('"label":"Reading file"') + expect(stream).toContain('"label":"Read file"') + expect(stream).toContain('"label":"Research Agent"') + expect(stream).toContain('"label":"Private Scoped Tool"') + expect(stream).toContain('"parentId":"agent-1"') + expect(stream).toContain('"state":"running"') + expect(stream).toContain('"state":"complete"') + expect(stream.match(/"type":"activity"/g)).toHaveLength(6) + expect(stream).not.toContain('Inspecting the workspace') + expect(stream).not.toContain('private-tool-id') + expect(stream).not.toContain('private_hidden_tool') + expect(stream).not.toContain('private_scoped_tool') + expect(stream).not.toContain('private-research-span') + expect(stream).not.toContain('never-forward-me') + expect(stream).not.toContain('scoped-secret') + expect(stream).not.toContain('scoped-result-secret') + expect(stream).not.toContain('private subagent reasoning') + }) + + it('authorizes a workspace key as its creator but executes and bills as the system actor', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-owner-1', keyType: 'workspace' }), + 'key-owner-1', + 'workspace-1', + 'read' + ) + expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1') + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialType: 'workspace', readOnly: false }) + ) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + sharedWorkspaceCredential: true, + }) + ) + expect(stream).not.toContain('"chatId":"chat-1"') + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockStreamWriter).not.toHaveBeenCalled() + expect(mockPersistCopilotUserMessage).not.toHaveBeenCalled() + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) + + it('routes a workspace-key abort by its owner while preserving the billing actor body', async () => { + mockGenerateId + .mockReset() + .mockReturnValueOnce('chat-1') + .mockReturnValueOnce('message-1') + .mockReturnValue('generated-extra') + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + keyType: 'workspace', + workspaceId: 'workspace-1', + }) + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream failed', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'Summarize it' }) + await response.text() + + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'key-owner-1', + actorUserId: 'workspace-billed-account', + billingAttribution: systemAttribution, + }) + ) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'workspace-billed-account', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('supports the auth-disabled self-host principal while keeping upstream auth server-owned', async () => { + const anonymousAttribution = { + ...personalAttribution, + actorUserId: 'anonymous', + } + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + userId: 'anonymous', + keyType: 'personal', + }) + mockEnvFlags.isAuthDisabled = true + mockResolveBillingAttribution.mockResolvedValue(anonymousAttribution) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'What is here?' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'anonymous', keyType: undefined }), + 'anonymous', + 'workspace-1', + 'read' + ) + expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ + actorUserId: 'anonymous', + workspaceId: 'workspace-1', + }) + expect(mockRunWorkspaceChat).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUserId: 'anonymous', + actorUserId: 'anonymous', + billingAttribution: anonymousAttribution, + }) + ) + expect(stream).toContain('"chatId":"chat-1"') + expect(mockPersistCopilotUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ chatId: 'chat-1', message: 'What is here?' }) + ) + }) + + it('returns 402 before opening a stream or calling Mothership when usage is exhausted', async () => { + mockCheckAttributedUsageLimits.mockResolvedValue({ + isExceeded: true, + message: 'Organization usage limit exceeded', + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(402) + expect(await response.json()).toEqual({ + error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Organization usage limit exceeded' }, + }) + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('surfaces a raced or self-hosted upstream 402 as a structured stream error', async () => { + const upgrade = + '{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}' + mockRunWorkspaceChat.mockImplementationOnce(async (input) => { + await input.onEvent?.({ + type: 'text', + payload: { channel: 'assistant', text: upgrade }, + }) + return { + success: true, + content: upgrade, + contentBlocks: [], + toolCalls: [], + } + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(response.status).toBe(200) + expect(stream).toContain('"code":"USAGE_LIMIT_EXCEEDED"') + expect(stream).toContain('Ask an org admin.') + expect(stream).not.toContain('') + expect(stream).not.toContain('"type":"complete"') + }) + + it('rejects a cross-workspace key before resolving a payer', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'API key is not authorized for this workspace', + }) + + const response = await callChat({ workspaceId: 'workspace-2', prompt: 'hello' }) + + expect(response.status).toBe(403) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns a clear 503 when the deployment has no Mothership key', async () => { + mockEnv.COPILOT_API_KEY = undefined + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Sim Chat is not configured on this deployment', + }, + }) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + }) + + it('does not leak an upstream failure body and explicitly stops detached generation', async () => { + mockRunWorkspaceChat.mockResolvedValueOnce({ + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'upstream secret response body', + errors: ['provider internal detail'], + }) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'hello' }) + const stream = await response.text() + + expect(stream).toContain('"code":"INTERNAL_ERROR"') + expect(stream).toContain('"message":"Chat request failed"') + expect(stream).not.toContain('upstream secret response body') + expect(stream).not.toContain('provider internal detail') + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + }) + + it('rejects caller-controlled identity, model, and provider fields', async () => { + for (const forbidden of [ + { userId: 'forged-user' }, + { model: 'caller-model' }, + { provider: 'caller-provider' }, + { chatId: 'raw-private-chat-id' }, + { conversationId: 'raw-private-chat-id' }, + ]) { + const response = await callChat({ + workspaceId: 'workspace-1', + prompt: 'hello', + ...forbidden, + }) + expect(response.status).toBe(400) + } + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + }) + + it('returns the shared v2 auth error before parsing the body', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date(), + error: 'Invalid API key', + }) + + const response = await callChat({}) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + expect(mockV2ApiGateError).not.toHaveBeenCalled() + }) + + it('stops local work, marks Go once, and retains the lease until the lifecycle settles', async () => { + const teardownOrder: string[] = [] + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockImplementationOnce(async () => { + teardownOrder.push('go-abort') + }) + mockReleasePendingChatStream.mockImplementationOnce(async () => { + teardownOrder.push('release') + }) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: false, + cancelled: true, + content: '', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith({ + streamId: 'message-1', + userId: 'key-owner-1', + routingUserId: 'key-owner-1', + chatId: 'chat-1', + workspaceId: 'workspace-1', + }) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + expect(teardownOrder).toEqual(['go-abort', 'release']) + expect(mockUnregisterActiveStream).toHaveBeenCalledTimes(1) + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('does not start a lifecycle when Stop wins before workspace chat begins', async () => { + mockRegisterActiveStream.mockImplementationOnce( + ( + _streamId: string, + _lifecycleController: AbortController, + userStopController: AbortController + ) => userStopController.abort('user_stop:abortActiveStream') + ) + + const response = await callChat({ workspaceId: 'workspace-1', prompt: 'keep going' }) + expect(await response.text()).toBe('') + + expect(mockRunWorkspaceChat).not.toHaveBeenCalled() + expect(mockUnregisterActiveStream).toHaveBeenCalledWith('message-1') + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'message-1') + expect(mockCleanupAbortMarker).toHaveBeenCalledWith('message-1') + }) + + it('still stops local work and retains the lease when the Go abort marker fails', async () => { + let settle!: () => void + let lifecycleSignal: AbortSignal | undefined + let userStopSignal: AbortSignal | undefined + mockRequestExplicitStreamAbort.mockRejectedValueOnce(new Error('marker unavailable')) + mockRunWorkspaceChat.mockImplementationOnce( + (input) => + new Promise((resolve) => { + lifecycleSignal = input.abortSignal + userStopSignal = input.userStopSignal + input.onInitialStreamAccepted?.() + settle = () => + resolve({ + success: true, + content: 'settled naturally', + contentBlocks: [], + toolCalls: [], + }) + }) + ) + + const request = new NextRequest('http://localhost:3000/api/v2/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'caller-platform-key', + }, + body: JSON.stringify({ workspaceId: 'workspace-1', prompt: 'keep going' }), + }) + const response = await POST(request) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel('test_disconnect') + + await vi.waitFor(() => expect(mockRequestExplicitStreamAbort).toHaveBeenCalledTimes(1)) + expect(lifecycleSignal?.aborted).toBe(false) + expect(userStopSignal?.aborted).toBe(true) + expect(userStopSignal?.reason).toBe('user_stop:abortActiveStream') + expect(mockReleasePendingChatStream).not.toHaveBeenCalled() + + settle() + await vi.waitFor(() => expect(mockReleasePendingChatStream).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts new file mode 100644 index 00000000000..cb99ea7ab07 --- /dev/null +++ b/apps/sim/app/api/v2/chat/route.ts @@ -0,0 +1,691 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { NextRequest } from 'next/server' +import { MAX_V2_CHAT_BODY_BYTES, v2ChatContract } from '@/lib/api/contracts/v2/chat' +import { parseRequest } from '@/lib/api/server' +import { + checkAttributedUsageLimits, + resolveBillingAttribution, + resolveSystemBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { createRunSegment } from '@/lib/copilot/async-runs/repository' +import { + getAccessibleCopilotChatContinuationMetadata, + resolveOrCreateChat, +} from '@/lib/copilot/chat/lifecycle' +import { + buildCopilotTurnOnComplete, + buildCopilotTurnOnError, + persistCopilotUserMessage, +} from '@/lib/copilot/chat/turn-persistence' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + MothershipStreamV1EventType, + MothershipStreamV1SessionKind, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' +import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' +import { + issueV2ChatContinuationToken, + verifyV2ChatContinuationToken, +} from '@/lib/copilot/headless/continuation-token' +import { + publicChatUsageLimitMessage, + runWorkspaceChat, + toPublicChatResult, +} from '@/lib/copilot/headless/workspace-chat' +import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' +import { fireTitleGeneration } from '@/lib/copilot/request/lifecycle/start' +import { + AbortReason, + acquirePendingChatStream, + cleanupAbortMarker, + clearFilePreviewSessions, + encodeSSEComment, + encodeSSEEnvelope, + registerActiveStream, + releasePendingChatStream, + resetBuffer, + SSE_RESPONSE_HEADERS, + StreamWriter, + scheduleBufferCleanup, + scheduleFilePreviewSessionCleanup, + startAbortPoller, + unregisterActiveStream, +} from '@/lib/copilot/request/session' +import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { env } from '@/lib/core/config/env' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { ChatActivityProjector, type V2ChatActivity } from '@/app/api/v2/chat/activity' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + rateLimitHeaders, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +export const maxDuration = 3600 +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const logger = createLogger('V2ChatAPI') +const encoder = new TextEncoder() +const HEARTBEAT_INTERVAL_MS = 15_000 +const ATTACHMENT_ONLY_PROMPT = 'Please inspect the attached file(s).' +const V2_CHAT_TITLE_MODEL = 'claude-opus-4-8' + +interface SyncedChat { + chat: { title?: string | null } | null + isNewChat: boolean + mcpServerIds: string[] +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +/** POST /api/v2/chat — normal workspace chat with opaque continuation over SSE. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + let acquiredChatId: string | undefined + let acquiredStreamId: string | undefined + let streamOwnsLock = false + + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const authenticatedUserId = rateLimit.userId! + const gate = await v2ApiGateError(authenticatedUserId) + if (gate) return gate + + const parsed = await parseRequest( + v2ChatContract, + request, + {}, + { + maxBodyBytes: MAX_V2_CHAT_BODY_BYTES, + validationErrorResponse: v2ValidationError, + invalidJsonResponse: () => + v2Error('BAD_REQUEST', 'Request body must be valid JSON', { + headers: rateLimitHeaders(rateLimit), + }), + } + ) + if (!parsed.success) { + return parsed.response.status === 413 + ? v2Error( + 'PAYLOAD_TOO_LARGE', + `Request body exceeds the ${MAX_V2_CHAT_BODY_BYTES}-byte limit`, + { headers: rateLimitHeaders(rateLimit) } + ) + : parsed.response + } + + const { workspaceId, prompt, continuationToken, readOnly, attachments, contexts } = + parsed.data.body + const credentialType = rateLimit.keyType === 'workspace' ? 'workspace' : 'personal' + const effectivePrompt = prompt.trim() ? prompt : ATTACHMENT_ONLY_PROMPT + // DISABLE_AUTH produces an anonymous pseudo-personal principal. It is not + // an API key, so the workspace's personal-key toggle must not reject it. + // Real personal keys retain the normal toggle on every hosted path. + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess( + accessPrincipal, + authenticatedUserId, + workspaceId, + 'read' + ) + if (access) return v2WorkspaceAccessError(access) + + // Sim API keys authenticate this public boundary only. Every Sim -> Go + // request uses the deployment-owned key so hosted and self-hosted billing + // semantics cannot be changed by a caller-controlled credential. + if (!env.COPILOT_API_KEY?.trim()) { + return v2Error('SERVICE_UNAVAILABLE', 'Sim Chat is not configured on this deployment', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const continuation = continuationToken + ? await verifyV2ChatContinuationToken(continuationToken, { + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + }) + : null + if (continuation && !continuation.valid) { + return v2Error('BAD_REQUEST', 'Invalid or expired continuation token', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const shouldSyncChat = rateLimit.keyType === 'personal' + let continuedSyncedChat: SyncedChat | null = null + if (continuation?.valid) { + if (continuation.persistence === 'sim' && !shouldSyncChat) { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + if (shouldSyncChat) { + const existing = await getAccessibleCopilotChatContinuationMetadata( + continuation.chatId, + authenticatedUserId + ) + const matchesPersistedChat = + existing?.type === 'mothership' && existing.workspaceId === workspaceId + /** + * Tokens issued before Sim-side persistence can point at a Go-only + * chat. A deleted/missing row follows the same path: keep the valid + * continuation working, but do not create a partial UI transcript + * without its earlier turns. + */ + if (matchesPersistedChat && existing) { + continuedSyncedChat = { + chat: { title: existing.title }, + isNewChat: !existing.hasMessages, + mcpServerIds: existing.mcpServerIds, + } + } else if (continuation.persistence === 'sim') { + return v2Error('NOT_FOUND', 'Chat not found', { + headers: rateLimitHeaders(rateLimit), + }) + } + } + } + + const preparedAttachments = prepareV2ChatAttachments(attachments) + if (!preparedAttachments.success) { + return v2Error(preparedAttachments.error.code, preparedAttachments.error.message, { + headers: rateLimitHeaders(rateLimit), + }) + } + + /** + * Match public workflow execution: a personal key identifies its human + * actor; a shared workspace key uses the atomically resolved system actor + * and payer. Authorization above always remains bound to the key owner. + */ + const billingAttribution = + rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: authenticatedUserId, workspaceId }) + const actorUserId = billingAttribution.actorUserId + + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + { headers: rateLimitHeaders(rateLimit) } + ) + } + + let syncedChat = continuedSyncedChat + let chatId: string + + if (continuation?.valid) { + chatId = continuation.chatId + } else if (shouldSyncChat) { + const created = await resolveOrCreateChat({ + userId: authenticatedUserId, + workspaceId, + model: V2_CHAT_TITLE_MODEL, + type: 'mothership', + }) + if (!created.chat || !created.chatId) { + throw new Error('Failed to create persisted v2 chat') + } + syncedChat = { + chat: created.chat, + isNewChat: created.conversationHistory.length === 0, + mcpServerIds: [], + } + chatId = created.chatId + chatPubSub?.publishStatusChanged({ workspaceId, chatId, type: 'created' }) + } else { + chatId = generateId() + } + + const messageId = generateId() + const executionId = syncedChat ? generateId() : undefined + const runId = syncedChat ? generateId() : undefined + const replayPublisher = syncedChat + ? new StreamWriter({ streamId: messageId, chatId, requestId }) + : null + const onTurnComplete = syncedChat + ? buildCopilotTurnOnComplete({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const onTurnError = syncedChat + ? buildCopilotTurnOnError({ + chatId, + userMessageId: messageId, + requestId, + workspaceId, + notifyWorkspaceStatus: true, + }) + : undefined + const lifecycleAbortController = new AbortController() + const userStopController = new AbortController() + const chatStreamLockAcquired = await acquirePendingChatStream(chatId, messageId) + if (!chatStreamLockAcquired) { + return v2Error('CONFLICT', 'A response is already in progress for this chat', { + headers: rateLimitHeaders(rateLimit), + }) + } + acquiredChatId = chatId + acquiredStreamId = messageId + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + + const refreshedContinuationToken = await issueV2ChatContinuationToken({ + chatId, + workspaceId, + authorizationUserId: authenticatedUserId, + credentialType, + readOnly, + ...(syncedChat ? { persistence: 'sim' as const } : {}), + }) + if (request.signal.aborted) { + await releasePendingChatStream(chatId, messageId) + acquiredChatId = undefined + acquiredStreamId = undefined + return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request cancelled', { + headers: rateLimitHeaders(rateLimit), + }) + } + let cancelled = false + let publicStreamOpen = false + let lifecycleStarted = false + let abortRequested = false + let allowExplicitAbort = true + let explicitAbortRequest: Promise | undefined + + const requestExplicitAbortOnce = () => { + if (!lifecycleStarted || !allowExplicitAbort) return undefined + if (!explicitAbortRequest) { + explicitAbortRequest = requestExplicitStreamAbort({ + streamId: messageId, + // Go scopes the live stream to its execution/billing actor, while Sim + // must choose the upstream environment from the API-key owner. Keeping + // those identities separate prevents an actor override from rerouting + // Stop without breaking Go's owner-scoped abort marker. + userId: actorUserId, + routingUserId: authenticatedUserId, + chatId, + workspaceId, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to send explicit abort for v2 chat`, { + error: toError(error).message, + }) + }) + } + return explicitAbortRequest + } + + /** + * A disconnected public reader is an explicit stop request. Match the web + * UI's Stop path: stop Sim-side work, mark the detached Go execution, and + * keep draining the active Go leg so persistence settles before cleanup. + * The route owns the chat lease until that lifecycle has unwound. + */ + const abortLifecycle = () => { + abortRequested = true + requestExplicitAbortOnce() + if (allowExplicitAbort && !userStopController.signal.aborted) { + userStopController.abort(AbortReason.UserStop) + } + } + const onRequestAbort = () => abortLifecycle() + + if (request.signal.aborted) onRequestAbort() + else request.signal.addEventListener('abort', onRequestAbort, { once: true }) + + let heartbeatId: ReturnType | undefined + const stream = new ReadableStream({ + start(controller) { + publicStreamOpen = true + registerActiveStream(messageId, lifecycleAbortController, userStopController) + const abortPoller = startAbortPoller(messageId, lifecycleAbortController, { + requestId, + chatId, + userStopController, + }) + const send = (data: unknown): boolean => { + if (cancelled || !publicStreamOpen) return false + controller.enqueue(encodeSSEEnvelope(data)) + return true + } + const activityProjector = new ChatActivityProjector() + const sendActivities = (activities: V2ChatActivity[]) => { + for (const activity of activities) send({ type: 'activity', data: activity }) + } + + let sessionSent = false + let pendingTitle = syncedChat?.chat?.title?.trim() || undefined + let publishedTitle: string | undefined + let replayFinalized = false + let runSegmentPromise: Promise | undefined + const publishTitle = (title: string) => { + const next = title.trim() + if (!next) return + pendingTitle = next + if (!sessionSent || next === publishedTitle) return + if (send({ type: 'session', chatId, title: next })) publishedTitle = next + } + const sendSession = () => { + if (sessionSent) return + sessionSent = true + const sent = send({ + type: 'session', + continuationToken: refreshedContinuationToken, + requestId, + ...(syncedChat ? { chatId } : {}), + ...(pendingTitle ? { title: pendingTitle } : {}), + }) + if (sent && pendingTitle) publishedTitle = pendingTitle + } + heartbeatId = setInterval(() => { + if (!cancelled && publicStreamOpen) { + controller.enqueue(encodeSSEComment(`heartbeat ${new Date().toISOString()}`)) + } + }, HEARTBEAT_INTERVAL_MS) + + void (async () => { + try { + if (lifecycleAbortController.signal.aborted || userStopController.signal.aborted) { + return + } + + if (replayPublisher && syncedChat && executionId && runId) { + await Promise.all([resetBuffer(messageId), clearFilePreviewSessions(messageId)]) + runSegmentPromise = createRunSegment({ + id: runId, + executionId, + chatId, + userId: authenticatedUserId, + workspaceId, + streamId: messageId, + model: null, + requestContext: { requestId, source: 'v2_chat' }, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to create v2 chat run segment`, { + error: getErrorMessage(error), + }) + }) + replayPublisher.publish({ + type: MothershipStreamV1EventType.session, + payload: { kind: MothershipStreamV1SessionKind.chat, chatId }, + }) + await replayPublisher.flush() + await persistCopilotUserMessage({ + chatId, + userMessageId: messageId, + message: effectivePrompt, + contexts, + workspaceId, + notifyWorkspaceStatus: true, + }) + fireTitleGeneration({ + chatId, + currentChat: syncedChat.chat, + isNewChat: syncedChat.isNewChat, + userId: authenticatedUserId, + message: effectivePrompt, + titleModel: V2_CHAT_TITLE_MODEL, + workspaceId, + billingAttribution, + requestId, + publisher: { + publish(event) { + replayPublisher.publish(event) + if ( + event.type === MothershipStreamV1EventType.session && + event.payload.kind === MothershipStreamV1SessionKind.title + ) { + publishTitle(event.payload.title) + } + }, + }, + }) + } + + lifecycleStarted = true + if (abortRequested) requestExplicitAbortOnce() + const result = await runWorkspaceChat({ + prompt: effectivePrompt, + authorizationUserId: authenticatedUserId, + actorUserId, + workspaceId, + chatId, + messageId, + requestId, + executionId, + runId, + billingAttribution, + readOnly, + sharedWorkspaceCredential: credentialType === 'workspace', + fileAttachments: preparedAttachments.attachments, + contexts, + mcpServerIds: syncedChat?.mcpServerIds, + abortSignal: lifecycleAbortController.signal, + userStopSignal: userStopController.signal, + onInitialStreamAccepted: sendSession, + onEvent: async (event) => { + replayPublisher?.publish(event) + sendActivities(activityProjector.project(event)) + if ( + event.type === MothershipStreamV1EventType.text && + event.payload.channel === MothershipStreamV1TextChannel.assistant && + !event.scope && + event.payload.text + ) { + const text = event.payload.text + if (!publicChatUsageLimitMessage(text)) { + send({ type: 'text', delta: text }) + } + } + }, + onComplete: onTurnComplete, + onError: onTurnError, + }) + + if (replayPublisher && runId) { + await runSegmentPromise + const replayOutcome = result.success + ? RequestTraceV1Outcome.success + : result.cancelled || + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error + await finalizeStream(result, replayPublisher, runId, replayOutcome, requestId) + replayFinalized = true + } + + const upstreamUsageLimit = publicChatUsageLimitMessage(result.content) + if (upstreamUsageLimit) { + allowExplicitAbort = false + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'USAGE_LIMIT_EXCEEDED', + message: upstreamUsageLimit, + }, + }) + return + } + + if (!sessionSent) { + throw new Error('Mothership did not acknowledge the initial chat stream') + } + if ( + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted || + result.cancelled + ) { + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { code: 'CLIENT_CLOSED_REQUEST', message: 'Chat request cancelled' }, + }) + return + } + + if (!result.success) { + requestExplicitAbortOnce() + logger.error(`[${requestId}] V2 chat failed`, { + workspaceId, + error: result.error, + errors: result.errors, + }) + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: 'INTERNAL_ERROR', + message: 'Chat request failed', + }, + }) + return + } + + allowExplicitAbort = false + + sendActivities(activityProjector.finish('complete')) + send({ + type: 'complete', + data: toPublicChatResult(result, refreshedContinuationToken), + }) + if (!cancelled) controller.enqueue(encoder.encode('data: [DONE]\n\n')) + publicStreamOpen = false + } catch (error) { + const aborted = + lifecycleAbortController.signal.aborted || + userStopController.signal.aborted || + request.signal.aborted || + isAbortError(error) + const terminalResult: OrchestratorResult = { + success: false, + cancelled: aborted, + content: '', + contentBlocks: [], + toolCalls: [], + error: toError(error).message, + } + if (!replayFinalized) { + if (aborted) { + await onTurnComplete?.(terminalResult) + } else { + await onTurnError?.(toError(error), terminalResult) + } + if (replayPublisher && runId) { + try { + await runSegmentPromise + await finalizeStream( + terminalResult, + replayPublisher, + runId, + aborted ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error, + requestId + ) + replayFinalized = true + } catch (finalizeError) { + logger.warn(`[${requestId}] Failed to finalize v2 replay stream`, { + error: getErrorMessage(finalizeError), + }) + } + } + } + if (!aborted) { + logger.error(`[${requestId}] V2 chat error`, { + workspaceId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + requestExplicitAbortOnce() + sendActivities(activityProjector.finish('error')) + send({ + type: 'error', + error: { + code: aborted ? 'CLIENT_CLOSED_REQUEST' : 'INTERNAL_ERROR', + message: aborted ? 'Chat request cancelled' : 'Chat request failed', + }, + }) + } finally { + publicStreamOpen = false + allowExplicitAbort = false + if (heartbeatId) clearInterval(heartbeatId) + request.signal.removeEventListener('abort', onRequestAbort) + await explicitAbortRequest + clearInterval(abortPoller) + unregisterActiveStream(messageId) + await releasePendingChatStream(chatId, messageId) + await cleanupAbortMarker(messageId) + if (replayPublisher) { + try { + await replayPublisher.close() + } catch (error) { + logger.warn(`[${requestId}] Failed to flush v2 replay stream`, { + error: getErrorMessage(error), + }) + } + await scheduleBufferCleanup(messageId) + await scheduleFilePreviewSessionCleanup(messageId) + } + if (!cancelled) controller.close() + } + })() + }, + cancel(reason) { + cancelled = true + publicStreamOpen = false + if (heartbeatId) clearInterval(heartbeatId) + abortLifecycle() + }, + }) + streamOwnsLock = true + + return new Response(stream, { + headers: { + ...SSE_RESPONSE_HEADERS, + 'Cache-Control': 'private, no-store, no-transform', + ...rateLimitHeaders(rateLimit), + }, + }) + } catch (error) { + if (!streamOwnsLock && acquiredChatId && acquiredStreamId) { + await releasePendingChatStream(acquiredChatId, acquiredStreamId) + } + logger.error(`[${requestId}] Failed to start v2 chat`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.test.ts b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts new file mode 100644 index 00000000000..997739fa9a0 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.test.ts @@ -0,0 +1,372 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, flattenMockConditions, resetDbChainMock, schemaMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockGetAccessibleCopilotChatWithMessages, + mockIssueV2ChatContinuationToken, + mockPublishStatusChanged, + mockCaptureServerEvent, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockGetAccessibleCopilotChatWithMessages: vi.fn(), + mockIssueV2ChatContinuationToken: vi.fn(), + mockPublishStatusChanged: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChatWithMessages, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/copilot/headless/continuation-token', () => ({ + issueV2ChatContinuationToken: mockIssueV2ChatContinuationToken, +})) + +vi.mock('@/lib/copilot/chat-status', () => ({ + chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, +})) + +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mockCaptureServerEvent, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET, PATCH } from '@/app/api/v2/chats/[chatId]/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + userId: 'user-1', + workflowId: null, + workspaceId: 'workspace-1', + type: 'mothership', + title: 'Release plan', + conversationId: 'stream-stale', + resources: null, + createdAt: new Date('2026-08-07T11:00:00.000Z'), + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + messages: [], + ...overrides, + } +} + +function callDetail(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats/chat-1?${query}`), { + params: Promise.resolve({ chatId: 'chat-1' }), + }) +} + +function callRename(body: Record) { + return PATCH( + new NextRequest('http://localhost:3000/api/v2/chats/chat-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ chatId: 'chat-1' }) } + ) +} + +describe('GET /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(buildChat()) + mockReconcileChatStreamMarkers.mockResolvedValue( + new Map([['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }]]) + ) + mockIssueV2ChatContinuationToken.mockResolvedValue('continuation-token') + }) + + it('rejects workspace keys before loading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without loading the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callDetail() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(mockGetAccessibleCopilotChatWithMessages).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + + const response = await callDetail() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it.each([ + ['an inaccessible chat', null], + ['a workflow-scoped chat', buildChat({ type: 'copilot' })], + ['a chat from another workspace', buildChat({ workspaceId: 'workspace-2' })], + ])('masks %s as the same not-found response', async (_case, chat) => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue(chat) + + const response = await callDetail() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockIssueV2ChatContinuationToken).not.toHaveBeenCalled() + expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() + }) + + it('projects display-safe messages and reports the reconciled active marker', async () => { + mockGetAccessibleCopilotChatWithMessages.mockResolvedValue( + buildChat({ + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + contexts: [{ kind: 'workflow', label: 'Release', workflowId: 'workflow-1' }], + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + requestId: 'request-private', + contentBlocks: [{ type: 'text', content: 'Done' }], + }, + { + id: 'message-system', + role: 'system', + content: 'private instructions', + timestamp: '2026-08-07T11:29:00.000Z', + }, + null, + ], + }) + ) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([['chat-1', { chatId: 'chat-1', streamId: 'stream-live', status: 'active' }]]) + ) + + const response = await callDetail('workspaceId=workspace-1&readOnly=true') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ + id: 'chat-1', + title: 'Release plan', + active: true, + continuationToken: 'continuation-token', + messages: [ + { + id: 'message-user', + role: 'user', + content: 'Ship it', + timestamp: '2026-08-07T11:30:00.000Z', + }, + { + id: 'message-assistant', + role: 'assistant', + content: 'Done', + timestamp: '2026-08-07T11:31:00.000Z', + }, + ], + }) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [{ chatId: 'chat-1', streamId: 'stream-stale' }], + { repairVerifiedStaleMarkers: true } + ) + }) + + it.each([ + ['true', true], + ['false', false], + ])('binds readOnly=%s into the minted continuation token', async (raw, expected) => { + const response = await callDetail(`workspaceId=workspace-1&readOnly=${raw}`) + + expect(response.status).toBe(200) + expect(mockIssueV2ChatContinuationToken).toHaveBeenCalledWith({ + chatId: 'chat-1', + workspaceId: 'workspace-1', + authorizationUserId: 'user-1', + credentialType: 'personal', + readOnly: expected, + persistence: 'sim', + }) + }) +}) + +describe('PATCH /api/v2/chats/[chatId]', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + }) + + it('renames an owned chat and notifies the synchronized Home list', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1', workspaceId: 'workspace-1' }]) + + const response = await callRename({ + workspaceId: 'workspace-1', + title: 'Incident investigation', + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: 'chat-1', title: 'Incident investigation' }, + }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + title: 'Incident investigation', + updatedAt: expect.any(Date), + lastSeenAt: expect.any(Date), + }) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.id, + right: 'chat-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: 'user-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.workspaceId, + right: 'workspace-1', + }), + expect.objectContaining({ + type: 'eq', + left: schemaMock.copilotChats.type, + right: 'mothership', + }), + expect.objectContaining({ + type: 'isNull', + column: schemaMock.copilotChats.deletedAt, + }), + ]) + ) + expect(mockPublishStatusChanged).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + chatId: 'chat-1', + type: 'renamed', + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'task_renamed', + { workspace_id: 'workspace-1' }, + { groups: { workspace: 'workspace-1' } } + ) + }) + + it('rejects workspace keys before touching private chat data', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure before updating the chat', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(403) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('masks missing, deleted, foreign, and non-mothership chats as not found', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + const response = await callRename({ workspaceId: 'workspace-1', title: 'Private title' }) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Chat not found' }, + }) + expect(mockPublishStatusChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/[chatId]/route.ts b/apps/sim/app/api/v2/chats/[chatId]/route.ts new file mode 100644 index 00000000000..16b09ac2bf8 --- /dev/null +++ b/apps/sim/app/api/v2/chats/[chatId]/route.ts @@ -0,0 +1,158 @@ +import { db } from '@sim/db' +import { copilotChats } 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 } from 'next/server' +import { v2GetChatContract, v2RenameChatContract } from '@/lib/api/contracts/v2/chats' +import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatWithMessages } from '@/lib/copilot/chat/lifecycle' +import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { issueV2ChatContinuationToken } from '@/lib/copilot/headless/continuation-token' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { captureServerEvent } from '@/lib/posthog/server' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatDetailAPI') +type ChatRouteContext = { params: Promise<{ chatId: string }> } + +/** GET /api/v2/chats/[chatId] — open one owned chat and mint a fresh resume token. */ +export const GET = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest(v2GetChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, readOnly } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + if (!chat || chat.type !== 'mothership' || chat.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Chat not found') + } + + const streamMarkers = await reconcileChatStreamMarkers( + [{ chatId: chat.id, streamId: chat.conversationId }], + { repairVerifiedStaleMarkers: true } + ) + const active = Boolean(streamMarkers.get(chat.id)?.streamId) + const continuationToken = await issueV2ChatContinuationToken({ + chatId: chat.id, + workspaceId, + authorizationUserId: userId, + credentialType: 'personal', + readOnly, + persistence: 'sim', + }) + const messages = (Array.isArray(chat.messages) ? chat.messages : []) + .filter((message): message is Record => Boolean(message)) + .map(normalizeMessage) + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map(({ id, role, content, timestamp }) => ({ id, role, content, timestamp })) + + return v2Data( + { + id: chat.id, + title: chat.title, + messages, + continuationToken, + active, + }, + { rateLimit } + ) + } catch (error) { + logger.error('Failed to open v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** PATCH /api/v2/chats/[chatId] — rename one owned workspace chat. */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: ChatRouteContext) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Renaming chats requires a personal API key') + } + + const parsed = await parseRequest(v2RenameChatContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { workspaceId, title } = parsed.data.body + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const now = new Date() + const [updated] = await db + .update(copilotChats) + .set({ title, updatedAt: now, lastSeenAt: now }) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) + ) + ) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!updated) return v2Error('NOT_FOUND', 'Chat not found') + + if (updated.workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId: updated.workspaceId, + chatId: updated.id, + type: 'renamed', + }) + captureServerEvent( + userId, + 'task_renamed', + { workspace_id: updated.workspaceId }, + { groups: { workspace: updated.workspaceId } } + ) + } + + return v2Data({ id: updated.id, title }, { rateLimit }) + } catch (error) { + logger.error('Failed to rename v2 chat', { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/chats/route.test.ts b/apps/sim/app/api/v2/chats/route.test.ts new file mode 100644 index 00000000000..b59eed21878 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockEnvFlags, + mockReconcileChatStreamMarkers, + mockResolveWorkspaceAccess, + mockV2ApiGateError, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockEnvFlags: { isAuthDisabled: false }, + mockReconcileChatStreamMarkers: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockV2ApiGateError: vi.fn(), +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, + resolveWorkspaceAccess: mockResolveWorkspaceAccess, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockV2ApiGateError, +})) + +vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ + reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, +})) + +vi.mock('@/lib/core/config/env-flags', () => mockEnvFlags) + +import { GET } from '@/app/api/v2/chats/route' + +const RATE_LIMIT = { + allowed: true, + userId: 'user-1', + keyType: 'personal' as const, + limit: 100, + remaining: 99, + resetAt: new Date('2026-08-07T13:00:00.000Z'), +} + +function buildChat(overrides: Record = {}) { + return { + id: 'chat-1', + title: 'Release plan', + updatedAt: new Date('2026-08-07T12:00:00.000Z'), + pinned: true, + activeStreamId: 'stream-stale', + ...overrides, + } +} + +function callList(query = 'workspaceId=workspace-1') { + return GET(new NextRequest(`http://localhost:3000/api/v2/chats?${query}`)) +} + +describe('GET /api/v2/chats', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockEnvFlags.isAuthDisabled = false + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) + mockV2ApiGateError.mockResolvedValue(null) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReconcileChatStreamMarkers.mockImplementation( + async (candidates: Array<{ chatId: string; streamId: string | null }>) => + new Map( + candidates.map((candidate) => [ + candidate.chatId, + { + chatId: candidate.chatId, + streamId: candidate.streamId, + status: candidate.streamId ? 'active' : 'inactive', + }, + ]) + ) + ) + }) + + it('rejects workspace keys before reading private chat history', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, keyType: 'workspace' }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { + code: 'FORBIDDEN', + message: 'Chat history requires a personal API key', + }, + }) + expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('returns the workspace-access failure without querying chats', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + + const response = await callList() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: { code: 'FORBIDDEN', message: 'Access denied' }, + }) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + RATE_LIMIT, + 'user-1', + 'workspace-1', + 'read' + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('treats the auth-disabled principal like a session principal for workspace access', async () => { + mockEnvFlags.isAuthDisabled = true + queueTableRows(schemaMock.copilotChats, []) + + const response = await callList() + + expect(response.status).toBe(200) + expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: undefined }), + 'user-1', + 'workspace-1', + 'read' + ) + }) + + it('bounds the SQL page, maps summaries, and derives active state from the live marker', async () => { + queueTableRows(schemaMock.copilotChats, [ + buildChat(), + buildChat({ + id: 'chat-2', + title: null, + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: 'stream-live', + }), + buildChat({ id: 'chat-3' }), + ]) + mockReconcileChatStreamMarkers.mockResolvedValueOnce( + new Map([ + ['chat-1', { chatId: 'chat-1', streamId: null, status: 'inactive' }], + ['chat-2', { chatId: 'chat-2', streamId: 'stream-live', status: 'active' }], + ]) + ) + + const response = await callList('workspaceId=workspace-1&limit=2') + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual([ + { + id: 'chat-1', + title: 'Release plan', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: true, + active: false, + }, + { + id: 'chat-2', + title: null, + updatedAt: '2026-08-06T12:00:00.000Z', + pinned: false, + active: true, + }, + ]) + expect(body.nextCursor).toEqual(expect.any(String)) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(3) + expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith( + [ + { chatId: 'chat-1', streamId: 'stream-stale' }, + { chatId: 'chat-2', streamId: 'stream-live' }, + ], + { repairVerifiedStaleMarkers: true } + ) + }) + + it('replays its opaque cursor as a keyset bound', async () => { + queueTableRows(schemaMock.copilotChats, [buildChat(), buildChat({ id: 'chat-2' })]) + const first = await callList('workspaceId=workspace-1&limit=1') + const { nextCursor } = await first.json() + + queueTableRows(schemaMock.copilotChats, [ + buildChat({ + id: 'chat-2', + title: 'Older chat', + updatedAt: new Date('2026-08-06T12:00:00.000Z'), + pinned: false, + activeStreamId: null, + }), + ]) + const second = await callList( + `workspaceId=workspace-1&limit=1&cursor=${encodeURIComponent(nextCursor)}` + ) + + expect(second.status).toBe(200) + const conditions = flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(conditions.some((condition) => condition?.type === 'or')).toBe(true) + }) + + it('rejects a malformed cursor instead of restarting at the first page', async () => { + const response = await callList('workspaceId=workspace-1&cursor=not-a-cursor') + + expect(response.status).toBe(400) + expect((await response.json()).error.message).toMatch(/cursor does not match/i) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/chats/route.ts b/apps/sim/app/api/v2/chats/route.ts new file mode 100644 index 00000000000..667f5c788f4 --- /dev/null +++ b/apps/sim/app/api/v2/chats/route.ts @@ -0,0 +1,139 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { type V2ChatSummary, v2ListChatsContract } from '@/lib/api/contracts/v2/chats' +import { + encodeKeyset, + keysetAfter, + keysetColumns, + listOrderBy, + numberKey, + searchFilter, + textKey, + timestampKey, +} from '@/lib/api/list-query' +import { parseRequest } from '@/lib/api/server' +import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + decodeSortedCursor, + encodeSortedCursor, + v2CursorList, + v2CursorSortError, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2ChatsAPI') +const CHAT_SORT = 'pinned:desc,updatedAt:desc' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +type ChatRow = { + id: string + title: string | null + updatedAt: Date + pinned: boolean + activeStreamId: string | null +} + +const pinnedRank = sql`case when ${copilotChats.pinned} then 1 else 0 end` +const CHAT_KEYS = [ + numberKey(pinnedRank, (row) => (row.pinned ? 1 : 0)), + timestampKey(copilotChats.updatedAt, (row) => row.updatedAt), + textKey(copilotChats.id, (row) => row.id), +] + +/** GET /api/v2/chats — bounded personal chat history for the terminal picker. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'copilot-chat') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + const gate = await v2ApiGateError(userId) + if (gate) return gate + + // A workspace key can be held by people other than its creator. Its + // creator's UI chats are private and must never become shared-key data. + if (rateLimit.keyType === 'workspace') { + return v2Error('FORBIDDEN', 'Chat history requires a personal API key') + } + + const parsed = await parseRequest( + v2ListChatsContract, + request, + {}, + { + validationErrorResponse: v2ValidationError, + } + ) + if (!parsed.success) return parsed.response + const { workspaceId, search, limit, cursor } = parsed.data.query + + const accessPrincipal = isAuthDisabled ? { ...rateLimit, keyType: undefined } : rateLimit + const access = await resolveWorkspaceAccess(accessPrincipal, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = decodeSortedCursor(cursor, CHAT_SORT) + if (decoded.status === 'invalid') return v2CursorSortError() + const resumeAfter = + decoded.status === 'ok' ? keysetAfter(CHAT_KEYS, decoded.keys, 'desc') : undefined + if (resumeAfter === null) return v2CursorSortError() + + const rows = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + updatedAt: copilotChats.updatedAt, + pinned: copilotChats.pinned, + activeStreamId: copilotChats.conversationId, + }) + .from(copilotChats) + .where( + and( + eq(copilotChats.userId, userId), + eq(copilotChats.workspaceId, workspaceId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt), + searchFilter(copilotChats.title, search), + resumeAfter + ) + ) + .orderBy(...listOrderBy(keysetColumns(CHAT_KEYS), 'desc')) + .limit(limit + 1) + + const page = rows.slice(0, limit) + const streamMarkers = await reconcileChatStreamMarkers( + page.map((chat) => ({ chatId: chat.id, streamId: chat.activeStreamId })), + { repairVerifiedStaleMarkers: true } + ) + const data: V2ChatSummary[] = page.map((chat) => ({ + id: chat.id, + title: chat.title, + updatedAt: chat.updatedAt.toISOString(), + pinned: chat.pinned, + active: Boolean(streamMarkers.get(chat.id)?.streamId), + })) + + const last = page.at(-1) + const nextCursor = + rows.length > limit && last + ? encodeSortedCursor(CHAT_SORT, encodeKeyset(CHAT_KEYS, last)) + : null + + return v2CursorList(data, nextCursor, { rateLimit }) + } catch (error) { + logger.error('Failed to list v2 chats', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts new file mode 100644 index 00000000000..27cdb0a18a9 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -0,0 +1,87 @@ +import { db } from '@sim/db' +import { workspace } 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 { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' +import { parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2WorkspacesAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface RouteContext { + params: Promise<{ workspaceId: string }> +} + +/** GET /api/v2/workspaces/[workspaceId] — Resolve a workspace id to its name. */ +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'workspace') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetWorkspaceContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.params + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const [row] = await db + .select({ + id: workspace.id, + name: workspace.name, + color: workspace.color, + logoUrl: workspace.logoUrl, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!row) return v2Error('NOT_FOUND', 'Workspace not found') + + return v2Data( + { + workspace: { + id: row.id, + name: row.name, + color: row.color, + logoUrl: row.logoUrl, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }, + }, + { rateLimit } + ) + } catch (error) { + logger.error(`[${requestId}] Error fetching workspace`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx index 01d908daf05..c4cd52dc5a3 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.test.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -81,22 +81,22 @@ describe('CliAuthView workspace loading', () => { }) 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. + // The regression: while pending, the picker falls back to no default, so an + // early click approved a profile without the workspace that would appear a + // moment later. 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)') + expect(container.textContent).not.toContain('No default workspace') }) 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).toContain('Loading your workspaces') expect(container.textContent).not.toContain('Issues a personal key') }) @@ -106,10 +106,10 @@ describe('CliAuthView workspace loading', () => { expect(connectButton().disabled).toBe(false) expect(container.textContent).toContain('Acme') - expect(container.textContent).toContain('only reach Acme') + expect(container.textContent).toContain('personal key tied to your account') }) - it('binds the key to the workspace when the approver is an admin', () => { + it("keeps an admin's key personal while saving the selected workspace as its default", () => { mockUseWorkspaces.mockReturnValue(LOADED) render() act(() => { @@ -120,7 +120,7 @@ describe('CliAuthView workspace loading', () => { expect.objectContaining({ scope: 'platform', workspaceId: 'ws_admin', - bindKeyToWorkspace: true, + bindKeyToWorkspace: false, }), expect.anything() ) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index 080b872f297..004057bd47d 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -11,8 +11,8 @@ 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__' +/** Sentinel for a profile without a default workspace; an empty string reads as unselected. */ +const NO_WORKSPACE_VALUE = '__no_workspace__' /** * The signed-in half of the CLI key handoff: a consent card that records the @@ -40,7 +40,7 @@ export function CliAuthView() { label: workspace.name, value: workspace.id, })) - return [...rows, { label: 'No workspace (personal key)', value: PERSONAL_VALUE }] + return [...rows, { label: 'No default workspace', value: NO_WORKSPACE_VALUE }] }, [workspaces.data]) if (!resolution.valid) { @@ -63,11 +63,10 @@ export function CliAuthView() { * 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. + * as "No default workspace" — a real answer, not a pending one. Leaving + * Connect live through that window let a fast click approve a key with no + * default workspace when the picker was about to select the user's workspace. + * Blocking is the only way the card can promise what it is about to do. */ const loadingWorkspaces = isPlatform && workspaces.isPending @@ -88,11 +87,6 @@ export function CliAuthView() { 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 - // 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 (
Default workspace

{loadingWorkspaces - ? 'Checking which workspaces you can issue a key for…' + ? 'Loading your workspaces…' : 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}.` - : 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.'} + : chosen + ? 'Issues a personal key tied to your account, defaulting to this workspace.' + : // 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.'}

)} @@ -151,10 +143,11 @@ export function CliAuthView() { 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. + // The picked workspace is the terminal profile's default. CLI + // login represents the signed-in person, so its key remains + // personal even when that person administers the workspace. ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), - bindKeyToWorkspace: isPlatform && bindsToWorkspace, + bindKeyToWorkspace: false, }, { onSuccess: () => router.push('/cli/auth/done') } ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 96e131e3ea2..7061cf5a76b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -8,6 +8,7 @@ export type { MothershipResource, MothershipResourceType, } from '@/lib/copilot/resources/types' +export { SUBAGENT_LABELS } from '@/lib/copilot/tools/subagent-display' /** Union of all valid context kind strings, derived from {@link ChatContext}. */ export type ChatContextKind = ChatContext['kind'] @@ -176,24 +177,3 @@ export interface ChatMessage { contexts?: ChatMessageContext[] requestId?: string } - -export const SUBAGENT_LABELS: Record = { - workflow: 'Workflow Agent', - debug: 'Debug Agent', - deploy: 'Deploy Agent', - auth: 'Auth Agent', - research: 'Research Agent', - knowledge: 'Knowledge Agent', - table: 'Table Agent', - custom_tool: 'Custom Tool Agent', - scout: 'Scout Agent', - search: 'Search Agent', - superagent: 'Superagent', - run: 'Run Agent', - agent: 'Tools Agent', - // `job` retained as a backward-compat alias so historical transcripts still render a label. - job: 'Job Agent', - file: 'File Agent', - media: 'Media Agent', - browser: 'Browser Agent', -} as const diff --git a/apps/sim/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index afec00da53f..602829f7202 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -32,6 +32,8 @@ export const BrowserUseBlock: BlockConfig = { id: 'variables', title: 'Variables (Secrets)', type: 'table', + password: true, + required: false, columns: ['Key', 'Value'], }, { diff --git a/apps/sim/blocks/blocks/codepipeline.ts b/apps/sim/blocks/blocks/codepipeline.ts index 04f54231134..7b7d3f5603e 100644 --- a/apps/sim/blocks/blocks/codepipeline.ts +++ b/apps/sim/blocks/blocks/codepipeline.ts @@ -230,6 +230,7 @@ export const CodePipelineBlock: BlockConfig< id: 'approvalToken', title: 'Approval Token', type: 'short-input', + password: true, placeholder: 'Token from Get Pipeline State', condition: { field: 'operation', value: 'put_approval_result' }, required: { field: 'operation', value: 'put_approval_result' }, diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 82c90160505..8f54fd5e6bb 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -297,6 +297,7 @@ export const DiscordBlock: BlockConfig = { id: 'webhookToken', title: 'Webhook Token', type: 'short-input', + password: true, placeholder: 'Enter webhook token', required: true, condition: { diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index a7eb65d60ae..bd9ad22b945 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -461,6 +461,7 @@ export const PiBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, paramVisibility: 'user-only', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', required: { diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index 19fda449390..c3c00de9259 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -100,6 +100,7 @@ export const SecretsManagerBlock: BlockConfig = { id: 'secretValue', title: 'Secret Value', type: 'code', + password: true, placeholder: '{"username":"admin","password":"secret123"}', condition: { field: 'operation', value: ['create_secret', 'update_secret'] }, required: { field: 'operation', value: ['create_secret', 'update_secret'] }, diff --git a/apps/sim/blocks/blocks/sftp.ts b/apps/sim/blocks/blocks/sftp.ts index dc2fc15e224..453bf11c775 100644 --- a/apps/sim/blocks/blocks/sftp.ts +++ b/apps/sim/blocks/blocks/sftp.ts @@ -81,6 +81,7 @@ export const SftpBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/ssh.ts b/apps/sim/blocks/blocks/ssh.ts index 11dfdbfdcf1..501d0c46288 100644 --- a/apps/sim/blocks/blocks/ssh.ts +++ b/apps/sim/blocks/blocks/ssh.ts @@ -91,6 +91,7 @@ export const SSHBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + password: true, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', condition: { field: 'authMethod', value: 'privateKey' }, dependsOn: ['authMethod'], diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 38b711b0c95..1abab52f5f9 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -103,6 +103,7 @@ export const STSBlock: BlockConfig = { id: 'webIdentityToken', title: 'Web Identity Token', type: 'long-input', + password: true, placeholder: 'OIDC/OAuth 2.0 token from the identity provider', condition: { field: 'operation', value: 'assume_role_with_web_identity' }, required: { field: 'operation', value: 'assume_role_with_web_identity' }, @@ -128,6 +129,7 @@ export const STSBlock: BlockConfig = { id: 'samlAssertion', title: 'SAML Assertion', type: 'long-input', + password: true, placeholder: 'Base64-encoded SAML authentication response', condition: { field: 'operation', value: 'assume_role_with_saml' }, required: { field: 'operation', value: 'assume_role_with_saml' }, @@ -213,6 +215,7 @@ export const STSBlock: BlockConfig = { id: 'tokenCode', title: 'MFA Token Code', type: 'short-input', + password: true, placeholder: '123456', condition: { field: 'operation', value: ['assume_role', 'get_session_token'] }, required: false, diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts index 42df3956fb2..3c1ff2564b2 100644 --- a/apps/sim/blocks/blocks/zoom.ts +++ b/apps/sim/blocks/blocks/zoom.ts @@ -222,6 +222,8 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, id: 'password', title: 'Password', type: 'short-input', + password: true, + required: false, placeholder: 'Meeting password', mode: 'advanced', condition: { diff --git a/apps/sim/lib/api/contracts/v1/tables/index.test.ts b/apps/sim/lib/api/contracts/v1/tables/index.test.ts new file mode 100644 index 00000000000..78eaca5f422 --- /dev/null +++ b/apps/sim/lib/api/contracts/v1/tables/index.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, +} from '@/lib/api/contracts/v1/tables' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' + +describe('v1 public table row contracts', () => { + it('never expose private secret provenance', () => { + for (const contract of [ + v1CreateTableRowContract, + v1UpdateRowsByFilterContract, + v1UpdateTableRowContract, + v1UpsertTableRowContract, + ]) { + expect( + JSON.stringify(z.toJSONSchema(contract.body, { unrepresentable: 'any' })) + ).not.toContain(PRIVATE_SECRET_PROVENANCE_FIELD) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v1/tables/index.ts b/apps/sim/lib/api/contracts/v1/tables/index.ts index 4491b8840be..aa4490889f7 100644 --- a/apps/sim/lib/api/contracts/v1/tables/index.ts +++ b/apps/sim/lib/api/contracts/v1/tables/index.ts @@ -18,6 +18,7 @@ import { upsertTableRowBodySchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import type { Filter, Sort } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' @@ -61,7 +62,7 @@ export const v1CreateTableBodySchema = createTableBodySchema.omit({ * new rows at the tail; ordering by index is an in-app affordance only. */ export const v1InsertTableRowBodySchema = insertTableRowBodyBaseSchema - .omit({ position: true }) + .omit({ position: true, [PRIVATE_SECRET_PROVENANCE_FIELD]: true }) .refine(...rowAnchorMutexRefine) /** @@ -83,6 +84,18 @@ export const v1CreateTableRowsBodySchema = z.union([ v1InsertTableRowBodySchema, ]) +export const v1UpdateRowsByFilterBodySchema = updateRowsByFilterBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpdateTableRowBodySchema = updateTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + +export const v1UpsertTableRowBodySchema = upsertTableRowBodySchema.omit({ + [PRIVATE_SECRET_PROVENANCE_FIELD]: true, +}) + export type V1ListTablesQuery = z.output export type V1TableRowsQuery = z.output export type V1InsertTableRowBody = z.output @@ -209,7 +222,7 @@ export const v1UpdateRowsByFilterContract = defineRouteContract({ method: 'PUT', path: '/api/v1/tables/[tableId]/rows', params: tableIdParamsSchema, - body: updateRowsByFilterBodySchema, + body: v1UpdateRowsByFilterBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -242,7 +255,7 @@ export const v1UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v1/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - body: updateTableRowBodySchema, + body: v1UpdateTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, @@ -264,7 +277,7 @@ export const v1UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v1/tables/[tableId]/rows/upsert', params: tableIdParamsSchema, - body: upsertTableRowBodySchema, + body: v1UpsertTableRowBodySchema, response: { mode: 'json', schema: v1TableApiResponseSchema, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index 9e9dca55d01..fd126450553 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -1,14 +1,35 @@ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { V2_TABLE_IMPORT_OPTIONS_MAX_BYTES, v2CreateTableImportBodySchema, + v2CreateTableRowsContract, v2TableUploadImportSourceSchema, + v2UpdateRowsByFilterContract, + v2UpdateTableRowContract, + v2UpsertTableRowContract, } from '@/lib/api/contracts/v2/tables' +import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import { TABLE_LIMITS } from '@/lib/table/constants' import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +describe('v2 table row contracts', () => { + it('never expose private secret provenance on the public API', () => { + for (const contract of [ + v2CreateTableRowsContract, + v2UpdateRowsByFilterContract, + v2UpdateTableRowContract, + v2UpsertTableRowContract, + ]) { + expect( + JSON.stringify(z.toJSONSchema(contract.body, { unrepresentable: 'any' })) + ).not.toContain(PRIVATE_SECRET_PROVENANCE_FIELD) + } + }) +}) + function uploadSource(size: number) { return { type: 'upload' as const, @@ -59,17 +80,11 @@ describe('v2 table import contracts', () => { ).toBe(false) }) - it('caps mapping entries and createColumns items at the table column limit', () => { + it('accepts bounded metadata and rejects collections over the table column limit', () => { const mapping = Object.fromEntries( - Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, (_, index) => [ - `header_${index}`, - `column_${index}`, - ]) - ) - const createColumns = Array.from( - { length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE }, - (_, index) => `header_${index}` + Array.from({ length: 10 }, (_, index) => [`h${index}`, `c${index}`]) ) + const createColumns = Array.from({ length: 10 }, (_, index) => `c${index}`) expect(v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping })).success).toBe( true @@ -77,14 +92,24 @@ describe('v2 table import contracts', () => { expect( v2CreateTableImportBodySchema.safeParse(existingTableImport({ createColumns })).success ).toBe(true) + + const mappingOverLimit = Object.fromEntries( + Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, index) => [ + String(index), + 'c', + ]) + ) + const columnsOverLimit = Array.from( + { length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, + (_, index) => String(index) + ) expect( - v2CreateTableImportBodySchema.safeParse( - existingTableImport({ mapping: { ...mapping, overflow: 'overflow' } }) - ).success + v2CreateTableImportBodySchema.safeParse(existingTableImport({ mapping: mappingOverLimit })) + .success ).toBe(false) expect( v2CreateTableImportBodySchema.safeParse( - existingTableImport({ createColumns: [...createColumns, 'overflow'] }) + existingTableImport({ createColumns: columnsOverLimit }) ).success ).toBe(false) }) diff --git a/apps/sim/lib/api/contracts/v2/chat.test.ts b/apps/sim/lib/api/contracts/v2/chat.test.ts new file mode 100644 index 00000000000..c72f89de194 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_V2_CHAT_ATTACHMENTS, + MAX_V2_CHAT_CONTEXTS, + MAX_V2_CHAT_PROMPT_LENGTH, + v2ChatBodySchema, +} from '@/lib/api/contracts/v2/chat' + +describe('v2ChatBodySchema', () => { + it('enforces the prompt limit in UTF-8 bytes', () => { + const overLimit = 'é'.repeat(MAX_V2_CHAT_PROMPT_LENGTH / 2 + 1) + + const result = v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: overLimit, + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Prompt cannot exceed 10 MiB') + } + }) + + it('accepts an opaque continuation token and inline base64 attachment', () => { + expect( + v2ChatBodySchema.parse({ + workspaceId: 'workspace-1', + prompt: 'Read this', + continuationToken: 'opaque-token', + attachments: [{ name: 'Notes.MD', mediaType: 'TEXT/MARKDOWN', data: 'aGk=' }], + }) + ).toEqual({ + workspaceId: 'workspace-1', + prompt: 'Read this', + continuationToken: 'opaque-token', + readOnly: false, + attachments: [{ name: 'Notes.MD', mediaType: 'text/markdown', data: 'aGk=' }], + }) + }) + + it('accepts only the identity-bearing contexts supported by public resource lists', () => { + const contexts = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'table', tableId: 'table-1', label: 'Leads' }, + { kind: 'file', fileId: 'file-1', label: 'Brief.md' }, + { kind: 'knowledge', knowledgeId: 'kb-1', label: 'Handbook' }, + { kind: 'logs', executionId: 'execution-1', label: 'Release log' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + + expect( + v2ChatBodySchema.parse({ workspaceId: 'workspace-1', prompt: 'Use these', contexts }).contexts + ).toEqual(contexts) + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Use this', + contexts: [{ kind: 'folder', folderId: 'folder-1', label: 'Folder' }], + }).success + ).toBe(false) + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Use these', + contexts: Array.from({ length: MAX_V2_CHAT_CONTEXTS + 1 }, (_, index) => ({ + kind: 'skill', + skillId: `skill-${index}`, + label: `skill-${index}`, + })), + }).success + ).toBe(false) + }) + + it('allows an attachment-only turn but still rejects an entirely empty turn', () => { + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: ' ', + attachments: [{ name: 'image.png', mediaType: 'image/png', data: 'AAAA' }], + }).success + ).toBe(true) + expect(v2ChatBodySchema.safeParse({ workspaceId: 'workspace-1', prompt: ' ' }).success).toBe( + false + ) + }) + + it('accepts only file basenames and a bounded attachment count', () => { + for (const name of ['/tmp/secret.txt', '../secret.txt', 'folder\\secret.txt', 'bad\0.txt']) { + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Read this', + attachments: [{ name, mediaType: 'text/plain', data: 'aGk=' }], + }).success + ).toBe(false) + } + + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'Read these', + attachments: Array.from({ length: MAX_V2_CHAT_ATTACHMENTS + 1 }, (_, index) => ({ + name: `${index}.txt`, + mediaType: 'text/plain', + data: 'aGk=', + })), + }).success + ).toBe(false) + }) + + it('continues to reject raw caller-controlled chat ids and attachment URLs or paths', () => { + for (const extra of [ + { chatId: 'raw-chat-id' }, + { conversationId: 'raw-chat-id' }, + { + attachments: [ + { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + path: '/tmp/notes.txt', + }, + ], + }, + { + attachments: [ + { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'aGk=', + url: 'https://example.com/notes.txt', + }, + ], + }, + ]) { + expect( + v2ChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + prompt: 'hello', + ...extra, + }).success + ).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts new file mode 100644 index 00000000000..65b9238cca8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chat.ts @@ -0,0 +1,200 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** Bounds both non-interactive output and persistent interactive CLI chat. */ +export const MAX_V2_CHAT_PROMPT_LENGTH = 10 * 1024 * 1024 +export const MAX_V2_CHAT_ATTACHMENTS = 5 +export const MAX_V2_CHAT_ATTACHMENT_BYTES = 5 * 1024 * 1024 +export const MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES = 200 * 1024 +export const MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES = 10 * 1024 * 1024 +export const MAX_V2_CHAT_ATTACHMENT_NAME_LENGTH = 255 +export const MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH = 4096 +export const MAX_V2_CHAT_CONTEXTS = 50 +export const MAX_V2_CHAT_CONTEXT_LABEL_LENGTH = 255 +export const MAX_V2_CHAT_CONTEXT_ID_LENGTH = 255 +/** Prevent small compressed inputs from expanding into unbounded image allocations. */ +export const MAX_V2_CHAT_IMAGE_DIMENSION = 8192 +/** Caps one 4-byte decoded image surface at roughly 64 MiB before resize overhead. */ +export const MAX_V2_CHAT_IMAGE_PIXELS = 16_000_000 +/** Caps all decoded image surfaces in one request at roughly 128 MiB. */ +export const MAX_V2_CHAT_IMAGES_TOTAL_PIXELS = 32_000_000 + +const MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH = Math.ceil(MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES / 3) * 4 +const MAX_V2_CHAT_JSON_OVERHEAD_BYTES = 64 * 1024 + +/** + * A prompt byte may occupy six transport bytes as a JSON `\u00XX` escape; + * attachment base64 is already ASCII. This cap is deliberately a transport + * bound, while the decoded prompt/file limits are enforced below and at the + * route's attachment-validation boundary. + */ +export const MAX_V2_CHAT_BODY_BYTES = + MAX_V2_CHAT_PROMPT_LENGTH * 6 + + MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH + + MAX_V2_CHAT_JSON_OVERHEAD_BYTES + +export const V2_CHAT_IMAGE_MEDIA_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +] as const + +export const V2_CHAT_TEXT_MEDIA_TYPES = [ + 'text/plain', + 'text/markdown', + 'text/csv', + 'text/tab-separated-values', + 'text/html', + 'text/css', + 'text/javascript', + 'text/typescript', + 'text/xml', + 'text/yaml', + 'application/json', + 'application/jsonl', + 'application/x-ndjson', + 'application/xml', + 'application/yaml', + 'application/x-yaml', + 'application/toml', +] as const + +export const V2_CHAT_DOCUMENT_MEDIA_TYPES = ['application/pdf'] as const + +const v2ChatContextIdSchema = z.string().trim().min(1).max(MAX_V2_CHAT_CONTEXT_ID_LENGTH) +const v2ChatContextLabelSchema = z.string().trim().min(1).max(MAX_V2_CHAT_CONTEXT_LABEL_LENGTH) + +/** + * Identity-bearing tags supported by the public CLI surface. The home client + * uses the same context kinds; this deliberately exposes only resources whose + * stable ids are already available from public v2 list endpoints. + */ +export const v2ChatContextSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('workflow'), + workflowId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('table'), + tableId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('file'), + fileId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('knowledge'), + knowledgeId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('logs'), + executionId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('skill'), + skillId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), + z + .object({ + kind: z.literal('mcp'), + serverId: v2ChatContextIdSchema, + label: v2ChatContextLabelSchema, + }) + .strict(), +]) + +export type V2ChatContext = z.output + +const textEncoder = new TextEncoder() + +const v2ChatAttachmentSchema = z + .object({ + // Basenames only: local paths belong to the CLI process and must never + // cross the API boundary. + name: z + .string() + .trim() + .min(1, 'Attachment name is required') + .max(MAX_V2_CHAT_ATTACHMENT_NAME_LENGTH) + .refine( + (value) => value !== '.' && value !== '..' && !/[\\/\u0000-\u001f\u007f]/.test(value), + 'Attachment name must be a file basename' + ), + mediaType: z + .string() + .trim() + .toLowerCase() + .max(127) + .regex( + /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/, + 'Attachment mediaType must be a MIME type without parameters' + ), + // Semantic validation performs strict canonical-base64 decoding, byte + // sniffing, and the type-specific decoded limits after workspace auth. + data: z.string().min(4).max(MAX_V2_CHAT_ATTACHMENT_BASE64_LENGTH), + }) + .strict() + +export type V2ChatAttachment = z.output + +export const v2ChatBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + prompt: z + .string() + .max(MAX_V2_CHAT_PROMPT_LENGTH, 'Prompt cannot exceed 10 MiB') + .refine( + (value) => textEncoder.encode(value).byteLength <= MAX_V2_CHAT_PROMPT_LENGTH, + 'Prompt cannot exceed 10 MiB' + ), + continuationToken: z.string().min(1).max(MAX_V2_CHAT_CONTINUATION_TOKEN_LENGTH).optional(), + /** Normal Mothership is the default; this explicitly selects its read-only projection. */ + readOnly: z.boolean().optional().default(false), + attachments: z.array(v2ChatAttachmentSchema).max(MAX_V2_CHAT_ATTACHMENTS).optional(), + contexts: z.array(v2ChatContextSchema).max(MAX_V2_CHAT_CONTEXTS).optional(), + }) + .strict() + .superRefine((value, context) => { + if (!value.prompt.trim() && !value.attachments?.length) { + context.addIssue({ + code: 'custom', + path: ['prompt'], + message: 'Prompt or at least one attachment is required', + }) + } + }) +export type V2ChatBody = z.input + +/** + * A normal workspace Mothership turn. Omit `continuationToken` for a one-shot + * or the first interactive turn; pass the latest server-issued token to + * continue the same private conversation. `readOnly` explicitly selects the + * secretless query projection. Successful responses are SSE so proxies stay + * alive during long agent turns and callers can cancel the run. + */ +export const v2ChatContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/chat', + body: v2ChatBodySchema, + response: { mode: 'stream' }, +}) diff --git a/apps/sim/lib/api/contracts/v2/chats.test.ts b/apps/sim/lib/api/contracts/v2/chats.test.ts new file mode 100644 index 00000000000..e648db545a1 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chats.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { + v2ChatDetailSchema, + v2GetChatQuerySchema, + v2ListChatsQuerySchema, + v2RenameChatBodySchema, +} from '@/lib/api/contracts/v2/chats' + +describe('v2ListChatsQuerySchema', () => { + it('defaults to a bounded page and clamps caller-provided limits', () => { + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1' }).limit).toBe(30) + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '0' }).limit).toBe(1) + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '500' }).limit).toBe( + 100 + ) + expect(v2ListChatsQuerySchema.parse({ workspaceId: 'workspace-1', limit: '2.9' }).limit).toBe(2) + }) + + it('rejects empty search and cursor values', () => { + expect( + v2ListChatsQuerySchema.safeParse({ workspaceId: 'workspace-1', search: '' }).success + ).toBe(false) + expect( + v2ListChatsQuerySchema.safeParse({ workspaceId: 'workspace-1', cursor: '' }).success + ).toBe(false) + }) +}) + +describe('v2GetChatQuerySchema', () => { + it('parses text booleans without treating "false" as truthy', () => { + expect(v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1' }).readOnly).toBe(false) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: false }).readOnly + ).toBe(false) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: true }).readOnly + ).toBe(true) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: 'false' }).readOnly + ).toBe(false) + expect( + v2GetChatQuerySchema.parse({ workspaceId: 'workspace-1', readOnly: 'true' }).readOnly + ).toBe(true) + }) +}) + +describe('v2ChatDetailSchema', () => { + it('accepts only the display-safe transcript projection', () => { + const detail = { + id: 'chat-1', + title: 'Release plan', + active: false, + continuationToken: 'opaque-token', + messages: [ + { + id: 'message-1', + role: 'assistant', + content: 'Ready', + timestamp: '2026-08-07T12:00:00.000Z', + contentBlocks: [{ type: 'tool', result: 'private' }], + }, + ], + } + + expect(v2ChatDetailSchema.parse(detail)).toEqual({ + ...detail, + messages: [ + { + id: 'message-1', + role: 'assistant', + content: 'Ready', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + }) + }) +}) + +describe('v2RenameChatBodySchema', () => { + it('trims a bounded title and rejects empty or unknown input', () => { + expect( + v2RenameChatBodySchema.parse({ workspaceId: 'workspace-1', title: ' Release plan ' }) + ).toEqual({ workspaceId: 'workspace-1', title: 'Release plan' }) + expect( + v2RenameChatBodySchema.safeParse({ workspaceId: 'workspace-1', title: ' ' }).success + ).toBe(false) + expect( + v2RenameChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + title: 'Release plan', + extra: true, + }).success + ).toBe(false) + expect( + v2RenameChatBodySchema.safeParse({ + workspaceId: 'workspace-1', + title: 'x'.repeat(201), + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/chats.ts b/apps/sim/lib/api/contracts/v2/chats.ts new file mode 100644 index 00000000000..4a27771a919 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/chats.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CursorListResponse, v2DataResponse, v2SearchSchema } from '@/lib/api/contracts/v2/shared' + +/** A bounded, display-safe chat summary for the public CLI history picker. */ +export const v2ChatSummarySchema = z.object({ + id: z.string().min(1), + title: z.string().nullable(), + updatedAt: z.string().datetime(), + pinned: z.boolean(), + /** True while another client owns the chat's single active response stream. */ + active: z.boolean(), +}) + +export type V2ChatSummary = z.output + +/** The intentionally small transcript shape needed to repaint a terminal chat. */ +export const v2ChatMessageSchema = z.object({ + id: z.string().min(1), + role: z.enum(['user', 'assistant']), + content: z.string(), + timestamp: z.string().datetime(), +}) + +export type V2ChatMessage = z.output + +export const v2ChatDetailSchema = z.object({ + id: z.string().min(1), + title: z.string().nullable(), + messages: z.array(v2ChatMessageSchema), + continuationToken: z.string().min(1), + active: z.boolean(), +}) + +export type V2ChatDetail = z.output + +export const v2RenameChatBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + title: z + .string() + .trim() + .min(1, 'Chat title is required') + .max(200, 'Chat title must be at most 200 characters'), + }) + .strict() + +export type V2RenameChatBody = z.input + +export const v2RenamedChatSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1).max(200), +}) + +export type V2RenamedChat = z.output + +/** + * Recent chats use their Home ordering (pinned first, then most recently + * updated) with a fixed keyset cursor. The modest default keeps `/chats` + * cheap even for workspaces with years of chat history. + */ +export const v2ListChatsQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + search: v2SearchSchema, + limit: z.coerce + .number() + .optional() + .default(30) + .transform((value) => Math.min(Math.max(1, Math.trunc(value)), 100)), + cursor: z.string().min(1).optional(), + }) + .strict() + +export type V2ListChatsQuery = z.output + +export const v2ChatParamsSchema = z.object({ chatId: z.string().min(1) }).strict() + +export const v2GetChatQuerySchema = z + .object({ + workspaceId: workspaceIdSchema, + readOnly: booleanQueryFlagSchema.optional().default(false), + }) + .strict() + +export const v2ListChatsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chats', + query: v2ListChatsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ChatSummarySchema) }, +}) + +export const v2GetChatContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/chats/[chatId]', + params: v2ChatParamsSchema, + query: v2GetChatQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2ChatDetailSchema) }, +}) + +export const v2RenameChatContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/chats/[chatId]', + params: v2ChatParamsSchema, + body: v2RenameChatBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2RenamedChatSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 84e35ef40ef..ec0f0fbbb86 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -22,12 +22,9 @@ import { tableRowsQueryBaseSchema, tableViewConfigSchema, tableViewParamsSchema, - updateRowsByFilterBodySchema, updateTableColumnBodySchema, - updateTableRowBodySchema, updateTableViewBodySchema, updateWorkflowGroupBodySchema, - upsertTableRowBodySchema, workflowGroupOutputColumnSchema, } from '@/lib/api/contracts/tables' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -36,6 +33,9 @@ import { v1CreateTableBodySchema, v1CreateTableRowsBodySchema, v1ListTablesQuerySchema, + v1UpdateRowsByFilterBodySchema, + v1UpdateTableRowBodySchema, + v1UpsertTableRowBodySchema, } from '@/lib/api/contracts/v1/tables' import { v2CreateFolderBodySchema, @@ -490,7 +490,7 @@ export const v2CreateTableRowsContract = defineRouteContract({ }) /** Bulk update body — v2 accepts ONLY the predicate tree as the filter. */ -export const v2UpdateRowsByPredicateBodySchema = updateRowsByFilterBodySchema.extend({ +export const v2UpdateRowsByPredicateBodySchema = v1UpdateRowsByFilterBodySchema.extend({ filter: predicateSchema, }) export type V2UpdateRowsByPredicateBody = z.input @@ -560,7 +560,7 @@ export const v2UpdateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/rows/[rowId]', params: tableRowParamsSchema, - body: updateTableRowBodySchema, + body: v1UpdateTableRowBodySchema, response: { mode: 'json', schema: v2DataResponse(v2TableRowDataSchema), @@ -582,7 +582,7 @@ export const v2UpsertTableRowContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/[tableId]/rows/upsert', params: tableIdParamsSchema, - body: upsertTableRowBodySchema, + body: v1UpsertTableRowBodySchema, response: { mode: 'json', schema: v2DataResponse(v2UpsertRowDataSchema), diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts new file mode 100644 index 00000000000..6297755cdf8 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +/** + * v2 workspace contracts. + * + * Read-only. Clients hold a workspace id (from a profile, a flag, or an env + * var) and need a human-readable name to show beside it; without this they can + * only ever display the raw uuid. + */ + +const v2WorkspaceParamsSchema = z.object({ + workspaceId: workspaceIdSchema, +}) + +const v2WorkspaceSchema = z.object({ + id: z.string(), + name: z.string(), + color: z.string(), + logoUrl: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +const v2WorkspaceDataSchema = z.object({ + workspace: v2WorkspaceSchema, +}) + +export type V2Workspace = z.output + +export const v2GetWorkspaceContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + params: v2WorkspaceParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkspaceDataSchema), + }, +}) diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index fcd9c01a4e7..1d5563b03a3 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -11,6 +11,7 @@ import { completeAsyncToolCall, detachAsyncToolCall, getClaimedWorkflowExecutionId, + markAsyncToolRunning, recordToolPermissionDecision, releaseWorkflowToolExecutionClaim, replaceTerminalAsyncToolCallResult, @@ -132,6 +133,27 @@ describe('async tool repository single-row semantics', () => { ) }) + it('marks a Sim tool running only while its durable row is still live', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'sim-tool', + status: 'running', + claimedBy: 'sim-stream', + }, + ]) + + await markAsyncToolRunning('sim-tool', 'sim-stream') + + const predicate = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect(predicate).toEqual({ + type: 'and', + conditions: [ + expect.objectContaining({ type: 'eq', right: 'sim-tool' }), + expect.objectContaining({ type: 'inArray', values: ['pending', 'running'] }), + ], + }) + }) + it('atomically binds an eligible workflow tool to one execution', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/copilot/async-runs/repository.ts index 1cff09c4a63..465c3da1afa 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/copilot/async-runs/repository.ts @@ -380,7 +380,10 @@ async function markAsyncToolStatus( } export async function markAsyncToolRunning(toolCallId: string, claimedBy: string) { - return markAsyncToolStatus(toolCallId, 'running', { claimedBy }) + return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.running, { claimedBy }, [ + ASYNC_TOOL_STATUS.pending, + ASYNC_TOOL_STATUS.running, + ]) } export function getClaimedWorkflowExecutionId(claimedBy: string | null | undefined) { diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 46e5c63dc31..fc4fa740fe9 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getAccessibleCopilotChat, + getAccessibleCopilotChatContinuationMetadata, getAccessibleCopilotChatWithMessages, resolveOrCreateChat, } from '@/lib/copilot/chat/lifecycle' @@ -106,6 +107,59 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(result?.messages).toEqual([]) }) + it('loads continuation metadata with a one-row probe and contexts-only MCP projection', async () => { + const continuationRow = { + id: chatRow.id, + userId: chatRow.userId, + workflowId: chatRow.workflowId, + workspaceId: chatRow.workspaceId, + type: chatRow.type, + title: chatRow.title, + } + dbChainMockFns.limit + .mockResolvedValueOnce([continuationRow]) + .mockResolvedValueOnce([{ id: 'message-1' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([ + { + contexts: [ + { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs' }, + { kind: 'skill', skillId: 'skill-review', label: 'Review' }, + ], + }, + { contexts: [{ kind: 'mcp', serverId: 'mcp-docs', label: 'Docs again' }] }, + { contexts: [{ kind: 'mcp', serverId: 'mcp-issues', label: 'Issues' }] }, + ]) + + const result = await getAccessibleCopilotChatContinuationMetadata(CHAT_ID, USER_ID) + + expect(result).toEqual({ + ...continuationRow, + hasMessages: true, + mcpServerIds: ['mcp-docs', 'mcp-issues'], + }) + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.orderBy).toHaveBeenCalledTimes(1) + const contextsProjection = dbChainMockFns.select.mock.calls[2]?.[0] as Record + expect(Object.keys(contextsProjection)).toEqual(['contexts']) + }) + + it('skips the MCP projection for an empty persisted chat', async () => { + const continuationRow = { + id: chatRow.id, + userId: chatRow.userId, + workflowId: chatRow.workflowId, + workspaceId: chatRow.workspaceId, + type: chatRow.type, + title: chatRow.title, + } + dbChainMockFns.limit.mockResolvedValueOnce([continuationRow]).mockResolvedValueOnce([]) + + const result = await getAccessibleCopilotChatContinuationMetadata(CHAT_ID, USER_ID) + + expect(result).toEqual({ ...continuationRow, hasMessages: false, mcpServerIds: [] }) + expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + }) + it('returns null and does NOT query messages when the chat is not found', async () => { dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 69b577a31e9..381a227ed75 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -6,7 +6,11 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' -import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' +import { + collectChatMcpServerIds, + type PersistedMessage, + stripToolResultOutput, +} from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, @@ -35,6 +39,11 @@ const copilotChatAuthColumns = { type: copilotChats.type, } as const +const copilotChatContinuationColumns = { + ...copilotChatAuthColumns, + title: copilotChats.title, +} as const + /** * Column set for chat-detail callers that need chat metadata. The conversation * transcript is no longer selected from `copilot_chats.messages` (JSONB) — @@ -103,6 +112,12 @@ type CopilotChatAuthRow = Pick< 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' > +export type CopilotChatContinuationMetadata = CopilotChatAuthRow & { + title: string | null + hasMessages: boolean + mcpServerIds: string[] +} + export type CopilotChatDetailRow = Pick< typeof copilotChats.$inferSelect, | 'id' @@ -181,6 +196,57 @@ export async function getAccessibleCopilotChatAuth( return authorizeCopilotChatRow(chat, chatId, userId) } +/** + * Loads only the authorized metadata needed to continue a persisted chat. The + * one-row existence probe preserves first-turn title behavior, while the MCP + * query projects only user-message context arrays. Assistant/tool content is + * never loaded or normalized. + */ +export async function getAccessibleCopilotChatContinuationMetadata( + chatId: string, + userId: string +): Promise { + const [chat] = await db + .select(copilotChatContinuationColumns) + .from(copilotChats) + .where(ownedLiveChatWhere(chatId, userId)) + .limit(1) + + const authorized = await authorizeCopilotChatRow(chat, chatId, userId) + if (!authorized) return null + + const [message] = await db + .select({ id: copilotMessages.id }) + .from(copilotMessages) + .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) + .limit(1) + + if (!message) return { ...authorized, hasMessages: false, mcpServerIds: [] } + + const contextRows = await db + .select({ contexts: sql`${copilotMessages.content} -> 'contexts'` }) + .from(copilotMessages) + .where( + and( + eq(copilotMessages.chatId, chatId), + eq(copilotMessages.role, 'user'), + isNull(copilotMessages.deletedAt), + sql`${copilotMessages.content} ? 'contexts'` + ) + ) + .orderBy( + sql`${copilotMessages.seq} asc nulls last`, + asc(copilotMessages.createdAt), + asc(copilotMessages.id) + ) + + return { + ...authorized, + hasMessages: true, + mcpServerIds: collectChatMcpServerIds(contextRows), + } +} + /** * Load a copilot chat row for the legacy chat detail endpoint, including the * transcript plus `model` and `config`. Drops `previewYaml` diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index d600f6ca5b1..1ce27c6e880 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -7,12 +7,40 @@ import type { OrchestratorResult } from '@/lib/copilot/request/types' import { buildPersistedAssistantMessage, buildPersistedUserMessage, + collectChatMcpServerIds, normalizeMessage, type PersistedMessage, stripToolResultOutput, } from './persisted-message' describe('persisted-message', () => { + it('collects append-only MCP ids from persisted and current contexts', () => { + expect( + collectChatMcpServerIds( + [ + { + contexts: [ + { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs' }, + { kind: 'skill', skillId: 'skill-review', label: 'Review' }, + ], + }, + null, + { + contexts: [ + { kind: 'mcp', serverId: 'mcp-docs', label: 'Docs again' }, + { kind: 'mcp', serverId: '', label: 'Invalid' }, + { kind: 'mcp', serverId: 'mcp-issues', label: 'Issues' }, + ], + }, + ], + [ + { kind: 'mcp', serverId: 'mcp-issues', label: 'Issues again' }, + { kind: 'mcp', serverId: 'mcp-search', label: 'Search' }, + ] + ) + ).toEqual(['mcp-docs', 'mcp-issues', 'mcp-search']) + }) + it('round-trips canonical tool blocks through normalizeMessage', () => { const blockTimestamp = 1_700_000_000_000 const result: OrchestratorResult = { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index a48841f135f..a60961f3d5d 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -125,6 +125,36 @@ export interface PersistedMessage { contexts?: PersistedMessageContext[] } +/** + * Collect the append-only MCP enablement carried by explicitly tagged user + * message contexts. Only ids move between turns: inherited contexts are not + * re-expanded into the prompt or persisted again as chips on later messages. + */ +export function collectChatMcpServerIds( + conversationHistory: readonly unknown[], + currentContexts?: unknown +): string[] { + const serverIds = new Set() + + const collect = (contexts: unknown) => { + if (!Array.isArray(contexts)) return + for (const context of contexts) { + if (!context || typeof context !== 'object') continue + const { kind, serverId } = context as { kind?: unknown; serverId?: unknown } + if (kind === 'mcp' && typeof serverId === 'string' && serverId) { + serverIds.add(serverId) + } + } + } + + for (const message of conversationHistory) { + collect((message as { contexts?: unknown } | null)?.contexts) + } + collect(currentContexts) + + return Array.from(serverIds) +} + /** * Drop the `output` of every persisted tool result, keeping `success` and * `error`. Tool outputs are never rendered (the chat thread shows only the tool diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 24da5a81104..5eafbf77e1e 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1,24 +1,16 @@ -import { type Context as OtelContext, context as otelContextApi } from '@opentelemetry/api' -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' +import { context as otelContextApi } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { isZodError, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' -import { - buildPersistedAssistantMessage, - buildPersistedUserMessage, - withStoppedContentBlock, -} from '@/lib/copilot/chat/persisted-message' +import { collectChatMcpServerIds } from '@/lib/copilot/chat/persisted-message' import { processContextsServer, resolveActiveResourceContext, @@ -29,17 +21,16 @@ import { MAX_TABLE_SELECTION_ROWS, safeBrowserSelectionUrl, } from '@/lib/copilot/chat/selection-context' -import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' +import { + buildCopilotTurnOnComplete, + buildCopilotTurnOnError, + persistCopilotUserMessage, +} from '@/lib/copilot/chat/turn-persistence' import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' -import { chatPubSub } from '@/lib/copilot/chat-status' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' -import { - CopilotChatFinalizeOutcome, - CopilotChatPersistOutcome, - CopilotTransport, -} from '@/lib/copilot/generated/trace-attribute-values-v1' +import { CopilotTransport } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' @@ -51,7 +42,7 @@ import { getPendingChatStreamId, releasePendingChatStream, } from '@/lib/copilot/request/session' -import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types' +import type { ExecutionContext } from '@/lib/copilot/request/types' import { persistChatResources } from '@/lib/copilot/resources/persistence' import { canonicalizeDesktopSessionResources, @@ -397,44 +388,6 @@ function normalizeContexts(contexts: UnifiedChatRequest['contexts']) { }) } -/** - * An MCP server tagged with `/name` stays enabled for the rest of the chat, not - * just the turn it was tagged on. Persisted user messages already carry their - * `mcp` contexts, so the transcript is the source of truth — enablement survives - * reloads and reopened chats with no extra state to keep in sync. There is - * deliberately no off switch: history is append-only. - * - * Only the ids travel forward, not the contexts themselves. The tools ride the - * tool array on every turn, so the model always sees their names and schemas; - * re-expanding the prompt listing each turn would just duplicate that. Keeping - * inherited servers out of the persisted contexts also keeps the `/name` chips - * on a sent message showing only what the user actually typed that turn. - */ -function collectChatMcpServerIds( - conversationHistory: unknown[], - currentContexts: UnifiedChatRequest['contexts'] -): string[] { - const serverIds = new Set() - - const collect = (contexts: unknown) => { - if (!Array.isArray(contexts)) return - for (const ctx of contexts) { - if (!ctx || typeof ctx !== 'object') continue - const { kind, serverId } = ctx as { kind?: unknown; serverId?: unknown } - if (kind === 'mcp' && typeof serverId === 'string' && serverId) { - serverIds.add(serverId) - } - } - } - - for (const message of conversationHistory) { - collect((message as { contexts?: unknown } | null)?.contexts) - } - collect(currentContexts) - - return Array.from(serverIds) -} - async function resolveAgentContexts(params: { contexts?: UnifiedChatRequest['contexts'] resourceAttachments?: UnifiedChatRequest['resourceAttachments'] @@ -534,96 +487,6 @@ function projectAgentContextInputs( })), } } - -async function persistUserMessage(params: { - chatId?: string - userMessageId: string - message: string - fileAttachments?: UnifiedChatRequest['fileAttachments'] - contexts?: UnifiedChatRequest['contexts'] - workspaceId?: string - notifyWorkspaceStatus: boolean - /** - * Root context for the mothership request. When present the persist - * span is created explicitly under it, which avoids relying on - * AsyncLocalStorage propagation — some upstream awaits (Next.js - * framework frames, Turbopack-instrumented I/O) can swap the active - * store out from under us in dev, which would otherwise leave this - * span parented to the about-to-be-dropped Next.js HTTP span. - */ - parentOtelContext?: OtelContext -}): Promise { - const { - chatId, - userMessageId, - message, - fileAttachments, - contexts, - workspaceId, - notifyWorkspaceStatus, - parentOtelContext, - } = params - if (!chatId) return - - return withCopilotSpan( - TraceSpan.CopilotChatPersistUserMessage, - { - [TraceAttr.DbSystem]: 'postgresql', - [TraceAttr.DbSqlTable]: 'copilot_chats', - [TraceAttr.ChatId]: chatId, - [TraceAttr.ChatUserMessageId]: userMessageId, - [TraceAttr.ChatMessageBytes]: message.length, - [TraceAttr.ChatFileAttachmentCount]: fileAttachments?.length ?? 0, - [TraceAttr.ChatContextCount]: contexts?.length ?? 0, - ...(workspaceId ? { [TraceAttr.WorkspaceId]: workspaceId } : {}), - }, - async (span) => { - const userMsg = buildPersistedUserMessage({ - id: userMessageId, - content: message, - fileAttachments, - contexts, - }) - - const updated = await db.transaction(async (tx) => { - const [row] = await tx - .update(copilotChats) - .set({ - conversationId: userMessageId, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, chatId)) - .returning({ model: copilotChats.model }) - - if (!row) return null - - await appendCopilotChatMessages( - chatId, - [userMsg], - { streamId: userMessageId, chatModel: row.model ?? null }, - tx - ) - return row - }) - - span.setAttribute( - TraceAttr.ChatPersistOutcome, - updated ? CopilotChatPersistOutcome.Appended : CopilotChatPersistOutcome.ChatNotFound - ) - - if (notifyWorkspaceStatus && updated && workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'started', - streamId: userMessageId, - }) - } - }, - parentOtelContext - ) -} - async function buildInitialExecutionContext(params: { userId: string workflowId?: string @@ -666,145 +529,6 @@ async function buildInitialExecutionContext(params: { } } -function buildOnComplete(params: { - chatId?: string - userMessageId: string - requestId: string - workspaceId?: string - notifyWorkspaceStatus: boolean - /** - * Root agent span for this request. When present, the final - * assistant message + invoked tool calls are recorded as - * `gen_ai.output.messages` on it before persistence runs. Keeps - * the Honeycomb Gen AI view complete across both the Sim root - * span and the Go-side `llm.stream` spans. - */ - otelRoot?: { - setOutputMessages: (output: { - assistantText?: string - toolCalls?: Array<{ id: string; name: string; arguments?: Record }> - }) => void - } -}) { - const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus, otelRoot } = params - - return async (result: OrchestratorResult) => { - if (otelRoot && result.success) { - otelRoot.setOutputMessages({ - assistantText: result.content, - toolCalls: result.toolCalls?.map((tc) => ({ - id: tc.id, - name: tc.name, - arguments: tc.params, - })), - }) - } - - if (!chatId) return - - try { - if (result.cancelled) { - const finalization = await finalizeAssistantTurn({ - chatId, - userMessageId, - assistantMessage: withStoppedContentBlock( - buildPersistedAssistantMessage(result, requestId) - ), - streamMarkerPolicy: 'active-or-cleared', - }) - const shouldPublishCompletion = - finalization.updated || - finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted - - if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'completed', - streamId: userMessageId, - }) - } - return - } - - // On a non-success terminal (e.g. a transient provider error like - // "overloaded"), persist whatever streamed before the failure — same as - // the cancelled path — instead of dropping the partial assistant output. - const assistantMessage = buildPersistedAssistantMessage(result, requestId) - const hasPartial = - !!assistantMessage.content?.trim() || (assistantMessage.contentBlocks?.length ?? 0) > 0 - await finalizeAssistantTurn({ - chatId, - userMessageId, - ...(result.success || hasPartial ? { assistantMessage } : {}), - // Match the cancelled path so the partial still persists if onError - // raced ahead and already cleared the stream marker. - ...(result.success ? {} : { streamMarkerPolicy: 'active-or-cleared' as const }), - }) - - if (notifyWorkspaceStatus && workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'completed', - streamId: userMessageId, - }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to persist chat messages`, { - chatId, - error: getErrorMessage(error, 'Unknown error'), - }) - } - } -} - -function buildOnError(params: { - chatId?: string - userMessageId: string - requestId: string - workspaceId?: string - notifyWorkspaceStatus: boolean -}) { - const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus } = params - - return async (_error: Error, result?: OrchestratorResult) => { - if (!chatId) return - - try { - // Persist whatever streamed before a thrown backend error, mirroring the - // cancelled / non-success completion path, so the partial assistant turn - // (text + tool calls + subagent work) survives the refetch instead of the - // chat collapsing to an empty assistant row. - const assistantMessage = result - ? buildPersistedAssistantMessage(result, requestId) - : undefined - const hasPartial = - !!assistantMessage?.content?.trim() || (assistantMessage?.contentBlocks?.length ?? 0) > 0 - await finalizeAssistantTurn({ - chatId, - userMessageId, - ...(hasPartial ? { assistantMessage } : {}), - streamMarkerPolicy: 'active-or-cleared', - }) - - if (notifyWorkspaceStatus && workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId, - chatId, - type: 'completed', - streamId: userMessageId, - }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to finalize errored chat stream`, { - chatId, - error: getErrorMessage(error, 'Unknown error'), - }) - } - } -} - async function resolveBranch(params: { authenticatedUserId: string workflowId?: string @@ -1216,7 +940,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.context ) }) - const persistUserMessagePromise = persistUserMessage({ + const persistUserMessagePromise = persistCopilotUserMessage({ chatId: actualChatId, userMessageId, message: body.message, @@ -1343,7 +1067,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { autoExecuteTools: true, interactive: true, executionContext, - onComplete: buildOnComplete({ + onComplete: buildCopilotTurnOnComplete({ chatId: actualChatId, userMessageId, requestId, @@ -1351,7 +1075,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { notifyWorkspaceStatus: branch.notifyWorkspaceStatus, otelRoot, }), - onError: buildOnError({ + onError: buildCopilotTurnOnError({ chatId: actualChatId, userMessageId, requestId, diff --git a/apps/sim/lib/copilot/chat/turn-persistence.ts b/apps/sim/lib/copilot/chat/turn-persistence.ts new file mode 100644 index 00000000000..ded0eb531bd --- /dev/null +++ b/apps/sim/lib/copilot/chat/turn-persistence.ts @@ -0,0 +1,246 @@ +import type { Context as OtelContext } from '@opentelemetry/api' +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, + type UserMessageParams, + withStoppedContentBlock, +} from '@/lib/copilot/chat/persisted-message' +import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' +import { chatPubSub } from '@/lib/copilot/chat-status' +import { + CopilotChatFinalizeOutcome, + CopilotChatPersistOutcome, +} from '@/lib/copilot/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/copilot/request/otel' +import type { OrchestratorResult } from '@/lib/copilot/request/types' + +const logger = createLogger('CopilotTurnPersistence') + +export interface PersistCopilotUserMessageParams { + chatId?: string + userMessageId: string + message: string + fileAttachments?: UserMessageParams['fileAttachments'] + contexts?: UserMessageParams['contexts'] + workspaceId?: string + notifyWorkspaceStatus: boolean + /** + * Root context for the mothership request. When present the persist span is + * created explicitly under it instead of relying on ambient propagation. + */ + parentOtelContext?: OtelContext +} + +/** Persists the user half of a chat turn and marks that turn as active. */ +export async function persistCopilotUserMessage({ + chatId, + userMessageId, + message, + fileAttachments, + contexts, + workspaceId, + notifyWorkspaceStatus, + parentOtelContext, +}: PersistCopilotUserMessageParams): Promise { + if (!chatId) return + + return withCopilotSpan( + TraceSpan.CopilotChatPersistUserMessage, + { + [TraceAttr.DbSystem]: 'postgresql', + [TraceAttr.DbSqlTable]: 'copilot_chats', + [TraceAttr.ChatId]: chatId, + [TraceAttr.ChatUserMessageId]: userMessageId, + [TraceAttr.ChatMessageBytes]: message.length, + [TraceAttr.ChatFileAttachmentCount]: fileAttachments?.length ?? 0, + [TraceAttr.ChatContextCount]: contexts?.length ?? 0, + ...(workspaceId ? { [TraceAttr.WorkspaceId]: workspaceId } : {}), + }, + async (span) => { + const userMessage = buildPersistedUserMessage({ + id: userMessageId, + content: message, + fileAttachments, + contexts, + }) + + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(copilotChats) + .set({ + conversationId: userMessageId, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) + + if (!row) return null + + await appendCopilotChatMessages( + chatId, + [userMessage], + { streamId: userMessageId, chatModel: row.model ?? null }, + tx + ) + return row + }) + + span.setAttribute( + TraceAttr.ChatPersistOutcome, + updated ? CopilotChatPersistOutcome.Appended : CopilotChatPersistOutcome.ChatNotFound + ) + + if (notifyWorkspaceStatus && updated && workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'started', + streamId: userMessageId, + }) + } + }, + parentOtelContext + ) +} + +interface CopilotTurnTerminalParams { + chatId?: string + userMessageId: string + requestId: string + workspaceId?: string + notifyWorkspaceStatus: boolean +} + +export interface BuildCopilotTurnOnCompleteParams extends CopilotTurnTerminalParams { + /** Records the terminal model output on an optional caller-owned root span. */ + otelRoot?: { + setOutputMessages: (output: { + assistantText?: string + toolCalls?: Array<{ id: string; name: string; arguments?: Record }> + }) => void + } +} + +/** Builds the shared successful/cancelled turn persistence callback. */ +export function buildCopilotTurnOnComplete({ + chatId, + userMessageId, + requestId, + workspaceId, + notifyWorkspaceStatus, + otelRoot, +}: BuildCopilotTurnOnCompleteParams) { + return async (result: OrchestratorResult): Promise => { + if (otelRoot && result.success) { + otelRoot.setOutputMessages({ + assistantText: result.content, + toolCalls: result.toolCalls?.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.params, + })), + }) + } + + if (!chatId) return + + try { + if (result.cancelled) { + const finalization = await finalizeAssistantTurn({ + chatId, + userMessageId, + assistantMessage: withStoppedContentBlock( + buildPersistedAssistantMessage(result, requestId) + ), + streamMarkerPolicy: 'active-or-cleared', + }) + const shouldPublishCompletion = + finalization.updated || + finalization.outcome === CopilotChatFinalizeOutcome.AssistantAlreadyPersisted + + if (notifyWorkspaceStatus && workspaceId && shouldPublishCompletion) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + return + } + + const assistantMessage = buildPersistedAssistantMessage(result, requestId) + const hasPartial = + !!assistantMessage.content?.trim() || (assistantMessage.contentBlocks?.length ?? 0) > 0 + await finalizeAssistantTurn({ + chatId, + userMessageId, + ...(result.success || hasPartial ? { assistantMessage } : {}), + ...(result.success ? {} : { streamMarkerPolicy: 'active-or-cleared' as const }), + }) + + if (notifyWorkspaceStatus && workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to persist chat messages`, { + chatId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + } +} + +/** Builds the shared thrown-error turn persistence callback. */ +export function buildCopilotTurnOnError({ + chatId, + userMessageId, + requestId, + workspaceId, + notifyWorkspaceStatus, +}: CopilotTurnTerminalParams) { + return async (_error: Error, result?: OrchestratorResult): Promise => { + if (!chatId) return + + try { + const assistantMessage = result + ? buildPersistedAssistantMessage(result, requestId) + : undefined + const hasPartial = + !!assistantMessage?.content?.trim() || (assistantMessage?.contentBlocks?.length ?? 0) > 0 + await finalizeAssistantTurn({ + chatId, + userMessageId, + ...(hasPartial ? { assistantMessage } : {}), + streamMarkerPolicy: 'active-or-cleared', + }) + + if (notifyWorkspaceStatus && workspaceId) { + chatPubSub?.publishStatusChanged({ + workspaceId, + chatId, + type: 'completed', + streamId: userMessageId, + }) + } + } catch (error) { + logger.error(`[${requestId}] Failed to finalize errored chat stream`, { + chatId, + error: getErrorMessage(error, 'Unknown error'), + }) + } + } +} diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index d2144ab844a..2b55e2d6f8a 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -25,7 +25,7 @@ import { import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listCustomToolSummaries } from '@/lib/workflows/custom-tools/operations' import { listSkillsForUser } from '@/lib/workflows/skills/operations' import { assertActiveWorkspaceAccess, @@ -329,7 +329,7 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string { async function buildWorkspaceMdData( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess } + options?: { workspaceAccess?: WorkspaceAccess; secretless?: boolean } ): Promise { try { // Reuse the caller's already-asserted access when provided (hot chat path); @@ -411,11 +411,17 @@ async function buildWorkspaceMdData( listWorkspaceFiles(workspaceId), - getAccessibleOAuthCredentials(workspaceId, userId), + options?.secretless + ? Promise.resolve([]) + : getAccessibleOAuthCredentials(workspaceId, userId), - getAccessibleEnvCredentials(workspaceId, userId), + options?.secretless ? Promise.resolve([]) : getAccessibleEnvCredentials(workspaceId, userId), - listCustomTools({ userId, workspaceId }), + listCustomToolSummaries({ + userId, + workspaceId, + workspaceOnly: options?.secretless, + }), db .select({ @@ -515,7 +521,12 @@ async function buildWorkspaceMdData( ), customTools: customTools.map((t) => ({ id: t.id, name: t.title })), customBlocks: customBlockSummaries, - mcpServers: mcpServerRows, + mcpServers: mcpServerRows.map((server) => ({ + id: server.id, + name: server.name, + enabled: server.enabled, + ...(options?.secretless ? {} : { url: server.url }), + })), skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })), ...(sandboxResult.entitled ? { @@ -549,7 +560,11 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = export async function generateWorkspaceContext( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess; secretMountPolicy?: SecretMountPolicy } + options?: { + workspaceAccess?: WorkspaceAccess + secretless?: boolean + secretMountPolicy?: SecretMountPolicy + } ): Promise { const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return WORKSPACE_CONTEXT_UNAVAILABLE_MD @@ -568,9 +583,10 @@ export async function generateWorkspaceContext( */ export async function generateWorkspaceSnapshot( workspaceId: string, - userId: string + userId: string, + options?: { workspaceAccess?: WorkspaceAccess; secretless?: boolean } ): Promise<{ markdown: string; snapshot: VfsSnapshotV1 } | null> { - const data = await buildWorkspaceMdData(workspaceId, userId) + const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return null return { markdown: buildWorkspaceMd(data), snapshot: buildVfsSnapshot(data) } } diff --git a/apps/sim/lib/copilot/headless/attachments.test.ts b/apps/sim/lib/copilot/headless/attachments.test.ts new file mode 100644 index 00000000000..173b63d6656 --- /dev/null +++ b/apps/sim/lib/copilot/headless/attachments.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_V2_CHAT_ATTACHMENT_BYTES, + MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES, + MAX_V2_CHAT_IMAGE_DIMENSION, + MAX_V2_CHAT_IMAGE_PIXELS, + MAX_V2_CHAT_IMAGES_TOTAL_PIXELS, + MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES, +} from '@/lib/api/contracts/v2/chat' +import { prepareV2ChatAttachments } from '@/lib/copilot/headless/attachments' + +function attachment(name: string, mediaType: string, bytes: Buffer) { + return { name, mediaType, data: bytes.toString('base64') } +} + +function pngHeader(width: number, height: number): Buffer { + const buffer = Buffer.alloc(24) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer) + buffer.writeUInt32BE(13, 8) + buffer.write('IHDR', 12, 'ascii') + buffer.writeUInt32BE(width, 16) + buffer.writeUInt32BE(height, 20) + return buffer +} + +function gifHeader(width: number, height: number): Buffer { + const buffer = Buffer.alloc(10) + buffer.write('GIF89a', 0, 'ascii') + buffer.writeUInt16LE(width, 6) + buffer.writeUInt16LE(height, 8) + return buffer +} + +describe('prepareV2ChatAttachments', () => { + it('maps byte-sniffed images, PDFs, and UTF-8 text to Mothership attachments', () => { + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X2b6WQAAAABJRU5ErkJggg==', + 'base64' + ) + const result = prepareV2ChatAttachments([ + attachment('screenshot.png', 'image/png', png), + attachment('report.pdf', 'application/pdf', Buffer.from('%PDF-1.7\nexample')), + attachment('notes.md', 'text/markdown', Buffer.from('# Notes\n', 'utf8')), + ]) + + expect(result).toEqual({ + success: true, + attachments: [ + { + type: 'image', + filename: 'screenshot.png', + source: { type: 'base64', media_type: 'image/png', data: png.toString('base64') }, + }, + { + type: 'document', + filename: 'report.pdf', + source: { + type: 'base64', + media_type: 'application/pdf', + data: Buffer.from('%PDF-1.7\nexample').toString('base64'), + }, + }, + { + type: 'document', + filename: 'notes.md', + source: { + type: 'base64', + media_type: 'text/markdown', + data: Buffer.from('# Notes\n', 'utf8').toString('base64'), + }, + }, + ], + }) + }) + + it('preserves each supported raster image format', () => { + const images = [ + { + name: 'photo.jpg', + mediaType: 'image/jpeg', + data: '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJUAB//Z', + }, + { + name: 'animation.gif', + mediaType: 'image/gif', + data: 'R0lGODlhAQABAIAAAExpcQAAACH5BAUAAAAALAAAAAABAAEAAAICRAEAOw==', + }, + { + name: 'image.webp', + mediaType: 'image/webp', + data: 'UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP79NmgA', + }, + ] + + for (const image of images) { + expect( + prepareV2ChatAttachments([ + attachment(image.name, image.mediaType, Buffer.from(image.data, 'base64')), + ]) + ).toMatchObject({ + success: true, + attachments: [{ type: 'image', source: { media_type: image.mediaType } }], + }) + } + }) + + it('rejects non-canonical base64 before forwarding it', () => { + expect( + prepareV2ChatAttachments([{ name: 'notes.txt', mediaType: 'text/plain', data: 'YQ= ' }]) + ).toEqual({ + success: false, + error: { + code: 'BAD_REQUEST', + message: 'Attachment "notes.txt" data must be canonical base64', + }, + }) + }) + + it('rejects unsupported types and declared image types that do not match the bytes', () => { + expect( + prepareV2ChatAttachments([ + attachment('archive.zip', 'application/zip', Buffer.from('PK\x03\x04')), + ]) + ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) + + expect( + prepareV2ChatAttachments([ + attachment('fake.png', 'image/png', Buffer.from('')), + ]) + ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) + }) + + it('rejects malformed images even when their magic bytes match the declared type', () => { + expect( + prepareV2ChatAttachments([ + attachment( + 'truncated.png', + 'image/png', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ), + ]) + ).toEqual({ + success: false, + error: { + code: 'UNSUPPORTED_MEDIA_TYPE', + message: 'Attachment "truncated.png" is not a readable image', + }, + }) + }) + + it('rejects compressed images with an oversized axis before forwarding them', () => { + expect( + prepareV2ChatAttachments([ + attachment('wide.png', 'image/png', pngHeader(MAX_V2_CHAT_IMAGE_DIMENSION + 1, 1)), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + + expect( + prepareV2ChatAttachments([ + attachment('tall.gif', 'image/gif', gifHeader(1, MAX_V2_CHAT_IMAGE_DIMENSION + 1)), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('rejects compressed images over the total decoded-pixel limit', () => { + const width = 5000 + const height = Math.floor(MAX_V2_CHAT_IMAGE_PIXELS / width) + 1 + expect(width).toBeLessThanOrEqual(MAX_V2_CHAT_IMAGE_DIMENSION) + expect(height).toBeLessThanOrEqual(MAX_V2_CHAT_IMAGE_DIMENSION) + + expect( + prepareV2ChatAttachments([ + attachment('too-many-pixels.png', 'image/png', pngHeader(width, height)), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('enforces an aggregate decoded-pixel limit across images', () => { + const width = 4000 + const height = 4000 + const pixelsPerImage = width * height + expect(pixelsPerImage).toBe(MAX_V2_CHAT_IMAGE_PIXELS) + expect(pixelsPerImage * 2).toBe(MAX_V2_CHAT_IMAGES_TOTAL_PIXELS) + + expect( + prepareV2ChatAttachments([ + attachment('one.png', 'image/png', pngHeader(width, height)), + attachment('two.gif', 'image/gif', gifHeader(width, height)), + attachment('three.png', 'image/png', pngHeader(1, 1)), + ]) + ).toEqual({ + success: false, + error: { + code: 'PAYLOAD_TOO_LARGE', + message: `Images exceed the ${MAX_V2_CHAT_IMAGES_TOTAL_PIXELS}-pixel aggregate limit`, + }, + }) + }) + + it('enforces text and binary per-file byte limits', () => { + expect( + prepareV2ChatAttachments([ + attachment( + 'large.txt', + 'text/plain', + Buffer.alloc(MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES + 1, 0x61) + ), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + + const oversizedPng = Buffer.alloc(MAX_V2_CHAT_ATTACHMENT_BYTES + 1) + pngHeader(1, 1).copy(oversizedPng) + expect( + prepareV2ChatAttachments([attachment('large.png', 'image/png', oversizedPng)]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('enforces the decoded aggregate byte limit across attachments', () => { + const imageBytes = Buffer.alloc(4 * 1024 * 1024) + pngHeader(1, 1).copy(imageBytes) + + expect(imageBytes.byteLength * 3).toBeGreaterThan(MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES) + expect( + prepareV2ChatAttachments([ + attachment('one.png', 'image/png', imageBytes), + attachment('two.png', 'image/png', imageBytes), + attachment('three.png', 'image/png', imageBytes), + ]) + ).toMatchObject({ success: false, error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + + it('rejects binary data mislabeled as text', () => { + expect( + prepareV2ChatAttachments([ + attachment('binary.txt', 'text/plain', Buffer.from([0xff, 0xfe, 0xfd])), + ]) + ).toMatchObject({ success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE' } }) + }) +}) diff --git a/apps/sim/lib/copilot/headless/attachments.ts b/apps/sim/lib/copilot/headless/attachments.ts new file mode 100644 index 00000000000..7cbd863c31d --- /dev/null +++ b/apps/sim/lib/copilot/headless/attachments.ts @@ -0,0 +1,181 @@ +import { imageSize } from 'image-size' +import { isCanonicalBase64 } from '@/lib/api/contracts/primitives' +import { + MAX_V2_CHAT_ATTACHMENT_BYTES, + MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES, + MAX_V2_CHAT_IMAGE_DIMENSION, + MAX_V2_CHAT_IMAGE_PIXELS, + MAX_V2_CHAT_IMAGES_TOTAL_PIXELS, + MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES, + V2_CHAT_DOCUMENT_MEDIA_TYPES, + V2_CHAT_IMAGE_MEDIA_TYPES, + V2_CHAT_TEXT_MEDIA_TYPES, + type V2ChatAttachment, +} from '@/lib/api/contracts/v2/chat' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' + +export interface MothershipInlineFileAttachment { + type: 'image' | 'document' + filename: string + source: { + type: 'base64' + media_type: string + data: string + } +} + +type AttachmentValidationErrorCode = 'BAD_REQUEST' | 'PAYLOAD_TOO_LARGE' | 'UNSUPPORTED_MEDIA_TYPE' + +export type PreparedV2ChatAttachments = + | { success: true; attachments: MothershipInlineFileAttachment[] } + | { + success: false + error: { code: AttachmentValidationErrorCode; message: string } + } + +type AttachmentValidationFailure = Extract + +const IMAGE_MEDIA_TYPES = new Set(V2_CHAT_IMAGE_MEDIA_TYPES) +const DOCUMENT_MEDIA_TYPES = new Set(V2_CHAT_DOCUMENT_MEDIA_TYPES) +const TEXT_MEDIA_TYPES = new Set(V2_CHAT_TEXT_MEDIA_TYPES) +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) + +function decodeCanonicalBase64(data: string): Buffer | null { + return data.length > 0 && isCanonicalBase64(data) ? Buffer.from(data, 'base64') : null +} + +function isPdf(buffer: Buffer): boolean { + // Match the existing workspace VFS behavior: PDFs may have a BOM or leading + // whitespace, but the signature must appear near the beginning. + return buffer.subarray(0, 1024).toString('latin1').includes('%PDF') +} + +function invalidAttachment(message: string): AttachmentValidationFailure { + return { success: false, error: { code: 'BAD_REQUEST', message } } +} + +function unsupportedAttachment(message: string): AttachmentValidationFailure { + return { success: false, error: { code: 'UNSUPPORTED_MEDIA_TYPE', message } } +} + +function oversizedAttachment(message: string): AttachmentValidationFailure { + return { success: false, error: { code: 'PAYLOAD_TOO_LARGE', message } } +} + +function validateImageDimensions( + name: string, + buffer: Buffer +): { success: true; pixels: number } | AttachmentValidationFailure { + let dimensions: ReturnType + try { + dimensions = imageSize(buffer) + } catch { + return unsupportedAttachment(`Attachment "${name}" is not a readable image`) + } + + const { width, height } = dimensions + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { + return unsupportedAttachment(`Attachment "${name}" has invalid image dimensions`) + } + + if ( + width > MAX_V2_CHAT_IMAGE_DIMENSION || + height > MAX_V2_CHAT_IMAGE_DIMENSION || + width > MAX_V2_CHAT_IMAGE_PIXELS / height + ) { + return oversizedAttachment( + `Attachment "${name}" dimensions ${width}x${height} exceed the ${MAX_V2_CHAT_IMAGE_DIMENSION}-pixel axis or ${MAX_V2_CHAT_IMAGE_PIXELS}-pixel image limit` + ) + } + + return { success: true, pixels: width * height } +} + +/** + * Validates the public inline-file boundary and maps it to Mothership's + * existing base64 attachment contract. No path or URL is accepted or resolved. + */ +export function prepareV2ChatAttachments( + input: V2ChatAttachment[] | undefined +): PreparedV2ChatAttachments { + if (!input?.length) return { success: true, attachments: [] } + + const prepared: MothershipInlineFileAttachment[] = [] + let totalBytes = 0 + let totalImagePixels = 0 + + for (const attachment of input) { + const isImage = IMAGE_MEDIA_TYPES.has(attachment.mediaType) + const isPdfDocument = DOCUMENT_MEDIA_TYPES.has(attachment.mediaType) + const isTextDocument = TEXT_MEDIA_TYPES.has(attachment.mediaType) + + if (!isImage && !isPdfDocument && !isTextDocument) { + return unsupportedAttachment( + `Attachment "${attachment.name}" has unsupported media type ${attachment.mediaType}` + ) + } + + const decoded = decodeCanonicalBase64(attachment.data) + if (!decoded) { + return invalidAttachment(`Attachment "${attachment.name}" data must be canonical base64`) + } + + const perFileLimit = isTextDocument + ? MAX_V2_CHAT_TEXT_ATTACHMENT_BYTES + : MAX_V2_CHAT_ATTACHMENT_BYTES + if (decoded.byteLength > perFileLimit) { + return oversizedAttachment( + `Attachment "${attachment.name}" exceeds the ${perFileLimit}-byte limit for ${attachment.mediaType}` + ) + } + + totalBytes += decoded.byteLength + if (totalBytes > MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES) { + return oversizedAttachment( + `Attachments exceed the ${MAX_V2_CHAT_ATTACHMENTS_TOTAL_BYTES}-byte aggregate limit` + ) + } + + if (isImage) { + const sniffedMediaType = sniffImageContentType(decoded) + if (sniffedMediaType !== attachment.mediaType) { + return unsupportedAttachment( + `Attachment "${attachment.name}" bytes do not match ${attachment.mediaType}` + ) + } + const dimensions = validateImageDimensions(attachment.name, decoded) + if (!dimensions.success) return dimensions + totalImagePixels += dimensions.pixels + if (totalImagePixels > MAX_V2_CHAT_IMAGES_TOTAL_PIXELS) { + return oversizedAttachment( + `Images exceed the ${MAX_V2_CHAT_IMAGES_TOTAL_PIXELS}-pixel aggregate limit` + ) + } + } else if (isPdfDocument) { + if (!isPdf(decoded)) { + return unsupportedAttachment(`Attachment "${attachment.name}" is not a valid PDF`) + } + } else { + try { + const text = utf8Decoder.decode(decoded) + if (text.includes('\0')) { + return unsupportedAttachment(`Attachment "${attachment.name}" is not UTF-8 text`) + } + } catch { + return unsupportedAttachment(`Attachment "${attachment.name}" is not UTF-8 text`) + } + } + + prepared.push({ + type: isImage ? 'image' : 'document', + filename: attachment.name, + source: { + type: 'base64', + media_type: attachment.mediaType, + data: attachment.data, + }, + }) + } + + return { success: true, attachments: prepared } +} diff --git a/apps/sim/lib/copilot/headless/continuation-token.test.ts b/apps/sim/lib/copilot/headless/continuation-token.test.ts new file mode 100644 index 00000000000..299a6b67656 --- /dev/null +++ b/apps/sim/lib/copilot/headless/continuation-token.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnv } = vi.hoisted(() => ({ + mockEnv: { BETTER_AUTH_SECRET: 'test-v2-chat-secret-that-is-at-least-32-characters' }, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) + +import { + issueV2ChatContinuationToken, + V2_CHAT_CONTINUATION_TTL_SECONDS, + verifyV2ChatContinuationToken, +} from './continuation-token' + +const NOW = 1_800_000_000 +const binding = { + workspaceId: 'workspace-1', + authorizationUserId: 'key-owner-1', + credentialType: 'personal' as const, + readOnly: false, +} + +describe('v2 chat continuation tokens', () => { + beforeEach(() => { + mockEnv.BETTER_AUTH_SECRET = 'test-v2-chat-secret-that-is-at-least-32-characters' + }) + + it('round-trips the private chat id only for its bound principal and workspace', async () => { + const token = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-private-1', + now: NOW, + }) + + await expect(verifyV2ChatContinuationToken(token, binding, NOW + 1)).resolves.toEqual({ + valid: true, + chatId: 'chat-private-1', + }) + await expect( + verifyV2ChatContinuationToken(token, { ...binding, workspaceId: 'workspace-2' }, NOW + 1) + ).resolves.toEqual({ valid: false }) + await expect( + verifyV2ChatContinuationToken( + token, + { ...binding, authorizationUserId: 'other-user' }, + NOW + 1 + ) + ).resolves.toEqual({ valid: false }) + await expect( + verifyV2ChatContinuationToken(token, { ...binding, readOnly: true }, NOW + 1) + ).resolves.toEqual({ valid: false }) + await expect( + verifyV2ChatContinuationToken(token, { ...binding, credentialType: 'workspace' }, NOW + 1) + ).resolves.toEqual({ valid: false }) + }) + + it('authenticates the optional Sim persistence claim without changing legacy tokens', async () => { + const syncedToken = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-synced-1', + persistence: 'sim', + now: NOW, + }) + const legacyToken = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-legacy-1', + now: NOW, + }) + + await expect(verifyV2ChatContinuationToken(syncedToken, binding, NOW + 1)).resolves.toEqual({ + valid: true, + chatId: 'chat-synced-1', + persistence: 'sim', + }) + await expect(verifyV2ChatContinuationToken(legacyToken, binding, NOW + 1)).resolves.toEqual({ + valid: true, + chatId: 'chat-legacy-1', + }) + }) + + it('rejects tampering and expiry', async () => { + const token = await issueV2ChatContinuationToken({ + ...binding, + chatId: 'chat-private-1', + now: NOW, + }) + const tampered = `${token.slice(0, -1)}${token.endsWith('a') ? 'b' : 'a'}` + + await expect(verifyV2ChatContinuationToken(tampered, binding, NOW + 1)).resolves.toEqual({ + valid: false, + }) + await expect( + verifyV2ChatContinuationToken(token, binding, NOW + V2_CHAT_CONTINUATION_TTL_SECONDS) + ).resolves.toEqual({ valid: false }) + }) + + it('encrypts the claims with a fresh nonce so decoding token segments cannot reveal the chat id', async () => { + const chatId = 'chat-private-1' + const token = await issueV2ChatContinuationToken({ ...binding, chatId, now: NOW }) + const nextToken = await issueV2ChatContinuationToken({ ...binding, chatId, now: NOW }) + const [prefix, ...encodedSegments] = token.split('.') + + expect(prefix).toBe('sim-v2-chat-v1') + expect(encodedSegments).toHaveLength(1) + expect(nextToken).not.toBe(token) + expect(token).not.toContain(chatId) + for (const segment of encodedSegments) { + expect(Buffer.from(segment, 'base64url').toString('utf8')).not.toContain(chatId) + } + }) +}) diff --git a/apps/sim/lib/copilot/headless/continuation-token.ts b/apps/sim/lib/copilot/headless/continuation-token.ts new file mode 100644 index 00000000000..46e646cbe0b --- /dev/null +++ b/apps/sim/lib/copilot/headless/continuation-token.ts @@ -0,0 +1,140 @@ +import { createHmac } from 'node:crypto' +import { decrypt, encrypt } from '@sim/security/encryption' +import { env } from '@/lib/core/config/env' + +const TOKEN_PREFIX = 'sim-v2-chat-v1' +const TOKEN_MAX_LENGTH = 4096 + +/** Interactive CLI sessions may refresh this rolling expiry on every turn. */ +export const V2_CHAT_CONTINUATION_TTL_SECONDS = 24 * 60 * 60 + +interface ContinuationClaims { + version: 1 + chatId: string + workspaceId: string + authorizationUserId: string + credentialType: 'personal' | 'workspace' + readOnly: boolean + /** Present only when the chat is backed by Sim's persisted chat tables. */ + persistence?: 'sim' + issuedAt: number + expiresAt: number +} + +export interface ContinuationBinding { + workspaceId: string + authorizationUserId: string + credentialType: 'personal' | 'workspace' + readOnly: boolean +} + +export interface IssueContinuationTokenInput extends ContinuationBinding { + chatId: string + persistence?: 'sim' + /** Unix seconds; exposed only to keep expiry behavior deterministic in tests. */ + now?: number +} + +export type VerifiedContinuationToken = + | { valid: true; chatId: string; persistence?: 'sim' } + | { valid: false } + +function encryptionKey(): Buffer { + // Derive a dedicated 256-bit key instead of using BETTER_AUTH_SECRET + // directly. The purpose string prevents ciphertexts from another feature + // backed by the same deployment secret from being valid here. + return createHmac('sha256', env.BETTER_AUTH_SECRET) + .update(`${TOKEN_PREFIX}:aes-256-gcm-encryption-key`, 'utf8') + .digest() +} + +function decodeCanonicalBase64Url(segment: string): string | null { + if (!segment || !/^[A-Za-z0-9_-]+$/.test(segment)) return null + const decoded = Buffer.from(segment, 'base64url') + return decoded.toString('base64url') === segment ? decoded.toString('utf8') : null +} + +function isContinuationClaims(value: unknown): value is ContinuationClaims { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const claims = value as Partial + return ( + claims.version === 1 && + typeof claims.chatId === 'string' && + claims.chatId.length > 0 && + claims.chatId.length <= 255 && + typeof claims.workspaceId === 'string' && + claims.workspaceId.length > 0 && + claims.workspaceId.length <= 255 && + typeof claims.authorizationUserId === 'string' && + claims.authorizationUserId.length > 0 && + claims.authorizationUserId.length <= 255 && + (claims.credentialType === 'personal' || claims.credentialType === 'workspace') && + typeof claims.readOnly === 'boolean' && + (claims.persistence === undefined || claims.persistence === 'sim') && + Number.isSafeInteger(claims.issuedAt) && + Number.isSafeInteger(claims.expiresAt) && + (claims.expiresAt as number) > (claims.issuedAt as number) + ) +} + +/** Issues an opaque, authenticated handle for one private Mothership chat. */ +export async function issueV2ChatContinuationToken( + input: IssueContinuationTokenInput +): Promise { + const issuedAt = input.now ?? Math.floor(Date.now() / 1000) + const claims: ContinuationClaims = { + version: 1, + chatId: input.chatId, + workspaceId: input.workspaceId, + authorizationUserId: input.authorizationUserId, + credentialType: input.credentialType, + readOnly: input.readOnly, + ...(input.persistence ? { persistence: input.persistence } : {}), + issuedAt, + expiresAt: issuedAt + V2_CHAT_CONTINUATION_TTL_SECONDS, + } + + const { encrypted } = await encrypt(JSON.stringify(claims), encryptionKey()) + return `${TOKEN_PREFIX}.${Buffer.from(encrypted, 'utf8').toString('base64url')}` +} + +/** + * Authenticates/decrypts the handle, then verifies expiry and the request's + * ownership tuple. Every failure is intentionally indistinguishable to callers. + */ +export async function verifyV2ChatContinuationToken( + token: string, + binding: ContinuationBinding, + now: number = Math.floor(Date.now() / 1000) +): Promise { + if (!token || token.length > TOKEN_MAX_LENGTH) return { valid: false } + + const [prefix, encodedCiphertext, ...extra] = token.split('.') + if (prefix !== TOKEN_PREFIX || !encodedCiphertext || extra.length > 0) { + return { valid: false } + } + + try { + const ciphertext = decodeCanonicalBase64Url(encodedCiphertext) + if (!ciphertext) return { valid: false } + const { decrypted } = await decrypt(ciphertext, encryptionKey()) + const parsed = JSON.parse(decrypted) as unknown + if (!isContinuationClaims(parsed)) return { valid: false } + if (parsed.expiresAt <= now || parsed.issuedAt > now + 60) return { valid: false } + if ( + parsed.workspaceId !== binding.workspaceId || + parsed.authorizationUserId !== binding.authorizationUserId || + parsed.credentialType !== binding.credentialType || + parsed.readOnly !== binding.readOnly + ) { + return { valid: false } + } + return { + valid: true, + chatId: parsed.chatId, + ...(parsed.persistence ? { persistence: parsed.persistence } : {}), + } + } catch { + return { valid: false } + } +} diff --git a/apps/sim/lib/copilot/headless/workspace-chat.test.ts b/apps/sim/lib/copilot/headless/workspace-chat.test.ts new file mode 100644 index 00000000000..154efb7da25 --- /dev/null +++ b/apps/sim/lib/copilot/headless/workspace-chat.test.ts @@ -0,0 +1,544 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAssertActiveWorkspaceAccess, + mockBuildIntegrationToolSchemas, + mockBuildTaggedMcpToolSchemas, + mockComputeWorkspaceEntitlements, + mockCreateCopilotEnvironmentContext, + mockGenerateWorkspaceSnapshot, + mockPrepareCopilotEnvironmentContext, + mockProcessContextsServer, + mockRunHeadlessCopilotLifecycle, +} = vi.hoisted(() => ({ + mockAssertActiveWorkspaceAccess: vi.fn(), + mockBuildIntegrationToolSchemas: vi.fn(), + mockBuildTaggedMcpToolSchemas: vi.fn(), + mockComputeWorkspaceEntitlements: vi.fn(), + mockCreateCopilotEnvironmentContext: vi.fn(), + mockGenerateWorkspaceSnapshot: vi.fn(), + mockPrepareCopilotEnvironmentContext: vi.fn(), + mockProcessContextsServer: vi.fn(), + mockRunHeadlessCopilotLifecycle: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/payload', () => ({ + buildIntegrationToolSchemas: mockBuildIntegrationToolSchemas, +})) + +vi.mock('@/lib/copilot/chat/process-contents', () => ({ + processContextsServer: mockProcessContextsServer, +})) + +vi.mock('@/lib/copilot/mcp-tools', () => ({ + buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas, +})) + +vi.mock('@/lib/copilot/chat/workspace-context', () => ({ + generateWorkspaceSnapshot: mockGenerateWorkspaceSnapshot, +})) + +vi.mock('@/lib/copilot/entitlements', () => ({ + computeWorkspaceEntitlements: mockComputeWorkspaceEntitlements, +})) + +vi.mock('@/lib/copilot/environment-context', () => ({ + createCopilotEnvironmentContext: mockCreateCopilotEnvironmentContext, + prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, +})) + +vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ + runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDocSandboxEnabled: false, + isHosted: false, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, +})) + +import { publicChatUsageLimitMessage, runWorkspaceChat, toPublicChatResult } from './workspace-chat' + +const billingAttribution = { + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + organizationId: 'organization-1', + billedAccountUserId: 'billed-account-1', + billingEntity: { type: 'organization' as const, id: 'organization-1' }, + billingPeriod: { + start: '2026-08-01T00:00:00.000Z', + end: '2026-09-01T00:00:00.000Z', + }, + payerSubscription: null, +} + +describe('runWorkspaceChat', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) + mockGenerateWorkspaceSnapshot.mockResolvedValue({ + markdown: 'workspace markdown', + snapshot: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, + }) + mockComputeWorkspaceEntitlements.mockResolvedValue(['custom-blocks']) + mockCreateCopilotEnvironmentContext.mockResolvedValue({ + resolvedSecretTraceRegistry: { kind: 'empty-registry' }, + }) + mockPrepareCopilotEnvironmentContext.mockResolvedValue({ + resolvedSecretTraceRegistry: { kind: 'full-registry' }, + }) + mockBuildIntegrationToolSchemas.mockResolvedValue([ + { name: 'slack_send', description: 'Send Slack message', input_schema: {} }, + ]) + mockBuildTaggedMcpToolSchemas.mockResolvedValue([]) + mockProcessContextsServer.mockResolvedValue([]) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'answer', + contentBlocks: [], + toolCalls: [], + usage: { prompt: 12, completion: 3 }, + }) + }) + + it('uses normal Mothership permissions, integrations, memory, and secrets by default', async () => { + const userStopController = new AbortController() + const onComplete = vi.fn() + const onError = vi.fn() + await runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + executionId: 'execution-1', + runId: 'run-1', + billingAttribution, + userStopSignal: userStopController.signal, + onComplete, + onError, + }) + + expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'key-owner-1') + expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { + workspaceAccess: { permission: 'admin' }, + secretless: false, + }) + expect(mockPrepareCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1') + expect(mockCreateCopilotEnvironmentContext).not.toHaveBeenCalled() + expect(mockBuildIntegrationToolSchemas).toHaveBeenCalledWith( + 'key-owner-1', + 'message-1', + { schemaSurface: 'copilot' }, + 'workspace-1' + ) + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toMatchObject({ + message: 'Fix the workflow', + userId: 'billing-actor', + userPermission: 'admin', + integrationTools: [ + { name: 'slack_send', description: 'Send Slack message', input_schema: {} }, + ], + }) + expect(payload).not.toHaveProperty('queryOnly') + expect(payload).not.toHaveProperty('disableUserMemory') + expect(options).toMatchObject({ + userId: 'billing-actor', + authorizationUserId: 'key-owner-1', + executionId: 'execution-1', + runId: 'run-1', + autoCreateRunIdentity: false, + userPermission: 'admin', + secretActorUserId: 'key-owner-1', + environmentContext: { resolvedSecretTraceRegistry: { kind: 'full-registry' } }, + billingAttribution, + userStopSignal: userStopController.signal, + onComplete, + onError, + }) + expect(options).not.toHaveProperty('secretMountPolicy') + }) + + it('uses the workspace-chat route with a read-only, secretless server policy', async () => { + await runWorkspaceChat({ + prompt: 'What is deployed?', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + }) + + expect(mockCreateCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1', { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + }) + expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { + workspaceAccess: { permission: 'admin' }, + secretless: true, + }) + expect(mockComputeWorkspaceEntitlements).toHaveBeenCalledWith('workspace-1', 'key-owner-1') + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toEqual({ + message: 'What is deployed?', + userId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + mode: 'agent', + queryOnly: true, + disableUserMemory: true, + workspaceContext: 'workspace markdown', + vfs: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, + userPermission: 'read', + entitlements: ['custom-blocks'], + isHosted: false, + }) + expect(payload).not.toHaveProperty('model') + expect(payload).not.toHaveProperty('provider') + expect(payload).not.toHaveProperty('integrationTools') + expect(payload).not.toHaveProperty('mothershipTools') + + expect(options).toMatchObject({ + userId: 'billing-actor', + authorizationUserId: 'key-owner-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + autoCreateRunIdentity: false, + simRequestId: 'request-1', + goRoute: '/api/mothership/v2-chat', + resumeRoute: '/api/tools/v2-chat/resume', + autoExecuteTools: true, + interactive: false, + billingAttribution, + userPermission: 'read', + secretActorUserId: null, + secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, + environmentContext: { resolvedSecretTraceRegistry: { kind: 'empty-registry' } }, + }) + }) + + it('resolves structured tags and exposes only explicitly tagged MCP tools', async () => { + const contexts = [ + { kind: 'workflow' as const, workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill' as const, skillId: 'skill-1', label: 'review' }, + { kind: 'mcp' as const, serverId: 'mcp-1', label: 'Docs' }, + ] + mockProcessContextsServer.mockResolvedValueOnce([ + { type: 'workflow', content: '', path: 'workflows/release', tag: '@Release' }, + { type: 'skill', content: 'Review carefully', tag: '/review' }, + ]) + mockBuildTaggedMcpToolSchemas.mockResolvedValueOnce([ + { name: 'mcp_docs_search', description: 'Search docs', input_schema: {} }, + ]) + + await runWorkspaceChat({ + prompt: 'Use @Release and /review with /Docs', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + contexts, + }) + + expect(mockProcessContextsServer).toHaveBeenCalledWith( + contexts, + 'key-owner-1', + 'Use @Release and /review with /Docs', + 'workspace-1', + 'chat-1' + ) + expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('key-owner-1', 'workspace-1', [ + 'mcp-1', + ]) + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toEqual( + expect.objectContaining({ + context: [ + { type: 'workflow', content: '', path: 'workflows/release', tag: '@Release' }, + { type: 'skill', content: 'Review carefully', tag: '/review' }, + ], + mothershipTools: [ + { name: 'mcp_docs_search', description: 'Search docs', input_schema: {} }, + ], + }) + ) + }) + + it('unions inherited MCP ids with this turn while expanding only explicit contexts', async () => { + const contexts = [ + { kind: 'skill' as const, skillId: 'skill-1', label: 'review' }, + { kind: 'mcp' as const, serverId: 'mcp-current', label: 'Current' }, + ] + + await runWorkspaceChat({ + prompt: 'Continue with /review and /Current', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + contexts, + mcpServerIds: ['mcp-history', 'mcp-current'], + }) + + expect(mockProcessContextsServer).toHaveBeenCalledWith( + contexts, + 'key-owner-1', + 'Continue with /review and /Current', + 'workspace-1', + 'chat-1' + ) + expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('key-owner-1', 'workspace-1', [ + 'mcp-history', + 'mcp-current', + ]) + }) + + it('drops MCP contexts and tools from secretless requests', async () => { + const workflow = { + kind: 'workflow' as const, + workflowId: 'workflow-1', + label: 'Release', + } + await runWorkspaceChat({ + prompt: 'Inspect @Release with /Docs', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + contexts: [workflow, { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }], + mcpServerIds: ['mcp-history'], + }) + + expect(mockProcessContextsServer).toHaveBeenCalledWith( + [workflow], + 'key-owner-1', + 'Inspect @Release with /Docs', + 'workspace-1', + 'chat-1' + ) + expect(mockBuildTaggedMcpToolSchemas).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).not.toHaveProperty('mothershipTools') + }) + + it('keeps shared workspace credentials out of personal environment, integrations, and memory', async () => { + await runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + sharedWorkspaceCredential: true, + }) + + expect(mockPrepareCopilotEnvironmentContext).not.toHaveBeenCalled() + expect(mockCreateCopilotEnvironmentContext).toHaveBeenCalledWith('key-owner-1', 'workspace-1', { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + }) + expect(mockBuildIntegrationToolSchemas).not.toHaveBeenCalled() + expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledWith('workspace-1', 'key-owner-1', { + workspaceAccess: { permission: 'admin' }, + secretless: true, + }) + + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + expect(payload).toMatchObject({ + userId: 'billing-actor', + userPermission: 'admin', + disableUserMemory: true, + }) + expect(payload).not.toHaveProperty('queryOnly') + expect(payload).not.toHaveProperty('integrationTools') + expect(options).toMatchObject({ + userId: 'billing-actor', + authorizationUserId: 'key-owner-1', + secretActorUserId: null, + secretMountPolicy: { secretScope: 'selected', mountedSecrets: [] }, + environmentContext: { resolvedSecretTraceRegistry: { kind: 'empty-registry' } }, + }) + }) + + it('authorizes before reading workspace context or resolving runtime state', async () => { + let resolveAccess: ((value: { permission: string }) => void) | undefined + mockAssertActiveWorkspaceAccess.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + + const pending = runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + }) + + expect(mockGenerateWorkspaceSnapshot).not.toHaveBeenCalled() + expect(mockComputeWorkspaceEntitlements).not.toHaveBeenCalled() + expect(mockPrepareCopilotEnvironmentContext).not.toHaveBeenCalled() + expect(mockBuildIntegrationToolSchemas).not.toHaveBeenCalled() + + resolveAccess?.({ permission: 'admin' }) + await pending + }) + + it('does not start the Go leg when cancellation wins during workspace preparation', async () => { + const abortController = new AbortController() + let resolveSnapshot!: (value: { + markdown: string + snapshot: { workspace: { id: string; name: string; ownerId: string } } + }) => void + mockGenerateWorkspaceSnapshot.mockReturnValueOnce( + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + + const pending = runWorkspaceChat({ + prompt: 'Fix the workflow', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + abortSignal: abortController.signal, + }) + await vi.waitFor(() => expect(mockGenerateWorkspaceSnapshot).toHaveBeenCalledTimes(1)) + + abortController.abort('test cancellation') + resolveSnapshot({ + markdown: 'workspace markdown', + snapshot: { workspace: { id: 'workspace-1', name: 'Acme', ownerId: 'owner-1' } }, + }) + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('fails rather than asking without a workspace snapshot', async () => { + mockGenerateWorkspaceSnapshot.mockResolvedValueOnce(null) + + await expect( + runWorkspaceChat({ + prompt: 'hello', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + }) + ).rejects.toThrow('Workspace context is unavailable') + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('passes validated inline attachments without exposing storage paths or URLs', async () => { + const fileAttachments = [ + { + type: 'document' as const, + filename: 'notes.txt', + source: { + type: 'base64' as const, + media_type: 'text/plain', + data: 'aGk=', + }, + }, + ] + + await runWorkspaceChat({ + prompt: 'Read this', + authorizationUserId: 'key-owner-1', + actorUserId: 'billing-actor', + workspaceId: 'workspace-1', + chatId: 'chat-1', + messageId: 'message-1', + requestId: 'request-1', + billingAttribution, + readOnly: true, + fileAttachments, + }) + + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toEqual( + expect.objectContaining({ fileAttachments }) + ) + }) +}) + +describe('toPublicChatResult', () => { + it('exposes only final content, opaque continuation token, and token usage', () => { + expect( + toPublicChatResult( + { + success: true, + content: 'answer', + contentBlocks: [{ type: 'thinking', content: 'private', timestamp: 1 }], + toolCalls: [{ id: 'tool-1', name: 'read', status: 'success' }], + usage: { prompt: 12, completion: 3 }, + cost: { input: 1, output: 2, total: 3 }, + }, + 'continuation-token-1' + ) + ).toEqual({ + content: 'answer', + continuationToken: 'continuation-token-1', + usage: { prompt: 12, completion: 3, total: 15 }, + }) + }) +}) + +describe('publicChatUsageLimitMessage', () => { + it('turns the interactive upgrade tag back into a public error message', () => { + expect( + publicChatUsageLimitMessage( + '{"reason":"usage_limit","action":"increase_limit","message":"Ask an org admin."}' + ) + ).toBe('Ask an org admin.') + expect(publicChatUsageLimitMessage('bad json')).toBe( + 'Usage limit exceeded' + ) + expect(publicChatUsageLimitMessage('ordinary answer')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/headless/workspace-chat.ts b/apps/sim/lib/copilot/headless/workspace-chat.ts new file mode 100644 index 00000000000..ba1f3cd9f43 --- /dev/null +++ b/apps/sim/lib/copilot/headless/workspace-chat.ts @@ -0,0 +1,262 @@ +import type { V2ChatContext } from '@/lib/api/contracts/v2/chat' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { processContextsServer } from '@/lib/copilot/chat/process-contents' +import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +import { + createCopilotEnvironmentContext, + prepareCopilotEnvironmentContext, +} from '@/lib/copilot/environment-context' +import type { MothershipInlineFileAttachment } from '@/lib/copilot/headless/attachments' +import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' +import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' +import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' +import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' +import type { EnvironmentResolutionSnapshot } from '@/lib/environment/utils' +import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const EMPTY_ENVIRONMENT: EnvironmentResolutionSnapshot = { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], +} + +function throwIfWorkspaceChatAborted( + input: Pick +) { + if (!input.abortSignal?.aborted && !input.userStopSignal?.aborted) return + const error = new Error('Chat request cancelled') + error.name = 'AbortError' + throw error +} + +export interface WorkspaceChatInput { + prompt: string + authorizationUserId: string + actorUserId: string + workspaceId: string + chatId: string + messageId: string + requestId: string + executionId?: string + runId?: string + billingAttribution: BillingAttributionSnapshot + /** Explicit safety mode. Normal Mothership capabilities are the default. */ + readOnly?: boolean + /** Shared workspace credentials never inherit their creator's personal runtime state. */ + sharedWorkspaceCredential?: boolean + fileAttachments?: MothershipInlineFileAttachment[] + /** Identity-bearing `@` resources and `/` skill/MCP tags for this turn. */ + contexts?: V2ChatContext[] + /** MCP servers explicitly tagged on earlier persisted turns. */ + mcpServerIds?: string[] + abortSignal?: AbortSignal + /** Stops local Sim work without cancelling the active Go stream transport. */ + userStopSignal?: AbortSignal + /** Signals that Go accepted and early-persisted the initial turn. */ + onInitialStreamAccepted?: () => void + onEvent?: (event: StreamEvent) => void | Promise + onComplete?: (result: OrchestratorResult) => void | Promise + onError?: (error: Error, result?: OrchestratorResult) => void | Promise +} + +/** + * Runs the public CLI workspace-chat surface with the normal Mothership + * capability set, or its explicit query-only projection. + * + * The caller has already authenticated and authorized the requested workspace. + * `authorizationUserId` remains the principal whose current membership governs + * workspace access. `actorUserId` is deliberately separate: personal keys use + * that same principal while workspace keys use the workspace billing account as + * the system actor. Local tool execution is projected back onto the + * authorization principal while billing remains frozen to `actorUserId`. + * + * Query-only is opt-in and gets an empty secret catalog plus the subtractive Go + * tool policy. Personal credentials in normal mode mirror workspace Mothership. + * Shared workspace credentials remain fully workspace-authorized but cannot + * inherit their creator's personal environment, integrations, or memory. + */ +export async function runWorkspaceChat(input: WorkspaceChatInput): Promise { + throwIfWorkspaceChatAborted(input) + const readOnly = input.readOnly === true + const secretless = readOnly || input.sharedWorkspaceCredential === true + // MCP execution depends on user-held credentials, which read-only and shared + // workspace credentials deliberately cannot inherit. + const contexts = (input.contexts ?? []).filter((context) => !secretless || context.kind !== 'mcp') + const mcpServerIds = secretless + ? [] + : Array.from( + new Set([ + ...(input.mcpServerIds ?? []), + ...contexts.flatMap((context) => (context.kind === 'mcp' ? [context.serverId] : [])), + ]) + ) + + /** + * Keep this authorization barrier ahead of every workspace/context read. The + * route also checks access, but this helper must fail closed on its own. + */ + const workspaceAccess = await assertActiveWorkspaceAccess( + input.workspaceId, + input.authorizationUserId + ) + throwIfWorkspaceChatAborted(input) + const [ + workspaceSnapshot, + entitlements, + environmentContext, + integrationTools, + agentContexts, + mothershipTools, + ] = await Promise.all([ + generateWorkspaceSnapshot(input.workspaceId, input.authorizationUserId, { + workspaceAccess, + secretless, + }), + computeWorkspaceEntitlements(input.workspaceId, input.authorizationUserId), + secretless + ? createCopilotEnvironmentContext( + input.authorizationUserId, + input.workspaceId, + EMPTY_ENVIRONMENT + ) + : prepareCopilotEnvironmentContext(input.authorizationUserId, input.workspaceId), + secretless + ? Promise.resolve([]) + : buildIntegrationToolSchemas( + input.authorizationUserId, + input.messageId, + { schemaSurface: 'copilot' }, + input.workspaceId + ), + processContextsServer( + contexts, + input.authorizationUserId, + input.prompt, + input.workspaceId, + input.chatId + ), + secretless + ? Promise.resolve([]) + : buildTaggedMcpToolSchemas(input.authorizationUserId, input.workspaceId, mcpServerIds), + ]) + throwIfWorkspaceChatAborted(input) + + if (!workspaceSnapshot) { + throw new Error('Workspace context is unavailable') + } + const userPermission = readOnly ? 'read' : workspaceAccess.permission + if (!userPermission) { + // `assertActiveWorkspaceAccess` should make this unreachable, but fail + // closed if its access/permission invariants ever drift apart. + throw new Error('Workspace permission is unavailable') + } + + const requestPayload: Record = { + message: input.prompt, + userId: input.actorUserId, + workspaceId: input.workspaceId, + chatId: input.chatId, + messageId: input.messageId, + mode: 'agent', + ...(readOnly ? { queryOnly: true } : {}), + ...(secretless ? { disableUserMemory: true } : {}), + ...(input.fileAttachments?.length ? { fileAttachments: input.fileAttachments } : {}), + ...(agentContexts.length ? { context: agentContexts } : {}), + workspaceContext: workspaceSnapshot.markdown, + vfs: workspaceSnapshot.snapshot, + userPermission, + ...(entitlements.length > 0 ? { entitlements } : {}), + ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(mothershipTools.length > 0 ? { mothershipTools } : {}), + ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), + isHosted, + } + + return runHeadlessCopilotLifecycle(requestPayload, { + userId: input.actorUserId, + authorizationUserId: input.authorizationUserId, + workspaceId: input.workspaceId, + chatId: input.chatId, + executionId: input.executionId, + runId: input.runId, + // This wrapper owns Sim run creation. Synced calls arrive with route-created + // ids; Go-only/workspace-key chats intentionally have no Sim parent row. + autoCreateRunIdentity: false, + simRequestId: input.requestId, + // This policy-aware route intentionally fails closed against an older Go + // task that would ignore queryOnly/disableUserMemory during a mixed deploy. + goRoute: '/api/mothership/v2-chat', + resumeRoute: '/api/tools/v2-chat/resume', + autoExecuteTools: true, + interactive: false, + abortSignal: input.abortSignal, + userStopSignal: input.userStopSignal, + billingAttribution: input.billingAttribution, + userPermission, + ...(secretless + ? { + secretActorUserId: null, + secretMountPolicy: { secretScope: 'selected' as const, mountedSecrets: [] }, + } + : { secretActorUserId: input.authorizationUserId }), + environmentContext, + ...(input.onInitialStreamAccepted + ? { onInitialStreamAccepted: input.onInitialStreamAccepted } + : {}), + onEvent: input.onEvent, + onComplete: input.onComplete, + onError: input.onError, + }) +} + +export interface PublicChatResult { + content: string + continuationToken: string + usage: { + prompt?: number + completion?: number + total?: number + } +} + +/** + * The lifecycle turns an upstream 402 into the UI's synthetic usage tag so an + * interactive browser can render an upgrade card. A public stream has no such + * renderer; recover the message and expose it as a normal v2 stream error. + */ +export function publicChatUsageLimitMessage(content: string): string | null { + const match = /^\s*([\s\S]+)<\/usage_upgrade>\s*$/.exec(content) + if (!match) return null + try { + const payload = JSON.parse(match[1]) as { message?: unknown } + return typeof payload.message === 'string' && payload.message.trim() + ? payload.message + : 'Usage limit exceeded' + } catch { + return 'Usage limit exceeded' + } +} + +/** Projects the internal result onto the intentionally small public surface. */ +export function toPublicChatResult( + result: OrchestratorResult, + continuationToken: string +): PublicChatResult { + return { + content: result.content, + continuationToken, + usage: result.usage + ? { + prompt: result.usage.prompt, + completion: result.usage.completion, + total: result.usage.prompt + result.usage.completion, + } + : {}, + } +} diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index 1fd556a76bf..ceefb46bfb1 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -17,6 +17,7 @@ export function createStreamingContext(overrides?: Partial): S contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), + inFlightToolExecutions: new Map(), currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index efa9d8ef7d8..cdf051e5933 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -519,6 +519,42 @@ describe('copilot go stream helpers', () => { expect(fetch).toHaveBeenCalledTimes(1) }) + it('reports acceptance only after an OK response exposes its stream body', async () => { + const complete = createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + }) + const onAccepted = vi.fn() + vi.mocked(fetch).mockResolvedValueOnce(createSseResponse([complete])) + + await runStreamLoop( + 'https://example.com/mothership/stream', + {}, + createStreamingContext(), + { userId: 'user-1', workflowId: 'workflow-1' }, + { timeout: 1000, onAccepted } + ) + + expect(onAccepted).toHaveBeenCalledOnce() + + onAccepted.mockClear() + vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 })) + await expect( + runStreamLoop( + 'https://example.com/mothership/stream', + {}, + createStreamingContext(), + { userId: 'user-1', workflowId: 'workflow-1' }, + { timeout: 1000, onAccepted } + ) + ).rejects.toThrow('Copilot backend error') + expect(onAccepted).not.toHaveBeenCalled() + }) + it('does not retry non-transient backend statuses before the SSE stream opens', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 })) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 471904c16fe..833c490df8c 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -134,6 +134,8 @@ export interface StreamLoopOptions extends OrchestratorOptions { * Called when the Go backend's trace ID (go_trace_id) is first received via SSE. */ onGoTraceId?: (goTraceId: string) => void + /** Called once the upstream accepted this leg and exposed an SSE body. */ + onAccepted?: () => void otelContext?: Context } @@ -209,6 +211,8 @@ export async function runStreamLoop( throw new CopilotBackendError('Copilot backend response missing body') } + options.onAccepted?.() + context.trace.endSpan(fetchSpan) const bodySpan = context.trace.startSpan(`SSE Body → ${pathname}`, 'sim.http.stream_body', { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index b98cbb79438..d8adfb16857 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -4,6 +4,7 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TOOL_WATCHDOG_DEFAULT_MS } from '@/lib/copilot/constants' import { TraceCollector } from '@/lib/copilot/request/trace' const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApproval } = vi.hoisted( @@ -15,11 +16,13 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), -})) +const { upsertAsyncToolCall, getAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = + vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + getAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), + })) const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = vi.hoisted(() => ({ @@ -47,7 +50,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ getLatestRunForStream: vi.fn(), getRunSegment: vi.fn(), createRunCheckpoint: vi.fn(), - getAsyncToolCall: vi.fn(), + getAsyncToolCall, markAsyncToolStatus: vi.fn(), listAsyncToolCallsForRun: vi.fn(), getAsyncToolCalls: vi.fn(), @@ -86,6 +89,7 @@ import { sseHandlers, subAgentHandlers, } from '@/lib/copilot/request/handlers' +import { cancelToolCallAndReport, executeToolAndReport } from '@/lib/copilot/request/tools/executor' import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -97,6 +101,7 @@ describe('sse-handlers tool lifecycle', () => { vi.clearAllMocks() isSimExecuted.mockReturnValue(true) upsertAsyncToolCall.mockResolvedValue(null) + getAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) waitForToolCompletion.mockResolvedValue(null) @@ -1281,6 +1286,102 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.error).toBe('Request aborted during tool execution') }) + it('creates and terminalizes the durable row when Stop wins before normal persistence', async () => { + context.runId = 'run-stop' + context.toolCalls.set('tool-stop', { + id: 'tool-stop', + name: ReadTool.id, + params: { path: 'WORKSPACE.md' }, + status: 'executing', + }) + + await cancelToolCallAndReport('tool-stop', context) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith({ + runId: 'run-stop', + toolCallId: 'tool-stop', + toolName: ReadTool.id, + args: { path: 'WORKSPACE.md' }, + }) + expect(completeAsyncToolCall).toHaveBeenCalledWith({ + toolCallId: 'tool-stop', + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Stopped by user', + }) + expect(context.toolCalls.get('tool-stop')).toEqual( + expect.objectContaining({ + status: MothershipStreamV1ToolOutcome.cancelled, + result: { success: false }, + error: 'Stopped by user', + endTime: expect.any(Number), + }) + ) + }) + + it('does not execute after a durable cancellation wins the running transition', async () => { + context.runId = 'run-stop' + context.toolCalls.set('tool-stop', { + id: 'tool-stop', + name: ReadTool.id, + params: { path: 'WORKSPACE.md' }, + status: 'pending', + }) + getAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-stop', + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Stopped by user', + }) + + const completion = await executeToolAndReport('tool-stop', context, execContext) + + expect(executeTool).not.toHaveBeenCalled() + expect(completion.status).toBe(MothershipStreamV1ToolOutcome.cancelled) + expect(context.toolCalls.get('tool-stop')).toEqual( + expect.objectContaining({ + status: MothershipStreamV1ToolOutcome.cancelled, + error: 'Stopped by user', + }) + ) + }) + + it('keeps a watchdog-timed-out raw handler tracked until it actually settles', async () => { + vi.useFakeTimers() + try { + let settleRawExecution!: (value: { success: boolean; output: { ok: boolean } }) => void + executeTool.mockReturnValueOnce( + new Promise((resolve) => { + settleRawExecution = resolve + }) + ) + markAsyncToolRunning.mockResolvedValueOnce({ + toolCallId: 'tool-timeout', + status: MothershipStreamV1AsyncToolRecordStatus.running, + }) + context.toolCalls.set('tool-timeout', { + id: 'tool-timeout', + name: ReadTool.id, + params: { path: 'WORKSPACE.md' }, + status: 'pending', + }) + + const reported = executeToolAndReport('tool-timeout', context, execContext) + await vi.waitFor(() => expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(true)) + + await vi.advanceTimersByTimeAsync(TOOL_WATCHDOG_DEFAULT_MS) + await reported + expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(true) + + settleRawExecution({ success: true, output: { ok: true } }) + await vi.waitFor(() => + expect(context.inFlightToolExecutions?.has('tool-timeout')).toBe(false) + ) + } finally { + vi.useRealTimers() + } + }) + it('does not replace an in-flight pending promise on duplicate tool_call', async () => { let resolveTool: ((value: { success: boolean; output: { ok: boolean } }) => void) | undefined executeTool.mockImplementationOnce( diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 0e5172280a9..28a0f1b4f0c 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -55,14 +55,15 @@ export async function runHeadlessCopilotLifecycle( }) outcome = result.success ? RequestTraceV1Outcome.success - : options.abortSignal?.aborted || result.cancelled + : options.userStopSignal?.aborted || options.abortSignal?.aborted || result.cancelled ? RequestTraceV1Outcome.cancelled : RequestTraceV1Outcome.error return result } catch (error) { - outcome = options.abortSignal?.aborted - ? RequestTraceV1Outcome.cancelled - : RequestTraceV1Outcome.error + outcome = + options.userStopSignal?.aborted || options.abortSignal?.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error throw error } finally { trace.endSpan( diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index 68fb457f076..a29590fd441 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -40,6 +40,7 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.contentBlocks).toBe(base.contentBlocks) expect(leg.toolCalls).toBe(base.toolCalls) expect(leg.pendingToolPromises).toBe(base.pendingToolPromises) + expect(leg.inFlightToolExecutions).toBe(base.inFlightToolExecutions) expect(leg.subAgentContent).toBe(base.subAgentContent) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 15c328f8904..c05fa7a76f5 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -11,6 +11,7 @@ afterAll(resetEnvironmentUtilsMock) const { mockCreateRunSegment, + mockCancelToolCallAndReport, mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, @@ -24,6 +25,7 @@ const { mockEnv, } = vi.hoisted(() => ({ mockCreateRunSegment: vi.fn(), + mockCancelToolCallAndReport: vi.fn(), mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), @@ -124,6 +126,7 @@ vi.mock('@/lib/copilot/request/tools/billing', () => ({ })) vi.mock('@/lib/copilot/request/tools/executor', () => ({ + cancelToolCallAndReport: mockCancelToolCallAndReport, executeToolAndReport: vi.fn(), forceFailHungToolCall: mockForceFailHungToolCall, pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs, @@ -165,6 +168,78 @@ describe('runCopilotLifecycle', () => { mockPrepareCopilotEnvironmentContext.mockResolvedValue({ resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), }) + mockCancelToolCallAndReport.mockImplementation( + async (toolCallId: string, context: StreamingContext, message = 'Stopped by user') => { + const tool = context.toolCalls.get(toolCallId) + if (!tool) return + tool.status = MothershipStreamV1ToolOutcome.cancelled + tool.endTime = Date.now() + tool.result = { success: false } + tool.error = message + } + ) + }) + + it('does not create a Sim run for a transport-only chat', async () => { + let captured: StreamingContext | undefined + mockRunStreamLoop.mockImplementationOnce(async (_url, _request, context) => { + captured = context + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-go-only' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'go-only-chat', + autoCreateRunIdentity: false, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'go-only-chat', + }, + } + ) + + expect(mockCreateRunSegment).not.toHaveBeenCalled() + expect(captured).toMatchObject({ chatId: 'go-only-chat' }) + expect(captured?.executionId).toBeUndefined() + expect(captured?.runId).toBeUndefined() + }) + + it('still creates a run identity by default for a persisted headless chat', async () => { + let captured: StreamingContext | undefined + mockCreateRunSegment.mockResolvedValueOnce({ id: 'created' }) + mockRunStreamLoop.mockImplementationOnce(async (_url, _request, context) => { + captured = context + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-persisted' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'persisted-chat', + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'persisted-chat', + }, + } + ) + + expect(mockCreateRunSegment).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'persisted-chat', + streamId: 'stream-persisted', + requestContext: { source: 'headless_lifecycle' }, + }) + ) + const created = mockCreateRunSegment.mock.calls[0][0] + expect(captured?.executionId).toBe(created.executionId) + expect(captured?.runId).toBe(created.id) }) it('threads trace provenance through server execution context only', async () => { @@ -204,6 +279,39 @@ describe('runCopilotLifecycle', () => { expect(executionContext).not.toHaveProperty('resolvedSecretTraceRegistry') }) + it('routes projected workspace actors through the authorization user environment', async () => { + let requestBody: Record | undefined + mockRunStreamLoop.mockImplementationOnce( + async (_url: string, request: RequestInit): Promise => { + requestBody = JSON.parse(String(request.body)) + } + ) + + await runCopilotLifecycle( + { + message: 'hello', + messageId: 'stream-workspace-key', + userId: 'workspace-billing-actor', + }, + { + userId: 'workspace-billing-actor', + authorizationUserId: 'workspace-key-owner', + workspaceId: 'ws-1', + executionContext: { + userId: 'workspace-billing-actor', + authorizationUserId: 'workspace-key-owner', + workflowId: '', + workspaceId: 'ws-1', + }, + } + ) + + expect(mockGetMothershipBaseURL).toHaveBeenCalledWith({ + userId: 'workspace-key-owner', + }) + expect(requestBody?.userId).toBe('workspace-billing-actor') + }) + it.each([ { goRoute: undefined, expected: 'mothership' }, { goRoute: '/api/copilot', expected: 'mothership' }, @@ -1742,6 +1850,98 @@ describe('runCopilotLifecycle', () => { ) }) + it('uses a caller-supplied resume route for async tool checkpoints', async () => { + const fetchUrls: string[] = [] + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + } + + mockRunStreamLoop.mockImplementationOnce( + async (fetchUrl: string, _fetchOptions: RequestInit, context: StreamingContext) => { + fetchUrls.push(fetchUrl) + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + mockRunStreamLoop.mockImplementationOnce(async (fetchUrl: string) => { + fetchUrls.push(fetchUrl) + }) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + workflowId: 'workflow-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + resumeRoute: '/api/tools/v2-chat/resume', + } + ) + + expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/v2-chat/resume') + }) + + it('reports initial stream acceptance once and never from resume legs', async () => { + const onInitialStreamAccepted = vi.fn() + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext, + _execContext: ExecutionContext, + options: { onAccepted?: () => void } + ) => { + options.onAccepted?.() + options.onAccepted?.() + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: [], + } + } + ) + mockRunStreamLoop.mockResolvedValueOnce(undefined) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + workflowId: 'workflow-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext: { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + }, + onInitialStreamAccepted, + } + ) + + expect(mockRunStreamLoop.mock.calls[0]?.[4]).toEqual( + expect.objectContaining({ onAccepted: expect.any(Function) }) + ) + expect(onInitialStreamAccepted).toHaveBeenCalledOnce() + expect(mockRunStreamLoop.mock.calls[1]?.[4]).not.toHaveProperty('onAccepted') + }) + it('finalizes as success when a resume fails with a retryable error then the retry succeeds', async () => { const executionContext: ExecutionContext = { userId: 'user-1', @@ -2050,6 +2250,106 @@ describe('runCopilotLifecycle', () => { expect(result.errors).toEqual(['The provider is overloaded']) }) + it('stops a pending Sim tool without aborting the active Go transport or resuming', async () => { + const transportController = new AbortController() + const userStopController = new AbortController() + const onComplete = vi.fn() + const onError = vi.fn() + let capturedContext: StreamingContext | undefined + let capturedExecutionContext: ExecutionContext | undefined + let settleTool!: () => void + let settleRawExecution!: () => void + const pendingTool = new Promise((resolve) => { + settleTool = resolve + }) + const rawExecution = new Promise((resolve) => { + settleRawExecution = resolve + }) + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext, + executionContext: ExecutionContext + ): Promise => { + capturedContext = context + capturedExecutionContext = executionContext + context.toolCalls.set('tool-running', { + id: 'tool-running', + name: 'read', + status: 'executing', + }) + context.pendingToolPromises.set('tool-running', pendingTool) + context.inFlightToolExecutions = new Map([['tool-running', rawExecution]]) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-running'], + } + } + ) + + const lifecycle = runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + abortSignal: transportController.signal, + userStopSignal: userStopController.signal, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + }, + onComplete, + onError, + } + ) + + await vi.waitFor(() => expect(mockRunStreamLoop).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + let lifecycleSettled = false + void lifecycle.finally(() => { + lifecycleSettled = true + }) + userStopController.abort('submit') + await new Promise((resolve) => setImmediate(resolve)) + + // Stop reaches the tool immediately, but the turn keeps its lease until + // that handler has actually unwound. + expect(capturedExecutionContext?.abortSignal?.aborted).toBe(true) + expect(lifecycleSettled).toBe(false) + settleTool() + await new Promise((resolve) => setImmediate(resolve)) + expect(lifecycleSettled).toBe(false) + settleRawExecution() + + const result = await lifecycle + + expect(transportController.signal.aborted).toBe(false) + expect(capturedExecutionContext?.abortSignal?.aborted).toBe(true) + expect(capturedExecutionContext?.userStopSignal).toBe(userStopController.signal) + expect(mockRunStreamLoop).toHaveBeenCalledTimes(1) + expect(mockCancelToolCallAndReport).toHaveBeenCalledWith('tool-running', capturedContext) + expect(capturedContext?.awaitingAsyncContinuation).toBeUndefined() + expect(capturedContext?.toolCalls.get('tool-running')).toEqual( + expect.objectContaining({ + status: MothershipStreamV1ToolOutcome.cancelled, + error: 'Stopped by user', + result: { success: false }, + }) + ) + expect(result).toEqual(expect.objectContaining({ success: false, cancelled: true })) + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ success: false, cancelled: true }) + ) + expect(onError).not.toHaveBeenCalled() + }) + it('force-fails a hung tool promise and resumes with an error result instead of wedging', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 42160ffe4e7..60fcf011885 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -54,10 +54,10 @@ import { import { getToolCallTerminalData, requireToolCallStateResult, - setTerminalToolCallState, } from '@/lib/copilot/request/tool-call-state' import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' import { + cancelToolCallAndReport, executeToolAndReport, forceFailHungToolCall, pendingToolWaitBudgetMs, @@ -757,6 +757,13 @@ function nonBlankString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined } +function combineAbortSignals(...signals: Array): AbortSignal | undefined { + const activeSignals = [...new Set(signals.filter((signal): signal is AbortSignal => !!signal))] + if (activeSignals.length === 0) return undefined + if (activeSignals.length === 1) return activeSignals[0] + return AbortSignal.any(activeSignals) +} + function resultContent(context: StreamingContext, options: CopilotLifecycleOptions): string { if (options.interactive === false && context.sawMainToolCall) { return context.finalAssistantContent @@ -766,16 +773,26 @@ function resultContent(context: StreamingContext, options: CopilotLifecycleOptio export interface CopilotLifecycleOptions extends OrchestratorOptions { userId: string + authorizationUserId?: string workflowId?: string workspaceId?: string chatId?: string executionId?: string runId?: string + /** + * Defaults to true. Set false when `chatId` is transport-only and has no + * parent row in Sim's `copilot_chats` table, or when the caller owns run + * creation and supplies any persisted identity itself. + */ + autoCreateRunIdentity?: boolean goRoute?: string + resumeRoute?: string trace?: TraceCollector simRequestId?: string otelContext?: Context onGoTraceId?: (goTraceId: string) => void + /** Fires after Go accepts the initial stream, before any resume legs. */ + onInitialStreamAccepted?: () => void executionContext?: ExecutionContext billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry @@ -831,10 +848,12 @@ export async function runCopilotLifecycle( chatId, executionId, runId, + autoCreateRunIdentity: options.autoCreateRunIdentity, messageId: payloadMsgId, }) const resolvedExecutionId = runIdentity.executionId ?? executionId const resolvedRunId = runIdentity.runId ?? runId + const toolAbortSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) const lifecycleOptions: CopilotLifecycleOptions = { ...options, executionId: resolvedExecutionId, @@ -843,10 +862,14 @@ export async function runCopilotLifecycle( ? { executionContext: { ...options.executionContext, + ...(options.authorizationUserId + ? { authorizationUserId: options.authorizationUserId } + : {}), messageId: payloadMsgId, executionId: resolvedExecutionId, runId: resolvedRunId, - abortSignal: options.abortSignal, + abortSignal: toolAbortSignal, + userStopSignal: options.userStopSignal, billingAttribution: options.billingAttribution ?? options.executionContext.billingAttribution, ...(options.userPermission ? { userPermission: options.userPermission } : {}), @@ -866,12 +889,14 @@ export async function runCopilotLifecycle( lifecycleOptions.executionContext ?? (await buildExecutionContext(requestPayload, { userId, + authorizationUserId: lifecycleOptions.authorizationUserId, workflowId, workspaceId, chatId, executionId: resolvedExecutionId, runId: resolvedRunId, - abortSignal: lifecycleOptions.abortSignal, + abortSignal: toolAbortSignal, + userStopSignal: lifecycleOptions.userStopSignal, billingAttribution: lifecycleOptions.billingAttribution, resolvedSecretTraceRegistry: lifecycleOptions.resolvedSecretTraceRegistry, environmentContext: lifecycleOptions.environmentContext, @@ -971,12 +996,14 @@ export async function runCopilotLifecycle( return result } catch (error) { const err = toError(error) + const wasCancelled = isAborted(lifecycleOptions, context) // A CopilotBackendError carries the upstream HTTP status + body (e.g. a 5xx // from /api/tools/resume when an oversized tool result — a rendered-doc // image — is posted back). Log those so a client-side "Stream error" that // originates from a thrown backend leg (vs an `error` SSE event) is // explained, not just reduced to a message string. - logger.error('Copilot orchestration failed', { + const logFailure = wasCancelled ? logger.warn : logger.error + logFailure.call(logger, 'Copilot orchestration failed', { error: err.message, name: err.name, ...(error instanceof CopilotBackendError @@ -993,7 +1020,6 @@ export async function runCopilotLifecycle( // partial content can be appended. // Return `cancelled: true` so upstream classification stays // consistent with the success-path cancel result. - const wasCancelled = lifecycleOptions.abortSignal?.aborted ?? false // Preserve whatever streamed before the throw for both terminals. A thrown // backend error (as opposed to an `error` SSE event that lets the loop finish // normally) must still carry the partial assistant turn so onError can @@ -1079,7 +1105,7 @@ function mothershipRequestHeaders( // lockstep: every field reset here is folded back there, and nothing else on // StreamingContext is per-leg. Everything not listed is shared BY REFERENCE // across all concurrent legs (the one merged chat: contentBlocks, toolCalls, -// pendingToolPromises, subagent maps, etc.). The per-leg ISOLATED set: +// pendingToolPromises, inFlightToolExecutions, subagent maps, etc.). The per-leg ISOLATED set: // - streamComplete / awaitingAsyncContinuation: stream-control flags, so a // finished leg can't stop a sibling's read loop (reset only; not merged). // - accumulatedContent / finalAssistantContent / usage / cost: join-leg @@ -1123,13 +1149,62 @@ export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingC if (leg.completionStatus) context.completionStatus = leg.completionStatus } -async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise { +type PendingToolWaitOutcome = 'settled' | 'aborted' | 'timed_out' + +/** + * Waits for tool work to stop before the lifecycle releases its chat lease. + * Abort is remembered immediately, but the promise settles only after the + * in-flight handlers have unwound. This is the same stop barrier the web UI + * relies on: a queued turn must never overlap mutations from the stopped turn. + */ +function waitForPendingToolPromises( + promises: Iterable>, + abortSignal?: AbortSignal, + timeoutMs?: number +): Promise { + const pending = Array.from(promises) + if (pending.length === 0) return Promise.resolve('settled') + + return new Promise((resolve) => { + let finished = false + let abortObserved = abortSignal?.aborted ?? false + let timeoutId: ReturnType | undefined + const finish = (outcome: PendingToolWaitOutcome) => { + if (finished) return + finished = true + if (timeoutId !== undefined) clearTimeout(timeoutId) + abortSignal?.removeEventListener('abort', onAbort) + resolve(outcome) + } + const onAbort = () => { + abortObserved = true + // Once Stop fires, safety wins over the ordinary resume watchdog: keep + // the lease until the cancellation-aware handler has actually unwound. + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + timeoutId = undefined + } + } + + abortSignal?.addEventListener('abort', onAbort, { once: true }) + void Promise.allSettled(pending).then(() => finish(abortObserved ? 'aborted' : 'settled')) + if (timeoutMs !== undefined && !abortObserved) { + timeoutId = setTimeout(() => finish('timed_out'), timeoutMs) + } + }) +} + +async function waitForToolIds( + context: StreamingContext, + toolIds: string[], + abortSignal?: AbortSignal +): Promise { const promises: Promise[] = [] for (const id of toolIds) { const p = context.pendingToolPromises.get(id) if (p) promises.push(p) } - if (promises.length > 0) await Promise.allSettled(promises) + return waitForPendingToolPromises(promises, abortSignal) } function collectResultsForToolIds( @@ -1175,6 +1250,7 @@ async function runResumeLegWithRetry( hostedBillingRequest?: AttributedBillingRequestEnvelope ): Promise { let attempt = 0 + const stopSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) for (;;) { const errorsBeforeAttempt = leg.errors.length try { @@ -1191,6 +1267,7 @@ async function runResumeLegWithRetry( ) return } catch (error) { + if (isAborted(options, leg)) throw error if (isRetryableStreamError(error) && attempt < MAX_RESUME_ATTEMPTS - 1) { leg.errors.length = errorsBeforeAttempt attempt++ @@ -1201,7 +1278,8 @@ async function runResumeLegWithRetry( backoffMs: backoff, error: toError(error).message, }) - await sleepWithAbort(backoff, options.abortSignal) + await sleepWithAbort(backoff, stopSignal) + if (isAborted(options, leg)) return continue } throw error @@ -1233,18 +1311,20 @@ async function driveOneChildChain( if (!frame.checkpointId) return null let checkpointId = frame.checkpointId let toolIds = frame.pendingToolIds + const stopSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) for (;;) { if (isAborted(options, context)) return null - await waitForToolIds(context, toolIds) + const waitOutcome = await waitForToolIds(context, toolIds, stopSignal) + if (waitOutcome === 'aborted' || isAborted(options, context)) return null const registry = execContext.resolvedSecretTraceRegistry if (!registry) throw new CopilotModelContentProjectionError() const results = collectResultsForToolIds(context, toolIds, checkpointId, registry) const leg = makeResumeLegContext(context) await runResumeLegWithRetry( - `${baseURL}/api/tools/resume`, + `${baseURL}${options.resumeRoute ?? '/api/tools/resume'}`, { streamId: context.messageId, checkpointId, @@ -1348,6 +1428,10 @@ async function driveSubagentChains( }) ) ) + if (isAborted(options, context)) { + await cancelCheckpointWork(context) + return null + } if (firstError !== undefined) throw firstError return followOns.find((c): c is AsyncContinuation => !!c) ?? null } finally { @@ -1368,10 +1452,19 @@ async function runCheckpointLoop( hostedBillingRequest?: AttributedBillingRequestEnvelope ): Promise { let route = initialRoute + const resumeRoute = options.resumeRoute ?? '/api/tools/resume' let payload: Record = initialPayload let resumeAttempt = 0 const callerOnEvent = options.onEvent - const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId }) + let initialStreamAccepted = false + const stopSignal = combineAbortSignals(options.abortSignal, options.userStopSignal) + // Route by the identity that authorized this request, not by a projected + // billing actor. Workspace API keys deliberately execute under a system + // billing actor, but that actor's admin environment override must never + // redirect another key owner's Mothership traffic. + const mothershipBaseURL = await getMothershipBaseURL({ + userId: options.authorizationUserId ?? options.userId, + }) const lifecycleWorkspaceId = nonBlankString(options.workspaceId) // Go's auth middleware re-validates every Sim -> Go request by reading @@ -1390,11 +1483,10 @@ async function runCheckpointLoop( for (;;) { context.streamComplete = false - const isResume = route === '/api/tools/resume' + const isResume = route === resumeRoute if (isResume && isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1456,7 +1548,18 @@ async function runCheckpointLoop( }, context, execContext, - loopOptions + { + ...loopOptions, + ...(!isResume && !initialStreamAccepted && options.onInitialStreamAccepted + ? { + onAccepted: () => { + if (initialStreamAccepted) return + initialStreamAccepted = true + options.onInitialStreamAccepted?.() + }, + } + : {}), + } ) const streamStatus = isAborted(options, context) ? RequestTraceV1SpanStatus.cancelled @@ -1469,6 +1572,10 @@ async function runCheckpointLoop( } catch (streamError) { context.trace.endSpan(streamSpan, RequestTraceV1SpanStatus.error) context.trace.setActiveSpan(undefined) + if (isAborted(options, context)) { + await cancelCheckpointWork(context) + throw streamError + } if (streamError instanceof BillingLimitError) { await handleBillingLimitResponse(streamError.userId, context, execContext, options) break @@ -1489,7 +1596,7 @@ async function runCheckpointLoop( backoffMs: backoff, error: toError(streamError).message, }) - await sleepWithAbort(backoff, options.abortSignal) + await sleepWithAbort(backoff, stopSignal) continue } throw streamError @@ -1507,8 +1614,7 @@ async function runCheckpointLoop( }) if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1526,11 +1632,16 @@ async function runCheckpointLoop( let next: AsyncContinuation | null = continuation while (next && isPerSubagentContinuation(next)) { if (isAborted(options, context)) { - cancelPendingTools(context) + await cancelCheckpointWork(context) + next = null + break + } + const waitOutcome = await waitForToolIds(context, next.pendingToolCallIds, stopSignal) + if (waitOutcome === 'aborted') { + await cancelCheckpointWork(context) next = null break } - await waitForToolIds(context, next.pendingToolCallIds) next = await driveSubagentChains( next, context, @@ -1567,10 +1678,18 @@ async function runCheckpointLoop( pendingCount: context.pendingToolPromises.size, waitBudgetMs, }) - const settledInTime = await Promise.race([ - Promise.allSettled(context.pendingToolPromises.values()).then(() => true), - sleep(waitBudgetMs).then(() => false), - ]) + const waitOutcome = await waitForPendingToolPromises( + context.pendingToolPromises.values(), + stopSignal, + waitBudgetMs + ) + const settledInTime = waitOutcome === 'settled' + if (waitOutcome === 'aborted') { + waitSpan.attributes = { ...waitSpan.attributes, settledInTime: false, aborted: true } + context.trace.endSpan(waitSpan, RequestTraceV1SpanStatus.cancelled) + await cancelCheckpointWork(context) + break + } if (!settledInTime) { const hungToolCallIds = Array.from(context.pendingToolPromises.keys()) logger.error('Pending tool executions exceeded the resume wait budget; force-failing', { @@ -1592,8 +1711,7 @@ async function runCheckpointLoop( } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1613,16 +1731,20 @@ async function runCheckpointLoop( checkpointId: continuation.checkpointId, toolCallIds: undispatchedToolIds, }) - await Promise.allSettled( + const waitOutcome = await waitForPendingToolPromises( undispatchedToolIds.map((toolCallId) => executeToolAndReport(toolCallId, context, execContext, options) - ) + ), + stopSignal ) + if (waitOutcome === 'aborted') { + await cancelCheckpointWork(context) + break + } } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1634,8 +1756,7 @@ async function runCheckpointLoop( }> = [] for (const toolCallId of continuation.pendingToolCallIds) { if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } const tool = context.toolCalls.get(toolCallId) @@ -1663,8 +1784,7 @@ async function runCheckpointLoop( } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1677,7 +1797,7 @@ async function runCheckpointLoop( }) context.awaitingAsyncContinuation = undefined - route = '/api/tools/resume' + route = resumeRoute payload = { streamId: context.messageId, checkpointId: continuation.checkpointId, @@ -1687,8 +1807,7 @@ async function runCheckpointLoop( } if (isAborted(options, context)) { - cancelPendingTools(context) - context.awaitingAsyncContinuation = undefined + await cancelCheckpointWork(context) break } @@ -1709,12 +1828,14 @@ async function buildExecutionContext( requestPayload: Record, params: { userId: string + authorizationUserId?: string workflowId?: string workspaceId?: string chatId?: string executionId?: string runId?: string abortSignal?: AbortSignal + userStopSignal?: AbortSignal billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry environmentContext?: CopilotEnvironmentContext @@ -1725,12 +1846,14 @@ async function buildExecutionContext( ): Promise { const { userId, + authorizationUserId, workflowId, workspaceId, chatId, executionId, runId, abortSignal, + userStopSignal, billingAttribution, resolvedSecretTraceRegistry, environmentContext, @@ -1741,6 +1864,7 @@ async function buildExecutionContext( const userTimezone = typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined + const queryOnly = requestPayload?.queryOnly === true let execContext: ExecutionContext if (workflowId) { @@ -1763,7 +1887,9 @@ async function buildExecutionContext( } if (userTimezone) execContext.userTimezone = userTimezone + if (authorizationUserId) execContext.authorizationUserId = authorizationUserId execContext.copilotToolExecution = true + if (queryOnly) execContext.queryOnly = true if (requestMode) execContext.requestMode = requestMode if (userPermission) execContext.userPermission = userPermission execContext.messageId = @@ -1771,6 +1897,7 @@ async function buildExecutionContext( execContext.executionId = executionId execContext.runId = runId execContext.abortSignal = abortSignal + execContext.userStopSignal = userStopSignal if (billingAttribution) execContext.billingAttribution = billingAttribution if (resolvedSecretTraceRegistry) { execContext.resolvedSecretTraceRegistry = resolvedSecretTraceRegistry @@ -1788,9 +1915,10 @@ async function ensureHeadlessRunIdentity(input: { chatId?: string executionId?: string runId?: string + autoCreateRunIdentity?: boolean messageId: string }): Promise<{ executionId?: string; runId?: string }> { - if (!input.chatId || input.executionId || input.runId) { + if (input.autoCreateRunIdentity === false || !input.chatId || input.executionId || input.runId) { return { executionId: input.executionId, runId: input.runId, @@ -1861,22 +1989,36 @@ async function withByokEligibilityHint( } function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean { - return !!(options.abortSignal?.aborted || context.wasAborted) + return !!(options.abortSignal?.aborted || options.userStopSignal?.aborted || context.wasAborted) } -function cancelPendingTools(context: StreamingContext): void { - for (const [, toolCall] of context.toolCalls) { +async function cancelCheckpointWork(context: StreamingContext): Promise { + context.wasAborted = true + context.awaitingAsyncContinuation = undefined + + // The stop signal has already reached every tool context. Keep the chat + // lease until those handlers observe it and unwind, then durably terminalize + // any call that never reached its normal cancellation branch. + await Promise.allSettled([ + ...context.pendingToolPromises.values(), + ...(context.inFlightToolExecutions?.values() ?? []), + ]) + await cancelPendingTools(context) +} + +async function cancelPendingTools(context: StreamingContext): Promise { + const cancellations: Promise[] = [] + for (const [toolCallId, toolCall] of context.toolCalls) { if ( toolCall.status === 'pending' || toolCall.status === 'executing' || - toolCall.status === 'awaiting_approval' + toolCall.status === 'awaiting_approval' || + toolCall.status === MothershipStreamV1ToolOutcome.cancelled ) { - setTerminalToolCallState(toolCall, { - status: MothershipStreamV1ToolOutcome.cancelled, - error: 'Stopped by user', - }) + cancellations.push(cancelToolCallAndReport(toolCallId, context)) } } + await Promise.allSettled(cancellations) } /** diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index c3dcfcb02d8..6d77df3af4f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -5,7 +5,14 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import { resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { + dbChainMockFns, + flattenMockConditions, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, @@ -26,6 +33,8 @@ const { cleanupAbortMarker, hasAbortMarker, releasePendingChatStream, + registerActiveStream, + startAbortPoller, fetchGo, } = vi.hoisted(() => ({ runCopilotLifecycle: vi.fn(), @@ -40,6 +49,8 @@ const { cleanupAbortMarker: vi.fn(), hasAbortMarker: vi.fn(), releasePendingChatStream: vi.fn(), + registerActiveStream: vi.fn(), + startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), fetchGo: vi.fn(), })) @@ -77,9 +88,9 @@ vi.mock('@/lib/copilot/request/session', () => ({ cleanupAbortMarker, hasAbortMarker, releasePendingChatStream, - registerActiveStream: vi.fn(), + registerActiveStream, unregisterActiveStream: vi.fn(), - startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), + startAbortPoller, isExplicitStopReason: vi.fn().mockReturnValue(false), SSE_RESPONSE_HEADERS: {}, StreamWriter: vi.fn().mockImplementation( @@ -127,7 +138,7 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) -import { createSSEStream, requestChatTitle } from './start' +import { createSSEStream, fireTitleGeneration, requestChatTitle } from './start' async function drainStream(stream: ReadableStream) { const reader = stream.getReader() @@ -290,6 +301,48 @@ describe('createSSEStream terminal error handling', () => { ) }) + it('registers and forwards distinct transport and explicit-stop signals', async () => { + runCopilotLifecycle.mockResolvedValue({ + success: true, + content: 'OK', + contentBlocks: [], + toolCalls: [], + }) + + const stream = createSSEStream({ + requestPayload: { message: 'hello' }, + userId: 'user-1', + streamId: 'stream-signals', + executionId: 'exec-signals', + runId: 'run-signals', + currentChat: null, + isNewChat: false, + message: 'hello', + titleModel: 'gpt-5.4', + requestId: 'req-signals', + orchestrateOptions: {}, + }) + + const [, transportController, userStopController] = registerActiveStream.mock.calls[0] + expect(transportController).toBeInstanceOf(AbortController) + expect(userStopController).toBeInstanceOf(AbortController) + expect(userStopController).not.toBe(transportController) + + await drainStream(stream) + + expect(startAbortPoller).toHaveBeenCalledWith( + 'stream-signals', + transportController, + expect.objectContaining({ userStopController }) + ) + expect(runCopilotLifecycle.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + abortSignal: transportController.signal, + userStopSignal: userStopController.signal, + }) + ) + }) + it('passes an OTel context into the streaming lifecycle', async () => { let lifecycleTraceparent = '' runCopilotLifecycle.mockImplementation(async (_payload, options) => { @@ -424,3 +477,76 @@ describe('requestChatTitle billing protocol', () => { expect(headers['x-sim-billing-request-id']).toBeUndefined() }) }) + +describe('fireTitleGeneration rename ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isHosted: true }) + setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) + fetchGo.mockResolvedValue( + new Response(JSON.stringify({ title: 'Generated title' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + it('does not overwrite or publish over a title renamed while generation was running', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + const publish = vi.fn() + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + + fireTitleGeneration({ + chatId: 'chat-1', + currentChat: null, + isNewChat: true, + userId: 'user-1', + message: 'Investigate the incident', + titleModel: 'claude-opus-4.8', + workspaceId: 'workspace-1', + billingAttribution: BILLING_ATTRIBUTION, + requestId: 'request-1', + publisher: { publish }, + resolvedSecretTraceRegistry, + }) + + await vi.waitFor(() => expect(dbChainMockFns.returning).toHaveBeenCalledTimes(1)) + await new Promise((resolve) => setImmediate(resolve)) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ title: 'Generated title' }) + expect( + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).some( + (condition) => + condition.type === 'isNull' && condition.column === schemaMock.copilotChats.title + ) + ).toBe(true) + expect(publish).not.toHaveBeenCalled() + }) + + it('publishes the generated title when the null-title update wins', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-1' }]) + const publish = vi.fn() + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + + fireTitleGeneration({ + chatId: 'chat-1', + currentChat: null, + isNewChat: true, + userId: 'user-1', + message: 'Investigate the incident', + titleModel: 'claude-opus-4.8', + workspaceId: 'workspace-1', + billingAttribution: BILLING_ATTRIBUTION, + requestId: 'request-1', + publisher: { publish }, + resolvedSecretTraceRegistry, + }) + + await vi.waitFor(() => + expect(publish).toHaveBeenCalledWith({ + type: 'session', + payload: { kind: 'title', title: 'Generated title' }, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index fcda495a529..cdadeb0fcda 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, @@ -60,7 +60,7 @@ export { SSE_RESPONSE_HEADERS } const logger = createLogger('CopilotChatStreaming') -type CurrentChatSummary = { +export type CurrentChatSummary = { title?: string | null } | null @@ -120,7 +120,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS }) const abortController = new AbortController() - registerActiveStream(streamId, abortController) + const userStopController = new AbortController() + registerActiveStream(streamId, abortController, userStopController) const publisher = new StreamWriter({ streamId, chatId, requestId }) @@ -225,6 +226,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS const abortPoller = startAbortPoller(streamId, abortController, { requestId, chatId, + userStopController, }) publisher.startKeepalive() @@ -265,10 +267,14 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS simRequestId: requestId, otelContext, abortSignal: abortController.signal, + userStopSignal: userStopController.signal, onEvent: async (event) => { await publisher.publish(event) }, onAbortObserved: (reason) => { + if (isExplicitStopReason(reason) && !userStopController.signal.aborted) { + userStopController.abort(reason) + } if (!abortController.signal.aborted) { abortController.abort(reason) } @@ -429,7 +435,8 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS // Title generation (fire-and-forget side effect) // --------------------------------------------------------------------------- -function fireTitleGeneration(params: { +/** Starts the shared chat-title side effect without delaying the response stream. */ +export function fireTitleGeneration(params: { chatId?: string currentChat: CurrentChatSummary isNewChat: boolean @@ -440,7 +447,7 @@ function fireTitleGeneration(params: { workspaceId?: string billingAttribution?: BillingAttributionSnapshot requestId: string - publisher: StreamWriter + publisher: Pick otelContext?: Context resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry }): void { @@ -478,7 +485,12 @@ function fireTitleGeneration(params: { }) .then(async (title) => { if (!title) return - await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) + const [updated] = await db + .update(copilotChats) + .set({ title }) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.title))) + .returning({ id: copilotChats.id }) + if (!updated) return await publisher.publish({ type: MothershipStreamV1EventType.session, payload: { kind: MothershipStreamV1SessionKind.title, title }, diff --git a/apps/sim/lib/copilot/request/session/abort-reason.ts b/apps/sim/lib/copilot/request/session/abort-reason.ts index 8a6b281e2c0..791b92b711e 100644 --- a/apps/sim/lib/copilot/request/session/abort-reason.ts +++ b/apps/sim/lib/copilot/request/session/abort-reason.ts @@ -30,6 +30,8 @@ export const AbortReason = { MarkerObservedAtBodyClose: 'redis_abort_marker:body_close', /** Internal timeout on the outbound explicit-abort fetch to Go. */ ExplicitAbortFetchTimeout: 'timeout:go_explicit_abort_fetch', + /** This handler no longer owns the per-chat lease and must stop writing. */ + LockOwnershipLost: 'chat_stream_lock:ownership_lost', } as const export type AbortReasonValue = (typeof AbortReason)[keyof typeof AbortReason] diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/copilot/request/session/abort.test.ts index 2404b12dc5c..788eef3e833 100644 --- a/apps/sim/lib/copilot/request/session/abort.test.ts +++ b/apps/sim/lib/copilot/request/session/abort.test.ts @@ -22,12 +22,38 @@ vi.mock('@/lib/copilot/request/otel', () => ({ })) import { + abortActiveStream, acquirePendingChatStream, getChatStreamLockOwners, + registerActiveStream, releasePendingChatStream, startAbortPoller, } from '@/lib/copilot/request/session/abort' +describe('active stream cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fires both the transport and explicit-stop controllers', async () => { + const transportController = new AbortController() + const userStopController = new AbortController() + + registerActiveStream('stream-stop', transportController, userStopController) + + await expect(abortActiveStream('stream-stop')).resolves.toBe(true) + expect(mockWriteAbortMarker).toHaveBeenCalledWith('stream-stop') + expect(transportController.signal).toMatchObject({ + aborted: true, + reason: 'user_stop:abortActiveStream', + }) + expect(userStopController.signal).toMatchObject({ + aborted: true, + reason: 'user_stop:abortActiveStream', + }) + }) +}) + describe('startAbortPoller heartbeat', () => { beforeEach(() => { vi.clearAllMocks() @@ -104,6 +130,7 @@ describe('startAbortPoller heartbeat', () => { it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => { const controller = new AbortController() + const userStopController = new AbortController() const streamId = 'stream-order-1' let signalAbortedWhenMarkerCleared: boolean | null = null @@ -112,7 +139,7 @@ describe('startAbortPoller heartbeat', () => { }) mockHasAbortMarker.mockResolvedValueOnce(true) - const interval = startAbortPoller(streamId, controller, {}) + const interval = startAbortPoller(streamId, controller, { userStopController }) try { await vi.advanceTimersByTimeAsync(300) @@ -120,6 +147,10 @@ describe('startAbortPoller heartbeat', () => { expect(mockClearAbortMarker).toHaveBeenCalledWith(streamId) expect(signalAbortedWhenMarkerCleared).toBe(true) expect(controller.signal.aborted).toBe(true) + expect(userStopController.signal).toMatchObject({ + aborted: true, + reason: 'redis_abort_marker:poller', + }) } finally { clearInterval(interval) } @@ -143,18 +174,22 @@ describe('startAbortPoller heartbeat', () => { } }) - it('stops heartbeating after ownership is lost', async () => { + it('aborts the stale lifecycle and stops heartbeating after ownership is lost', async () => { const controller = new AbortController() + const userStopController = new AbortController() const streamId = 'stream-lost' const chatId = 'chat-lost' redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false) - const interval = startAbortPoller(streamId, controller, { chatId }) + const interval = startAbortPoller(streamId, controller, { chatId, userStopController }) try { await vi.advanceTimersByTimeAsync(21_000) expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + expect(controller.signal.aborted).toBe(true) + expect(controller.signal.reason).toBe('chat_stream_lock:ownership_lost') + expect(userStopController.signal.reason).toBe('chat_stream_lock:ownership_lost') await vi.advanceTimersByTimeAsync(60_000) expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) @@ -162,6 +197,30 @@ describe('startAbortPoller heartbeat', () => { clearInterval(interval) } }) + + it('does not overlap heartbeat extensions when Redis is slow', async () => { + const controller = new AbortController() + let resolveExtend!: (owned: boolean) => void + redisConfigMockFns.mockExtendLock.mockReturnValueOnce( + new Promise((resolve) => { + resolveExtend = resolve + }) + ) + + const interval = startAbortPoller('stream-slow', controller, { chatId: 'chat-slow' }) + try { + await vi.advanceTimersByTimeAsync(21_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1) + + resolveExtend(true) + await vi.advanceTimersByTimeAsync(1) + } finally { + clearInterval(interval) + } + }) }) describe('getChatStreamLockOwners', () => { diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/copilot/request/session/abort.ts index b081044f8eb..5483ddaf7ad 100644 --- a/apps/sim/lib/copilot/request/session/abort.ts +++ b/apps/sim/lib/copilot/request/session/abort.ts @@ -11,7 +11,12 @@ import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' const logger = createLogger('SessionAbort') -const activeStreams = new Map() +interface ActiveStreamEntry { + abortController: AbortController + userStopController: AbortController +} + +const activeStreams = new Map() const pendingChatStreams = new Map< string, { promise: Promise; resolve: () => void; streamId: string } @@ -60,8 +65,12 @@ function getChatStreamLockKey(chatId: string): string { return `copilot:chat-stream-lock:${chatId}` } -export function registerActiveStream(streamId: string, controller: AbortController): void { - activeStreams.set(streamId, controller) +export function registerActiveStream( + streamId: string, + abortController: AbortController, + userStopController: AbortController +): void { + activeStreams.set(streamId, { abortController, userStopController }) } export function unregisterActiveStream(streamId: string): void { @@ -285,12 +294,13 @@ export async function abortActiveStream(streamId: string): Promise { async (span) => { await writeAbortMarker(streamId) span.setAttribute(TraceAttr.CopilotAbortMarkerWritten, true) - const controller = activeStreams.get(streamId) - if (!controller) { + const entry = activeStreams.get(streamId) + if (!entry) { span.setAttribute(TraceAttr.CopilotAbortControllerFired, false) return false } - controller.abort(AbortReason.UserStop) + entry.userStopController.abort(AbortReason.UserStop) + entry.abortController.abort(AbortReason.UserStop) activeStreams.delete(streamId) span.setAttribute(TraceAttr.CopilotAbortControllerFired, true) return true @@ -326,11 +336,17 @@ const pollingStreams = new Set() export function startAbortPoller( streamId: string, abortController: AbortController, - options?: { pollMs?: number; requestId?: string; chatId?: string } + options?: { + pollMs?: number + requestId?: string + chatId?: string + userStopController?: AbortController + } ): ReturnType { const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS const requestId = options?.requestId const chatId = options?.chatId + const userStopController = options?.userStopController let lastHeartbeatAt = Date.now() let heartbeatOwnershipLost = false @@ -341,46 +357,60 @@ export function startAbortPoller( void (async () => { try { - const shouldAbort = await hasAbortMarker(streamId) - if (shouldAbort && !abortController.signal.aborted) { - abortController.abort(AbortReason.RedisPoller) - await clearAbortMarker(streamId) - } - } catch (error) { - logger.warn('Failed to poll stream abort marker', { - streamId, - ...(requestId ? { requestId } : {}), - error: toError(error).message, - }) - } finally { - pollingStreams.delete(streamId) - } - - if (!chatId || heartbeatOwnershipLost) return - if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return - - try { - const owned = await extendLock( - getChatStreamLockKey(chatId), - streamId, - CHAT_STREAM_LOCK_TTL_SECONDS - ) - lastHeartbeatAt = Date.now() - if (!owned) { - heartbeatOwnershipLost = true - logger.warn('Lost ownership of chat stream lock — stopping heartbeat', { - chatId, + try { + const shouldAbort = await hasAbortMarker(streamId) + if (shouldAbort && !abortController.signal.aborted) { + userStopController?.abort(AbortReason.RedisPoller) + abortController.abort(AbortReason.RedisPoller) + await clearAbortMarker(streamId) + } + } catch (error) { + logger.warn('Failed to poll stream abort marker', { streamId, ...(requestId ? { requestId } : {}), + error: toError(error).message, }) } - } catch (error) { - logger.warn('Failed to extend chat stream lock TTL', { - chatId, - streamId, - ...(requestId ? { requestId } : {}), - error: toError(error).message, - }) + + if ( + chatId && + !heartbeatOwnershipLost && + Date.now() - lastHeartbeatAt >= CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS + ) { + try { + const owned = await extendLock( + getChatStreamLockKey(chatId), + streamId, + CHAT_STREAM_LOCK_TTL_SECONDS + ) + lastHeartbeatAt = Date.now() + if (!owned) { + heartbeatOwnershipLost = true + if (!userStopController?.signal.aborted) { + userStopController?.abort(AbortReason.LockOwnershipLost) + } + if (!abortController.signal.aborted) { + abortController.abort(AbortReason.LockOwnershipLost) + } + logger.warn('Lost ownership of chat stream lock — aborting stale stream', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + }) + } + } catch (error) { + logger.warn('Failed to extend chat stream lock TTL', { + chatId, + streamId, + ...(requestId ? { requestId } : {}), + error: toError(error).message, + }) + } + } + } finally { + // Cover both marker polling and the (potentially slower) lock EVAL so + // the 250ms timer cannot overlap heartbeats for one stream. + pollingStreams.delete(streamId) } })() }, pollMs) diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts index 5cfcd9efadf..bd01932fab2 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.test.ts @@ -10,8 +10,9 @@ beforeAll(() => { afterAll(resetEnvMock) -const { mockFetchGo } = vi.hoisted(() => ({ +const { mockFetchGo, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockFetchGo: vi.fn(), + mockGetMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), })) vi.mock('@/lib/copilot/request/go/fetch', () => ({ @@ -19,7 +20,7 @@ vi.mock('@/lib/copilot/request/go/fetch', () => ({ })) vi.mock('@/lib/copilot/server/agent-url', () => ({ - getMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), + getMothershipBaseURL: mockGetMothershipBaseURL, getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({ 'X-Sim-Source-Env': 'test' }), })) @@ -48,4 +49,23 @@ describe('requestExplicitStreamAbort', () => { }) ) }) + + it('routes separately from the execution owner stamped into the abort body', async () => { + await requestExplicitStreamAbort({ + streamId: 'stream-1', + userId: 'workspace-billing-actor', + routingUserId: 'workspace-key-owner', + workspaceId: 'workspace-1', + }) + + expect(mockGetMothershipBaseURL).toHaveBeenCalledWith({ + userId: 'workspace-key-owner', + }) + const request = mockFetchGo.mock.calls[0]?.[1] as RequestInit + expect(JSON.parse(String(request.body))).toEqual({ + messageId: 'stream-1', + userId: 'workspace-billing-actor', + workspaceId: 'workspace-1', + }) + }) }) diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.ts b/apps/sim/lib/copilot/request/session/explicit-abort.ts index 37fe00f1343..b6021e1053b 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.ts @@ -13,7 +13,10 @@ export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000 export async function requestExplicitStreamAbort(params: { streamId: string + /** Authenticated execution/billing owner stamped into the Go request body. */ userId: string + /** Sim principal whose environment override selects the Mothership URL. */ + routingUserId?: string chatId?: string workspaceId?: string timeoutMs?: number @@ -22,6 +25,7 @@ export async function requestExplicitStreamAbort(params: { const { streamId, userId, + routingUserId, chatId, workspaceId, timeoutMs = DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS, @@ -44,7 +48,7 @@ export async function requestExplicitStreamAbort(params: { ) try { - const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const mothershipBaseURL = await getMothershipBaseURL({ userId: routingUserId ?? userId }) const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, { method: 'POST', headers, diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 5078e2377fe..57a7860c8df 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -1,12 +1,15 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' const { executeTool, completeAsyncToolCall, + getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, + publishToolConfirmation, onEvent, recordSimToolMetric, setAttribute, @@ -16,8 +19,10 @@ const { return { executeTool: vi.fn(), completeAsyncToolCall: vi.fn(), + getAsyncToolCall: vi.fn().mockResolvedValue(null), markAsyncToolRunning: vi.fn(), upsertAsyncToolCall: vi.fn(), + publishToolConfirmation: vi.fn(), onEvent: vi.fn(), recordSimToolMetric: vi.fn(), setAttribute, @@ -35,12 +40,13 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ vi.mock('@/lib/copilot/async-runs/repository', () => ({ completeAsyncToolCall, + getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, })) vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ - publishToolConfirmation: vi.fn(), + publishToolConfirmation, })) vi.mock('@/lib/copilot/request/metrics', () => ({ @@ -77,6 +83,7 @@ import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothershi import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolExecutionContext, + cancelToolCallAndReport, executeToolAndReport, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, @@ -394,3 +401,89 @@ describe('executeToolAndReport metrics', () => { } ) }) + +describe('buildToolExecutionContext authorization and stop signals', () => { + it('projects the authorization principal while retaining the immutable billing actor', () => { + const billingAttribution: BillingAttributionSnapshot = { + actorUserId: 'workspace-billed-account', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-billed-account', + billingEntity: { type: 'user', id: 'workspace-billed-account' }, + billingPeriod: { start: '2026-07-01', end: '2026-08-01' }, + payerSubscription: null, + } + const executionContext: ExecutionContext = { + userId: 'workspace-billed-account', + authorizationUserId: 'workspace-key-owner', + workflowId: '', + workspaceId: 'workspace-1', + billingAttribution, + } + + const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext) + expect(toolContext).toMatchObject({ + userId: 'workspace-key-owner', + workspaceId: 'workspace-1', + toolCallId: 'call-1', + }) + expect(toolContext.billingAttribution).toBe(billingAttribution) + expect(toolContext).not.toHaveProperty('authorizationUserId') + expect(toolContext).not.toHaveProperty('billingActorUserId') + expect(executionContext.userId).toBe('workspace-billed-account') + expect(executionContext).not.toHaveProperty('billingActorUserId') + }) + + it('preserves the explicit user-stop signal in the per-tool context', () => { + const userStopController = new AbortController() + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + userStopSignal: userStopController.signal, + } + + const toolContext = buildToolExecutionContext({ id: 'call-1' }, executionContext) + + expect(toolContext.userStopSignal).toBe(userStopController.signal) + }) +}) + +describe('cancelToolCallAndReport', () => { + beforeEach(() => { + vi.clearAllMocks() + upsertAsyncToolCall.mockResolvedValue({ toolCallId: 'tool-stop' }) + }) + + it('publishes cancellation only when its durable terminal transition wins', async () => { + const losingContext = createStreamingContext({ runId: 'run-1' }) + losingContext.toolCalls.set('tool-lost-race', { + id: 'tool-lost-race', + name: 'read', + status: 'executing', + }) + completeAsyncToolCall.mockResolvedValueOnce(null) + + await cancelToolCallAndReport('tool-lost-race', losingContext) + expect(publishToolConfirmation).not.toHaveBeenCalled() + + const winningContext = createStreamingContext({ runId: 'run-1' }) + winningContext.toolCalls.set('tool-won-race', { + id: 'tool-won-race', + name: 'read', + status: 'executing', + }) + completeAsyncToolCall.mockResolvedValueOnce({ + toolCallId: 'tool-won-race', + status: 'cancelled', + }) + + await cancelToolCallAndReport('tool-won-race', winningContext) + expect(publishToolConfirmation).toHaveBeenCalledOnce() + expect(publishToolConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'tool-won-race', + status: MothershipStreamV1ToolOutcome.cancelled, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 2d550c7ff82..ccaf638aaa9 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -5,8 +5,10 @@ import type { AsyncCompletionEnvelope, AsyncCompletionSignal, } from '@/lib/copilot/async-runs/lifecycle' +import { isTerminalAsyncStatus } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, + getAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' @@ -76,6 +78,7 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -196,7 +199,11 @@ function abortRequested( options?: OrchestratorOptions ): boolean { return Boolean( - options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted + options?.userStopSignal?.aborted || + execContext.userStopSignal?.aborted || + options?.abortSignal?.aborted || + execContext.abortSignal?.aborted || + context.wasAborted ) } @@ -273,9 +280,13 @@ class ToolExecutionTimeoutError extends Error { export function buildToolExecutionContext( toolCall: Pick, execContext: ExecutionContext -): ExecutionContext { +): ToolExecutionContext { + const { authorizationUserId, ...toolContext } = execContext return { - ...execContext, + ...toolContext, + ...(authorizationUserId && authorizationUserId !== execContext.userId + ? { userId: authorizationUserId } + : {}), toolCallId: toolCall.id, resolvedSecretTraceRegistry: execContext.resolvedSecretTraceRegistry?.forkForToolInput( toolCall.params @@ -289,12 +300,31 @@ export function buildToolExecutionContext( * resolves nor rejects within the tool's watchdog cap, throw a timeout error * so the standard failure path (persist failed row, publish terminal * confirmation, resume Go with an error result) runs and the chat never - * wedges behind a hung await. The losing promise keeps running detached; its - * eventual settlement is ignored. + * wedges behind a hung await. The losing promise's result is ignored, but the + * raw execution remains tracked so an explicit Stop keeps the chat lease until + * any still-mutating handler has actually unwound. */ -async function executeToolWithWatchdog(toolCall: ToolCallState, toolContext: ExecutionContext) { +async function executeToolWithWatchdog( + toolCall: ToolCallState, + context: StreamingContext, + toolContext: ToolExecutionContext +) { const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) + const inFlightToolExecutions = (context.inFlightToolExecutions ??= new Map()) + inFlightToolExecutions.set(toolCall.id, execution) + void execution.then( + () => { + if (inFlightToolExecutions.get(toolCall.id) === execution) { + inFlightToolExecutions.delete(toolCall.id) + } + }, + () => { + if (inFlightToolExecutions.get(toolCall.id) === execution) { + inFlightToolExecutions.delete(toolCall.id) + } + } + ) let timer: ReturnType | undefined try { return await Promise.race([ @@ -314,6 +344,77 @@ async function executeToolWithWatchdog(toolCall: ToolCallState, toolContext: Exe } } +/** + * Durably terminalizes a Sim-owned tool call when its turn is stopped. + * + * The upsert closes the narrow race where cancellation wins before the normal + * executor creates its row. `markAsyncToolRunning` is terminal-safe, so a late + * executor cannot resurrect this cancellation back to `running`. + */ +export async function cancelToolCallAndReport( + toolCallId: string, + context: StreamingContext, + message = 'Stopped by user' +): Promise { + const toolCall = context.toolCalls.get(toolCallId) + if (!toolCall) return + + const alreadyCancelled = toolCall.status === MothershipStreamV1ToolOutcome.cancelled + if ( + !alreadyCancelled && + (toolCall.endTime !== undefined || isTerminalToolCallStatus(toolCall.status)) + ) { + return + } + + if (!alreadyCancelled) { + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.cancelled, + error: message, + }) + } + markToolResultSeen(toolCallId) + + if (context.runId) { + await upsertAsyncToolCall({ + runId: context.runId, + toolCallId, + toolName: toolCall.name, + args: toolCall.params, + }).catch((err) => { + logger.warn('Failed to persist async tool row before cancellation', { + toolCallId, + error: toError(err).message, + }) + }) + } + + const persisted = await completeAsyncToolCall({ + toolCallId, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: message, + }).catch((err) => { + logger.warn('Failed to persist async tool cancellation', { + toolCallId, + error: toError(err).message, + }) + return null + }) + + // Only the winner of the pending/running -> cancelled transition publishes. + // A null row means another terminal outcome already won and must not be + // overwritten by a late stop notification. + if (persisted) { + publishTerminalToolConfirmation({ + toolCallId, + status: MothershipStreamV1ToolOutcome.cancelled, + message, + data: { cancelled: true }, + }) + } +} + /** * Last-resort settlement for a tool whose promise never settled (a hang the * per-tool watchdog could not see, e.g. in post-processing or persistence). @@ -484,6 +585,9 @@ async function executeToolAndReportInner( }) } if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + await cancelToolCallAndReport(toolCall.id, context, requireToolCallError(toolCall)) + } return terminalCompletionFromToolCall(toolCall) } @@ -495,26 +599,9 @@ async function executeToolAndReportInner( } if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted before tool execution') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted before tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted before tool execution', - data: { cancelled: true }, - }) - return cancelledCompletion('Request aborted before tool execution') + const message = 'Request aborted before tool execution' + await cancelToolCallAndReport(toolCall.id, context, message) + return cancelledCompletion(message) } toolCall.status = 'executing' @@ -529,15 +616,53 @@ async function executeToolAndReportInner( error: toError(err).message, }) }) - await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { + const runningToolCall = await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { logger.warn('Failed to mark async tool running', { toolCallId: toolCall.id, error: toError(err).message, }) + return null }) - if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { - return terminalCompletionFromToolCall(toolCall) + if (!runningToolCall) { + const durableToolCall = await getAsyncToolCall(toolCall.id).catch((err) => { + logger.warn('Failed to inspect async tool state after running transition lost', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + return null + }) + if (durableToolCall && isTerminalAsyncStatus(durableToolCall.status)) { + const terminalStatus = + durableToolCall.status === MothershipStreamV1AsyncToolRecordStatus.completed + ? MothershipStreamV1ToolOutcome.success + : durableToolCall.status === MothershipStreamV1AsyncToolRecordStatus.cancelled + ? MothershipStreamV1ToolOutcome.cancelled + : MothershipStreamV1ToolOutcome.error + setTerminalToolCallState(toolCall, { + status: terminalStatus, + ...(durableToolCall.result !== null && durableToolCall.result !== undefined + ? { output: durableToolCall.result } + : {}), + ...(terminalStatus === MothershipStreamV1ToolOutcome.success + ? {} + : { error: durableToolCall.error || 'Tool execution was already terminalized' }), + }) + markToolResultSeen(toolCall.id) + return terminalCompletionFromToolCall(toolCall) + } + } + + const persistedToolCall = context.toolCalls.get(toolCall.id) ?? toolCall + if (persistedToolCall.endTime || isTerminalToolCallStatus(persistedToolCall.status)) { + if (persistedToolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + await cancelToolCallAndReport( + persistedToolCall.id, + context, + requireToolCallError(persistedToolCall) + ) + } + return terminalCompletionFromToolCall(persistedToolCall) } const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined @@ -546,6 +671,7 @@ async function executeToolAndReportInner( toolName: toolCall.name, argsPreview, abortSignalAborted: execContext.abortSignal?.aborted ?? false, + userStopSignalAborted: execContext.userStopSignal?.aborted ?? false, }) const endToolSpan = ( @@ -560,6 +686,13 @@ async function executeToolAndReportInner( if (options?.abortSignal?.aborted) { abortDetail.optionsAbortReason = String(options.abortSignal.reason ?? 'unknown') } + if (execContext.userStopSignal?.aborted) { + abortDetail.userStopSignalAborted = true + abortDetail.userStopReason = String(execContext.userStopSignal.reason ?? 'unknown') + } + if (options?.userStopSignal?.aborted) { + abortDetail.optionsUserStopReason = String(options.userStopSignal.reason ?? 'unknown') + } if (context.wasAborted) { abortDetail.wasAborted = true } @@ -598,40 +731,31 @@ async function executeToolAndReportInner( try { ensureHandlersRegistered() - let result = await executeToolWithWatchdog(toolCall, toolExecutionContext) - if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + let result = await executeToolWithWatchdog(toolCall, context, toolExecutionContext) + const currentToolCall = context.toolCalls.get(toolCall.id) ?? toolCall + if (currentToolCall.endTime || isTerminalToolCallStatus(currentToolCall.status)) { + if (currentToolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + await cancelToolCallAndReport( + currentToolCall.id, + context, + requireToolCallError(currentToolCall) + ) + } endToolSpanFromTerminalState() - return terminalCompletionFromToolCall(toolCall) + return terminalCompletionFromToolCall(currentToolCall) } if (abortRequested(context, execContext, options)) { const copilotResult = inspectToolResultForCopilot( result, toolExecutionContext.resolvedSecretTraceRegistry ).result - markToolCallCancelled('Request aborted during tool execution') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool execution', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool execution' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_execution', error: copilotResult.success === false ? copilotResult.error : undefined, }) - return cancelledCompletion('Request aborted during tool execution') + return cancelledCompletion(message) } result = await maybeWriteOutputToFile( toolCall.name, @@ -640,27 +764,10 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool post-processing' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' }) - return cancelledCompletion('Request aborted during tool post-processing') + return cancelledCompletion(message) } result = await maybeWriteOutputToTable( toolCall.name, @@ -669,27 +776,10 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool post-processing' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' }) - return cancelledCompletion('Request aborted during tool post-processing') + return cancelledCompletion(message) } result = await maybeWriteReadCsvToTable( toolCall.name, @@ -698,27 +788,10 @@ async function executeToolAndReportInner( toolExecutionContext ) if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool post-processing' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) - return cancelledCompletion('Request aborted during tool post-processing') + return cancelledCompletion(message) } const projection = inspectToolResultForCopilot( result, @@ -859,30 +932,13 @@ async function executeToolAndReportInner( mergeToolRegistry(projection.safe) const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool execution') - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool execution', - data: { cancelled: true }, - }) + const message = 'Request aborted during tool execution' + await cancelToolCallAndReport(toolCall.id, context, message) endToolSpan('cancelled', { cancelReason: 'abort_during_execution_catch', error: safeThrownMessage, }) - return cancelledCompletion('Request aborted during tool execution') + return cancelledCompletion(message) } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 5c75b645730..23af59650ee 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -375,6 +375,44 @@ describe('runGatedToolExecution', () => { expect(signal.status).toBe('error') }) + it('lets an explicit user stop cancel a permission wait independently of transport', async () => { + const context = makeContext() + const toolCall = makeToolCall() + const transportController = new AbortController() + const userStopController = new AbortController() + let permissionSignal: AbortSignal | undefined + waitForToolPermissionDecision.mockImplementationOnce( + (_toolCallId: string, _timeoutMs: number, signal?: AbortSignal) => { + permissionSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null), { once: true }) + }) + } + ) + + const pending = runGatedToolExecution( + toolCall, + toolCall.id, + toolCall.name, + toolCall.params, + MothershipStreamV1ToolExecutor.client, + context, + { + abortSignal: transportController.signal, + userStopSignal: userStopController.signal, + }, + vi.fn() as () => Promise + ) + + await vi.waitFor(() => expect(permissionSignal).toBeDefined()) + userStopController.abort('stop') + await pending + + expect(permissionSignal?.aborted).toBe(true) + expect(transportController.signal.aborted).toBe(false) + expect(toolCall.status).toBe('cancelled') + }) + it('refuses to run a gated tool whose row is hidden, rather than hanging the turn', async () => { const context = makeContext() const toolCall = makeToolCall() diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/copilot/request/tools/permission.ts index 08e751dc204..9001eb38725 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/copilot/request/tools/permission.ts @@ -240,10 +240,14 @@ export function runGatedToolExecution( return { status: MothershipStreamV1ToolOutcome.success, message: output.message } } + const stopSignal = + options.abortSignal && options.userStopSignal + ? AbortSignal.any([options.abortSignal, options.userStopSignal]) + : (options.userStopSignal ?? options.abortSignal) const decision = await waitForToolPermissionDecision( toolCallId, PERMISSION_WAIT_TIMEOUT_MS, - options.abortSignal + stopSignal ) if (!decision) { diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 55ad05c06e7..510232b97fc 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -327,6 +327,20 @@ describe('maybeWriteReadCsvToTable', () => { expect(mockReplaceTableRows).not.toHaveBeenCalled() }) + it('denies outputTable in query-only mode even when the principal can write', async () => { + const result = await maybeWriteReadCsvToTable( + ReadTool.id, + { outputTable: 'tbl_1', path: 'files/people.csv' }, + { success: true, output: { content: 'name,age\nAlice,30' } }, + buildContext({ userPermission: 'admin', queryOnly: true }) + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('query-only') + expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockReplaceTableRows).not.toHaveBeenCalled() + }) + it('imports CSV content through the service with id-keyed rows', async () => { const result = await maybeWriteReadCsvToTable( ReadTool.id, diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index b8158308986..1cdf967fc9a 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -108,6 +108,14 @@ export async function maybeWriteOutputToTable( const outputTable = params?.outputTable as string | undefined if (!outputTable) return result + if (context.queryOnly) { + return { + success: false, + error: + 'function_execute is query-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + const denied = denyOutputWriteWithoutWritePermission(context) if (denied) return denied @@ -231,6 +239,14 @@ export async function maybeWriteReadCsvToTable( const outputTable = params?.outputTable as string | undefined if (!outputTable) return result + if (context.queryOnly) { + return { + success: false, + error: + 'read is query-only: outputTable (workspace table overwrite) is not available; inspect the file and report its contents instead', + } + } + const denied = denyOutputWriteWithoutWritePermission(context) if (denied) return denied diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts index abcd14d5ef9..879d2ccd0c6 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts @@ -189,6 +189,22 @@ describe('create_workflow execution context', () => { expect(Object.isFrozen(attribution)).toBe(true) expect(context.billingAttribution).toBe(billingAttribution) }) + + it('uses the retained billing actor after a tool context projects its authorization user', async () => { + const context = { + ...createContext(), + userId: 'workspace-key-owner', + } + resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) + + const attribution = await resolveWorkflowExecutionBillingAttribution(context, 'workspace-2') + + expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ + actorUserId: 'user-1', + workspaceId: 'workspace-2', + }) + expect(attribution).toBe(childBillingAttribution) + }) }) describe('prepareWorkflowExecutionAdmission', () => { diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.ts b/apps/sim/lib/copilot/request/tools/workflow-context.ts index 0e8fa1523d6..f5474594372 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.ts @@ -73,12 +73,13 @@ export async function resolveWorkflowExecutionBillingAttribution( return rootAttribution } + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId const childAttribution = await resolveBillingAttribution({ - actorUserId: context.userId, + actorUserId: billingActorUserId, workspaceId: targetWorkspaceId, }) if ( - childAttribution.actorUserId !== context.userId || + childAttribution.actorUserId !== billingActorUserId || childAttribution.workspaceId !== targetWorkspaceId ) { throw new Error('Resolved workflow billing attribution does not match its actor and workspace') diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index ed76e5cd505..1391290f966 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -137,6 +137,12 @@ export interface StreamingContext { contentBlocks: ContentBlock[] toolCalls: Map pendingToolPromises: Map> + /** + * Raw handler executions beneath the timeout wrapper. Stop waits for these + * too, so a watchdog timeout cannot detach a still-mutating stopped tool from + * the chat lease. + */ + inFlightToolExecutions?: Map> awaitingAsyncContinuation?: ResumeContinuation currentThinkingBlock: ContentBlock | null /** @@ -216,6 +222,8 @@ export interface OrchestratorOptions { onComplete?: (result: OrchestratorResult) => void | Promise onError?: (error: Error, result?: OrchestratorResult) => void | Promise abortSignal?: AbortSignal + /** Fires only on explicit user stop, never on passive transport disconnect. */ + userStopSignal?: AbortSignal onAbortObserved?: (reason: string) => void interactive?: boolean } @@ -245,5 +253,11 @@ export interface ToolCallSummary { } export interface ExecutionContext extends ToolExecutionContext { + /** + * Turn-scoped authorization principal. It is projected onto `userId` before + * tool dispatch and never enters the generic tool context; billing remains + * frozen in `billingAttribution.actorUserId`. + */ + authorizationUserId?: string messageId?: string } diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 30b5d8d4f17..b4fe3fa1587 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -89,6 +89,188 @@ describe('copilot tool executor fallback', () => { expect(handler).toHaveBeenCalledOnce() }) + it('rejects a top-level workspaceId outside the trusted workspace before dispatch', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('manage_workspace_resource', handler) + + await expect( + executeTool( + 'manage_workspace_resource', + { workspaceId: 'ws-2' }, + { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + ) + ).resolves.toEqual({ + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('rejects a nested payload workspaceId outside the trusted workspace', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('manage_workspace_resource', handler) + + await expect( + executeTool( + 'manage_workspace_resource', + { payload: { workspaceId: 'ws-2' } }, + { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + ) + ).resolves.toEqual({ + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + }) + expect(handler).not.toHaveBeenCalled() + }) + + it('preserves workspaceId parameters owned by dynamic integrations', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + isClientExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { ok: true } }) + + await expect( + executeTool( + 'external_integration_action', + { workspaceId: 'external-service-workspace' }, + { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + ) + ).resolves.toEqual({ success: true, output: { ok: true } }) + expect(executeAppTool).toHaveBeenCalledWith( + 'external_integration_action', + expect.objectContaining({ + workspaceId: 'external-service-workspace', + _context: expect.objectContaining({ workspaceId: 'ws-1' }), + }) + ) + }) + + it('allows explicit workspaceIds that match the trusted workspace', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const handler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('manage_workspace_resource', handler) + const context = { userId: 'user-1', workflowId: '', workspaceId: 'ws-1' } + + await expect( + executeTool( + 'manage_workspace_resource', + { workspaceId: 'ws-1', payload: { workspace_id: 'ws-1' } }, + context + ) + ).resolves.toEqual({ success: true }) + expect(handler).toHaveBeenCalledWith( + { workspaceId: 'ws-1', payload: { workspace_id: 'ws-1' } }, + context + ) + }) + + it('fails closed to the reviewed local tool set in query-only mode', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const readHandler = vi.fn().mockResolvedValue({ success: true, output: 'ok' }) + const mutationHandler = vi.fn().mockResolvedValue({ success: true }) + registerHandler('read', readHandler) + registerHandler('create_workflow', mutationHandler) + const context = { userId: 'user-1', workflowId: '', queryOnly: true } + + await expect(executeTool('read', { path: 'WORKSPACE.md' }, context)).resolves.toEqual({ + success: true, + output: 'ok', + }) + await expect(executeTool('create_workflow', { name: 'Nope' }, context)).resolves.toEqual({ + success: false, + error: 'Tool denied: create_workflow is not available in query-only mode.', + }) + expect(readHandler).toHaveBeenCalledOnce() + expect(mutationHandler).not.toHaveBeenCalled() + }) + + it('denies private credential controls and dynamic integrations when credentialless', async () => { + isKnownTool.mockImplementation((toolId: string) => toolId !== 'gmail_read') + + for (const toolId of [ + 'generate_api_key', + 'list_user_workspaces', + 'manage_credential', + 'oauth_get_auth_link', + 'oauth_request_access', + 'gmail_read', + ]) { + await expect( + executeTool(toolId, {}, { userId: 'user-1', workflowId: '', secretActorUserId: null }) + ).resolves.toEqual({ + success: false, + error: `Tool denied: ${toolId} is not available without credential access.`, + }) + } + expect(executeAppTool).not.toHaveBeenCalled() + }) + + it('keeps workspace environment writes and workflow runs in credentialless mode', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(true) + isClientExecuted.mockReturnValue(false) + const envHandler = vi.fn().mockResolvedValue({ success: true }) + const runHandler = vi.fn().mockResolvedValue({ success: true, output: { ran: true } }) + registerHandler('set_environment_variables', envHandler) + registerHandler('run_workflow', runHandler) + const context = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + secretActorUserId: null, + } + + await expect( + executeTool('set_environment_variables', { scope: 'personal', variables: [] }, context) + ).resolves.toEqual({ + success: false, + error: + 'Tool denied: personal environment variables are not available without credential access.', + }) + await expect( + executeTool('set_environment_variables', { scope: 'workspace', variables: [] }, context) + ).resolves.toEqual({ success: true }) + await expect(executeTool('run_workflow', {}, context)).resolves.toEqual({ + success: true, + output: { ran: true }, + }) + expect(envHandler).toHaveBeenCalledOnce() + expect(runHandler).toHaveBeenCalledOnce() + }) + + it('keeps workspace custom tools but denies MCP execution in credentialless mode', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + isClientExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { ok: true } }) + const context = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + secretActorUserId: null, + } + + await expect(executeTool('custom_tool-1', {}, context)).resolves.toEqual({ + success: true, + output: { ok: true }, + }) + await expect(executeTool('mcp-server-1-search', {}, context)).resolves.toEqual({ + success: false, + error: 'Tool denied: mcp-server-1-search is not available without credential access.', + }) + expect(executeAppTool).toHaveBeenCalledOnce() + }) + it('projects resolved secrets before logging registered handler failures', async () => { const secret = 'mounted-secret-value' const registry = new ResolvedSecretTraceRegistry([ @@ -143,6 +325,27 @@ describe('copilot tool executor fallback', () => { expect(result).toEqual({ success: true, output: { emails: [] } }) }) + it('forwards the active cancellation signal to dynamic app tools', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: {} }) + const controller = new AbortController() + + await executeTool( + 'gmail_read', + {}, + { + userId: 'user-1', + workflowId: 'workflow-1', + abortSignal: controller.signal, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith('gmail_read', expect.any(Object), { + signal: controller.signal, + }) + }) + it('threads billing attribution into _context for dynamic tools (MCP)', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 6488b695f25..adc0ecea93e 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -3,6 +3,7 @@ import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/wo import { toError } from '@sim/utils/errors' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' +import { isCustomTool, isMcpTool } from '@/executor/constants' import { executeTool as executeAppTool } from '@/tools' import { getToolEntry, isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { @@ -16,6 +17,24 @@ const logger = createLogger('ToolExecutor') const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 const MILLISECONDS_PER_SECOND = 1000 +const QUERY_ONLY_TOOL_IDS = new Set([ + 'grep', + 'glob', + 'read', + 'get_block_outputs', + 'get_block_upstream_references', + 'get_deployed_workflow_state', + 'search_knowledge_base', + 'query_user_table', + 'get_platform_actions', +]) +const CREDENTIALLESS_DENIED_TOOL_IDS = new Set([ + 'generate_api_key', + 'list_user_workspaces', + 'manage_credential', + 'oauth_get_auth_link', + 'oauth_request_access', +]) const handlerRegistry = new Map() @@ -46,6 +65,48 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { + if ( + context.workspaceId && + isKnownTool(toolId) && + hasWorkspaceScopeMismatch(params, context.workspaceId) + ) { + return { + success: false, + error: 'Tool denied: requested workspace does not match the current workspace.', + } + } + + if (context.queryOnly && !QUERY_ONLY_TOOL_IDS.has(toolId)) { + return { + success: false, + error: `Tool denied: ${toolId} is not available in query-only mode.`, + } + } + + if ( + context.secretActorUserId === null && + (CREDENTIALLESS_DENIED_TOOL_IDS.has(toolId) || + isMcpTool(toolId) || + (!isKnownTool(toolId) && !isCustomTool(toolId))) + ) { + return { + success: false, + error: `Tool denied: ${toolId} is not available without credential access.`, + } + } + + if ( + context.secretActorUserId === null && + toolId === 'set_environment_variables' && + params.scope === 'personal' + ) { + return { + success: false, + error: + 'Tool denied: personal environment variables are not available without credential access.', + } + } + const requiredPermission = getToolEntry(toolId)?.requiredPermission if ( requiredPermission && @@ -71,10 +132,15 @@ export async function executeTool( (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(normalizedParams, context) - return context.resolvedSecretTraceRegistry - ? executeAppTool(toolId, appParams, { - resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, - }) + const signal = context.abortSignal ?? context.userStopSignal + const executionOptions = { + ...(signal ? { signal } : {}), + ...(context.resolvedSecretTraceRegistry + ? { resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry } + : {}), + } + return Object.keys(executionOptions).length > 0 + ? executeAppTool(toolId, appParams, executionOptions) : executeAppTool(toolId, appParams) } @@ -105,6 +171,27 @@ export async function executeTool( } } +function hasWorkspaceScopeMismatch(params: Record, workspaceId: string): boolean { + const payload = + typeof params.payload === 'object' && params.payload !== null + ? (params.payload as Record) + : undefined + const suppliedContext = + typeof params._context === 'object' && params._context !== null + ? (params._context as Record) + : undefined + const candidates = [ + params.workspaceId, + params.workspace_id, + payload?.workspaceId, + payload?.workspace_id, + suppliedContext?.workspaceId, + suppliedContext?.workspace_id, + ] + + return candidates.some((candidate) => typeof candidate === 'string' && candidate !== workspaceId) +} + function normalizeToolParams( toolId: string, params: Record, diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 17c233e2550..8cc38af972a 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -17,6 +17,8 @@ export interface ToolExecutionContext { copilotToolExecution?: boolean /** Server-owned base image selected from the fixed Go route for this turn. */ sandboxProfile?: 'mothership' + /** Trusted server policy: workspace inspection only, with every write sink disabled. */ + queryOnly?: boolean requestMode?: string currentAgentId?: string /** @@ -27,6 +29,8 @@ export interface ToolExecutionContext { */ parentToolCallId?: string abortSignal?: AbortSignal + /** Fires only on explicit user stop, never on passive transport disconnect. */ + userStopSignal?: AbortSignal userTimezone?: string userPermission?: string secretMountPolicy?: SecretMountPolicy diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..6f32827bcc4 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -2,12 +2,14 @@ import type { ComponentType } from 'react' import { Loader } from '@sim/emcn' import { FileText } from '@sim/emcn/icons' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/copilot/tools/tool-display' -import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' +import { + describeReadTarget, + humanizeDisplayIdentifier, + humanizeToolName, +} from '@/lib/copilot/tools/tool-display' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' @@ -45,7 +47,8 @@ function specialToolDisplay( } if (toolName === ReadTool.id) { - const target = describeReadTarget(readStringParam(params, 'path')) + const path = readStringParam(params, 'path') + const target = describeReadTarget(path, getReadTargetBlock(path)?.name) return { text: formatReadingLabel(target, state), icon: FileText, @@ -83,79 +86,6 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta } } -function describeReadTarget(path: string | undefined): string | undefined { - if (!path) return undefined - - const block = getReadTargetBlock(path) - if (block) return block.name - - const segments = path - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean) - .map(decodeVfsSegmentSafe) - - if (segments.length === 0) return undefined - - const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] - if (!resourceType) { - return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') - } - - if (resourceType === 'file') { - return describeFileReadTarget(segments) - } - - if (resourceType === 'workflow') { - return stripExtension(getLeafResourceSegment(segments)) - } - - const resourceName = segments[1] || segments[segments.length - 1] - return stripExtension(resourceName) -} - -// A workspace file is addressed as a directory of facets in the VFS -// (files/{...path}/{name}/{facet}). `content` is the default facet — reading a -// file means reading its content — so it carries no qualifier, matching a bare -// `files/{...path}/{name}` read. The remaining facets are genuinely distinct, so -// they keep a descriptive label. -const FILE_FACET_LABELS: Record = { - content: '', - 'meta.json': 'metadata for', - style: 'style details for', - 'compiled-check': 'the final file check for', -} - -function describeFileReadTarget(segments: string[]): string { - const lastSegment = segments[segments.length - 1] || '' - const facetLabel = FILE_FACET_LABELS[lastSegment] - // Treat the suffix as a facet only when a real file name precedes it; otherwise - // the leaf is the file itself (e.g. a file literally named "content"). - if (facetLabel !== undefined && segments.length > 2) { - const fileName = segments[segments.length - 2] - return facetLabel ? `${facetLabel} ${fileName}` : fileName - } - // Show just the file name, not the folder path — these are glanceable status - // lines, and the other resource types already render the leaf only. - return lastSegment -} - -function getLeafResourceSegment(segments: string[]): string { - const lastSegment = segments[segments.length - 1] || '' - if (hasFileExtension(lastSegment) && segments.length > 1) { - return segments[segments.length - 2] || lastSegment - } - return lastSegment -} - -function hasFileExtension(value: string): boolean { - return /\.[^/.]+$/.test(value) -} - -function stripExtension(value: string): string { - return value.replace(/\.[^/.]+$/, '') -} - function humanizedFallback( toolName: string, state: ClientToolCallState diff --git a/apps/sim/lib/copilot/tools/handlers/access.test.ts b/apps/sim/lib/copilot/tools/handlers/access.test.ts new file mode 100644 index 00000000000..47a38664729 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/access.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { authorizeWorkflow, checkWorkspaceAccess } = vi.hoisted(() => ({ + authorizeWorkflow: vi.fn(), + checkWorkspaceAccess: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + authorizeWorkflowByWorkspacePermission: authorizeWorkflow, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + listAccessibleWorkspaceRowsForUser: vi.fn(), +})) + +import { ensureWorkflowAccess, ensureWorkspaceAccess } from './access' + +describe('Copilot access scope', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('allows a workflow in the trusted workspace', async () => { + const workflow = { id: 'wf-1', workspaceId: 'ws-1' } + authorizeWorkflow.mockResolvedValue({ allowed: true, workflow }) + + await expect( + ensureWorkflowAccess('wf-1', { userId: 'user-1', workspaceId: 'ws-1' }) + ).resolves.toEqual({ workflow, workspaceId: 'ws-1' }) + }) + + it('hides a workflow outside the trusted workspace', async () => { + authorizeWorkflow.mockResolvedValue({ + allowed: true, + workflow: { id: 'wf-2', workspaceId: 'ws-2' }, + }) + + await expect( + ensureWorkflowAccess('wf-2', { userId: 'user-1', workspaceId: 'ws-1' }) + ).rejects.toThrow('Workflow wf-2 not found') + }) + + it('rejects a workspace outside the trusted scope before its membership lookup', async () => { + await expect( + ensureWorkspaceAccess('ws-2', { userId: 'user-1', workspaceId: 'ws-1' }) + ).rejects.toThrow('Workspace ws-2 not found') + expect(checkWorkspaceAccess).not.toHaveBeenCalled() + }) + + it('preserves normal permission checks inside the trusted workspace', async () => { + const access = { + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + } + checkWorkspaceAccess.mockResolvedValue(access) + + await expect( + ensureWorkspaceAccess('ws-1', { userId: 'user-1', workspaceId: 'ws-1' }, 'write') + ).resolves.toBe(access) + expect(checkWorkspaceAccess).toHaveBeenCalledWith('ws-1', 'user-1') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 2f5d592269e..9ea9fbc4b4d 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -5,9 +5,14 @@ import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> +export interface CopilotAccessContext { + userId: string + workspaceId?: string +} + export async function ensureWorkflowAccess( workflowId: string, - userId: string, + context: CopilotAccessContext, action: 'read' | 'write' | 'admin' = 'read' ): Promise<{ workflow: WorkflowRecord @@ -15,7 +20,7 @@ export async function ensureWorkflowAccess( }> { const result = await authorizeWorkflowByWorkspacePermission({ workflowId, - userId, + userId: context.userId, action, }) @@ -27,6 +32,10 @@ export async function ensureWorkflowAccess( throw new Error(result.message || 'Unauthorized workflow access') } + if (context.workspaceId && result.workflow.workspaceId !== context.workspaceId) { + throw new Error(`Workflow ${workflowId} not found`) + } + return { workflow: result.workflow, workspaceId: result.workflow.workspaceId } } @@ -45,10 +54,14 @@ export async function getDefaultWorkspaceId(userId: string): Promise { export async function ensureWorkspaceAccess( workspaceId: string, - userId: string, + context: CopilotAccessContext, level: 'read' | 'write' | 'admin' = 'read' ): Promise { - const access = await checkWorkspaceAccess(workspaceId, userId) + if (context.workspaceId && workspaceId !== context.workspaceId) { + throw new Error(`Workspace ${workspaceId} not found`) + } + + const access = await checkWorkspaceAccess(workspaceId, context.userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 54817fffbc3..1c7c26ba711 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -119,7 +119,7 @@ describe('executeDeployCustomBlock', () => { context ) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', context, 'admin') expect(publishCustomBlockMock).toHaveBeenCalledWith({ organizationId: 'org-1', workspaceId: 'ws-1', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 8b2182b6abe..108e9583cdb 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -129,7 +129,7 @@ export async function executeDeployCustomBlock( let workflowRecord: Awaited>['workflow'] try { - workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow + workflowRecord = (await ensureWorkflowAccess(workflowId, context, 'admin')).workflow } catch (error) { const message = toError(error).message if (message.includes('not found')) { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index a19291b1f87..392fabe7eda 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -166,11 +166,7 @@ export async function executeDeployApi( return { success: false, error: 'workflowId is required' } } const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') if (action === 'undeploy') { const result = await performFullUndeploy({ workflowId, userId: context.userId }) @@ -586,11 +582,7 @@ export async function executeDeployMcp( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') const workspaceId = workflowRecord.workspaceId if (!workspaceId) { return { success: false, error: 'workspaceId is required' } @@ -869,7 +861,7 @@ export async function executeRedeploy( 'versionName is required. Provide a short human-readable label for this deployment version.', } } - await ensureWorkflowAccess(workflowId, context.userId, 'admin') + await ensureWorkflowAccess(workflowId, context, 'admin') const result = await performFullDeploy({ workflowId, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 55ec5455a9e..87654dba84a 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -102,7 +102,11 @@ describe('executeLoadDeployment', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith( + 'wf-1', + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }), + 'admin' + ) expect(performRevertToVersionMock).toHaveBeenCalledWith({ workflowId: 'wf-1', version: 7, @@ -195,7 +199,11 @@ describe('executePromoteToLive', () => { toolCallId: 'call-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith( + 'wf-1', + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }), + 'admin' + ) expect(performActivateVersionMock).toHaveBeenCalledWith({ workflowId: 'wf-1', version: 3, @@ -340,8 +348,16 @@ describe('executeDiffWorkflows', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 1, 'user-1') - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 'live', 'user-1') + expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith( + 'wf-1', + 1, + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }) + ) + expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith( + 'wf-1', + 'live', + expect.objectContaining({ userId: 'user-1', workflowId: 'wf-1' }) + ) // ref1 = base/previous, ref2 = target/current. expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true }) expect(result.success).toBe(true) @@ -352,6 +368,74 @@ describe('executeDiffWorkflows', () => { diff: { hasChanges: false }, }) }) + + it('removes credentials before diffing in secretless mode', async () => { + const state = (apiKey: string) => ({ + blocks: { + request: { + id: 'request', + type: 'unknown-integration', + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: apiKey }, + path: { id: 'path', type: 'short-input', value: '/users' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + resolveWorkflowStateRefMock + .mockResolvedValueOnce({ state: state('SENTINEL_OLD_SECRET'), ref: '1', version: 1 }) + .mockResolvedValueOnce({ state: state('SENTINEL_NEW_SECRET'), ref: '2', version: 2 }) + generateWorkflowDiffSummaryMock.mockReturnValue({ + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + }) + + await executeDiffWorkflows({ workflowId: 'wf-1', ref1: 1, ref2: 2 }, { + userId: 'key-creator', + secretActorUserId: null, + workflowId: 'wf-1', + } as ExecutionContext) + + expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: expect.objectContaining({ + request: expect.objectContaining({ + subBlocks: expect.objectContaining({ + apiKey: expect.objectContaining({ value: null }), + path: expect.objectContaining({ value: '/users' }), + }), + }), + }), + }), + expect.objectContaining({ + blocks: expect.objectContaining({ + request: expect.objectContaining({ + subBlocks: expect.objectContaining({ + apiKey: expect.objectContaining({ value: null }), + path: expect.objectContaining({ value: '/users' }), + }), + }), + }), + }) + ) + expect(JSON.stringify(generateWorkflowDiffSummaryMock.mock.calls)).not.toContain('SENTINEL_') + }) }) describe('executeCheckDeploymentStatus', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index c2e13b77b14..5d146a6ddef 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -3,6 +3,7 @@ import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/sche import { toError } from '@sim/utils/errors' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { performCreateWorkflowMcpServer, performDeleteWorkflowMcpServer, @@ -44,7 +45,7 @@ export async function executeCheckDeploymentStatus( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context) const workspaceId = workflowRecord.workspaceId const [apiDeploy, chatDeploy, deploymentSummary] = await Promise.all([ @@ -163,18 +164,18 @@ export async function executeListWorkspaceMcpServers( context: ExecutionContext ): Promise { try { - let workspaceId = params.workspaceId || context.workspaceId + let workspaceId = context.workspaceId || params.workspaceId const workflowId = context.workflowId if (!workspaceId && workflowId) { - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context) workspaceId = workflowRecord.workspaceId ?? undefined } if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'read') + await ensureWorkspaceAccess(workspaceId, context, 'read') const servers = await db .select({ @@ -226,22 +227,18 @@ export async function executeCreateWorkspaceMcpServer( context: ExecutionContext ): Promise { try { - let workspaceId = params.workspaceId || context.workspaceId + let workspaceId = context.workspaceId || params.workspaceId const workflowId = context.workflowId if (!workspaceId && workflowId) { - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'write') workspaceId = workflowRecord.workspaceId ?? undefined } if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') + await ensureWorkspaceAccess(workspaceId, context, 'admin') const name = params.name?.trim() if (!name) { @@ -306,7 +303,7 @@ export async function executeUpdateWorkspaceMcpServer( return { success: false, error: 'MCP server not found' } } - await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(existing.workspaceId, context, 'write') const result = await performUpdateWorkflowMcpServer({ serverId, @@ -348,7 +345,7 @@ export async function executeDeleteWorkspaceMcpServer( return { success: false, error: 'MCP server not found' } } - await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin') + await ensureWorkspaceAccess(existing.workspaceId, context, 'admin') const result = await performDeleteWorkflowMcpServer({ serverId, @@ -374,7 +371,7 @@ export async function executeGetDeploymentLog( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const { versions: rows } = await listWorkflowVersions(workflowId) @@ -426,12 +423,16 @@ export async function executeDiffWorkflows( // resolveWorkflowStateRef enforces read access on the workflow. const [side1, side2] = await Promise.all([ - resolveWorkflowStateRef(workflowId, params.ref1, context.userId), - resolveWorkflowStateRef(workflowId, params.ref2, context.userId), + resolveWorkflowStateRef(workflowId, params.ref1, context), + resolveWorkflowStateRef(workflowId, params.ref2, context), ]) + const projection = { secretless: context.secretActorUserId === null } + const state1 = projectWorkflowStateForCopilot(side1.state, projection) + const state2 = projectWorkflowStateForCopilot(side2.state, projection) + // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. - const summary = generateWorkflowDiffSummary(side2.state, side1.state) + const summary = generateWorkflowDiffSummary(state2, state1) const diff = { ...summary, modifiedBlocks: summary.modifiedBlocks.map((block) => ({ @@ -496,11 +497,7 @@ export async function executeLoadDeployment( return { success: false, error: target.error } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') const result = await performRevertToVersion({ workflowId, version: target.version, @@ -553,11 +550,7 @@ export async function executePromoteToLive( } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context, 'admin') const result = await performActivateVersion({ workflowId, version, @@ -629,7 +622,7 @@ export async function executeUpdateDeploymentVersion( return { success: false, error: 'Provide a name and/or description to update' } } - await ensureWorkflowAccess(workflowId, context.userId, 'write') + await ensureWorkflowAccess(workflowId, context, 'write') const updated = await updateDeploymentVersionMetadata({ workflowId, diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts index 8dde36ba6f0..ae84d55e59f 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts @@ -3,7 +3,7 @@ import { workflowDeploymentVersion } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess } from '../access' +import { type CopilotAccessContext, ensureWorkflowAccess } from '../access' /** Canonical workflow-state selector: a deployment version number, the live * (active) deployment, or the current draft. */ @@ -42,10 +42,10 @@ export function parseWorkflowRef(raw: unknown): WorkflowRef { export async function resolveWorkflowStateRef( workflowId: string, rawRef: unknown, - userId: string + context: CopilotAccessContext ): Promise { const ref = parseWorkflowRef(rawRef) - await ensureWorkflowAccess(workflowId, userId, 'read') + await ensureWorkflowAccess(workflowId, context, 'read') if (ref === 'draft') { const state = await loadWorkflowDeploymentSnapshot(workflowId) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 8257c9cbcd3..a737afc598d 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -335,6 +335,26 @@ describe('executeFunctionExecute trace-secret provenance', () => { expect(mockExecuteTool).not.toHaveBeenCalled() }) + it('forwards cancellation to the nested function executor', async () => { + const controller = new AbortController() + + await executeFunctionExecute( + { code: 'return 1' }, + { + userId: 'u1', + workflowId: '', + workspaceId: 'ws_1', + abortSignal: controller.signal, + } + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.any(Object), + expect.objectContaining({ signal: controller.signal }) + ) + }) + it('returns the raw runtime result when provenance import fails', async () => { mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: { API_KEY: 'secret-value' }, diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 067a41f07ca..1b4af0d6773 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -668,7 +668,9 @@ export async function executeFunctionExecute( try { const result = await executeAppTool('function_execute', enrichedParams, { resolvedSecretTraceRegistry: mountedRegistry, - ...(context.abortSignal ? { signal: context.abortSignal } : {}), + ...((context.abortSignal ?? context.userStopSignal) + ? { signal: context.abortSignal ?? context.userStopSignal } + : {}), ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), }) crossingValue = result diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts new file mode 100644 index 00000000000..ee5625247d4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + deleteCustomTool, + deleteWorkspaceCustomTool, + getCustomToolById, + getWorkspaceCustomTool, + listCustomTools, + listWorkspaceCustomTools, + updateWorkspaceCustomTool, + upsertCustomTools, +} = vi.hoisted(() => ({ + deleteCustomTool: vi.fn(), + deleteWorkspaceCustomTool: vi.fn(), + getCustomToolById: vi.fn(), + getWorkspaceCustomTool: vi.fn(), + listCustomTools: vi.fn(), + listWorkspaceCustomTools: vi.fn(), + updateWorkspaceCustomTool: vi.fn(), + upsertCustomTools: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + CUSTOM_TOOL_CREATED: 'created', + CUSTOM_TOOL_UPDATED: 'updated', + CUSTOM_TOOL_DELETED: 'deleted', + }, + AuditResourceType: { CUSTOM_TOOL: 'custom_tool' }, + recordAudit: vi.fn(), +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/copilot/tools/permissions', () => ({ + copilotToolCanWrite: vi.fn(() => true), + copilotWriteDeniedMessage: vi.fn(), +})) +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + deleteCustomTool, + deleteWorkspaceCustomTool, + getCustomToolById, + getWorkspaceCustomTool, + listCustomTools, + listWorkspaceCustomTools, + updateWorkspaceCustomTool, + upsertCustomTools, +})) + +import { executeManageCustomTool } from './manage-custom-tool' + +const CREDENTIALLESS_CONTEXT = { + userId: 'key-owner', + workflowId: '', + workspaceId: 'ws-1', + userPermission: 'admin', + secretActorUserId: null, +} + +describe('manage_custom_tool credentialless workspace scope', () => { + beforeEach(() => vi.clearAllMocks()) + + it('lists workspace tools without including legacy personal tools', async () => { + listWorkspaceCustomTools.mockResolvedValue([{ id: 'tool-1', title: 'Shared tool' }]) + + const result = await executeManageCustomTool({ operation: 'list' }, CREDENTIALLESS_CONTEXT) + + expect(result.success).toBe(true) + expect(listWorkspaceCustomTools).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) + expect(listCustomTools).not.toHaveBeenCalled() + }) + + it('edits and deletes through workspace-scoped operations', async () => { + const existing = { + id: 'tool-1', + title: 'Shared tool', + schema: { type: 'function', function: { name: 'shared_tool', parameters: {} } }, + code: 'return 1', + } + getWorkspaceCustomTool.mockResolvedValue(existing) + updateWorkspaceCustomTool.mockResolvedValue(existing) + deleteWorkspaceCustomTool.mockResolvedValue(true) + + const edit = await executeManageCustomTool( + { operation: 'edit', toolId: 'tool-1', code: 'return 2' }, + CREDENTIALLESS_CONTEXT + ) + const remove = await executeManageCustomTool( + { operation: 'delete', toolId: 'tool-1' }, + CREDENTIALLESS_CONTEXT + ) + + expect(edit.success).toBe(true) + expect(remove.success).toBe(true) + expect(getWorkspaceCustomTool).toHaveBeenCalledWith({ + toolId: 'tool-1', + workspaceId: 'ws-1', + }) + expect(updateWorkspaceCustomTool).toHaveBeenCalledWith( + expect.objectContaining({ toolId: 'tool-1', workspaceId: 'ws-1', code: 'return 2' }) + ) + expect(deleteWorkspaceCustomTool).toHaveBeenCalledWith({ + toolId: 'tool-1', + workspaceId: 'ws-1', + }) + expect(getCustomToolById).not.toHaveBeenCalled() + expect(deleteCustomTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index d1491ff9af9..48f3ea79273 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -6,8 +6,12 @@ import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/to import { captureServerEvent } from '@/lib/posthog/server' import { deleteCustomTool, + deleteWorkspaceCustomTool, getCustomToolById, + getWorkspaceCustomTool, listCustomTools, + listWorkspaceCustomTools, + updateWorkspaceCustomTool, upsertCustomTools, } from '@/lib/workflows/custom-tools/operations' @@ -63,10 +67,13 @@ export async function executeManageCustomTool( try { if (operation === 'list') { - const toolsForUser = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + if (context.secretActorUserId === null && !workspaceId) { + return { success: false, error: "workspaceId is required for operation 'list'" } + } + const toolsForUser = + context.secretActorUserId === null + ? await listWorkspaceCustomTools({ workspaceId: workspaceId! }) + : await listCustomTools({ userId: context.userId, workspaceId }) return { success: true, @@ -158,11 +165,14 @@ export async function executeManageCustomTool( } } - const existing = await getCustomToolById({ - toolId: params.toolId, - userId: context.userId, - workspaceId, - }) + const existing = + context.secretActorUserId === null + ? await getWorkspaceCustomTool({ toolId: params.toolId, workspaceId }) + : await getCustomToolById({ + toolId: params.toolId, + userId: context.userId, + workspaceId, + }) if (!existing) { return { success: false, error: `Custom tool not found: ${params.toolId}` } } @@ -171,11 +181,24 @@ export async function executeManageCustomTool( const mergedCode = params.code || existing.code const title = params.title || mergedSchema.function?.name || existing.title - await upsertCustomTools({ - tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], - workspaceId, - userId: context.userId, - }) + if (context.secretActorUserId === null) { + const updated = await updateWorkspaceCustomTool({ + toolId: params.toolId, + title, + schema: mergedSchema, + code: mergedCode, + workspaceId, + }) + if (!updated) { + return { success: false, error: `Custom tool not found: ${params.toolId}` } + } + } else { + await upsertCustomTools({ + tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], + workspaceId, + userId: context.userId, + }) + } recordAudit({ workspaceId, @@ -212,6 +235,9 @@ export async function executeManageCustomTool( } if (operation === 'delete') { + if (context.secretActorUserId === null && !workspaceId) { + return { success: false, error: "workspaceId is required for operation 'delete'" } + } const toolIds: string[] = params.toolIds ?? (params.toolId ? [params.toolId] : []) if (toolIds.length === 0) { return { success: false, error: "'toolId' or 'toolIds' is required for operation 'delete'" } @@ -221,11 +247,10 @@ export async function executeManageCustomTool( const notFound: string[] = [] for (const toolId of toolIds) { - const result = await deleteCustomTool({ - toolId, - userId: context.userId, - workspaceId, - }) + const result = + context.secretActorUserId === null + ? await deleteWorkspaceCustomTool({ toolId, workspaceId: workspaceId! }) + : await deleteCustomTool({ toolId, userId: context.userId, workspaceId }) if (result) { deleted.push(toolId) } else { diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts new file mode 100644 index 00000000000..cf175bfd8c4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { select, from, where } = vi.hoisted(() => { + const where = vi.fn() + const from = vi.fn(() => ({ where })) + const select = vi.fn(() => ({ from })) + return { select, from, where } +}) + +vi.mock('@sim/db', () => ({ db: { select } })) +vi.mock('@sim/db/schema', () => ({ + mcpServers: { + workspaceId: 'workspaceId', + deletedAt: 'deletedAt', + }, +})) +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => conditions), + eq: vi.fn((left: unknown, right: unknown) => [left, right]), + isNull: vi.fn((value: unknown) => [value, null]), +})) +vi.mock('@/lib/copilot/tools/permissions', () => ({ + copilotToolCanWrite: vi.fn(() => true), + copilotWriteDeniedMessage: vi.fn(), +})) +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateMcpServer: vi.fn(), + performDeleteMcpServer: vi.fn(), + performUpdateMcpServer: vi.fn(), +})) + +import { executeManageMcpTool } from './manage-mcp-tool' + +const SERVER = { + id: 'server-1', + name: 'Private MCP', + url: 'https://user:secret@example.com/mcp?token=sentinel', + transport: 'streamable-http', + enabled: true, + connectionStatus: 'connected', +} + +const CONTEXT = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + userPermission: 'admin', +} + +describe('manage_mcp_tool list projection', () => { + beforeEach(() => { + vi.clearAllMocks() + where.mockResolvedValue([SERVER]) + }) + + it('omits raw URLs from secretless workspace chat', async () => { + const result = await executeManageMcpTool( + { operation: 'list' }, + { ...CONTEXT, secretActorUserId: null } + ) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + servers: [ + { + id: 'server-1', + name: 'Private MCP', + transport: 'streamable-http', + enabled: true, + connectionStatus: 'connected', + }, + ], + }) + expect(JSON.stringify(result.output)).not.toContain('sentinel') + }) + + it('keeps URLs for normal user-backed chat', async () => { + const result = await executeManageMcpTool({ operation: 'list' }, CONTEXT) + + expect(result.output).toMatchObject({ servers: [{ url: SERVER.url }] }) + expect(select).toHaveBeenCalledOnce() + expect(from).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index c13ba02c76e..9b710dff9ca 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -69,7 +69,7 @@ export async function executeManageMcpTool( servers: servers.map((s) => ({ id: s.id, name: s.name, - url: s.url, + ...(context.secretActorUserId === null ? {} : { url: s.url }), transport: s.transport, enabled: s.enabled, connectionStatus: s.connectionStatus, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index b789a2360a5..f61885dda67 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -172,7 +172,7 @@ describe('executeMaterializeFile - workspace write gate', () => { const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context) - expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context.userId, 'write') + expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context, 'write') }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3c6f05e17e0..edc6a9934aa 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -557,7 +557,7 @@ export async function executeMaterializeFile( // Every operation writes: save/extract create files, import creates a workflow. // The handler-map path has no central permission gate. try { - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(context.workspaceId, context, 'write') } catch (error) { return { success: false, error: getErrorMessage(error, 'Workspace write access required') } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 1c7936d8980..dd840c425c7 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -43,11 +43,7 @@ export async function executeOAuthGetAuthLink( if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) + const workspaceAccess = await ensureWorkspaceAccess(context.workspaceId, context, 'write') const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) const configuredAllowedIntegrations = intersectIntegrationAllowlists( permissionConfig?.allowedIntegrations ?? null, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 322c5524e5d..a60c1875777 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -474,7 +474,7 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') + expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1', 'ws-1') expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( 'kb-1', { name: 'Product Docs' }, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index c0fa82f81c6..7ce738a8885 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -158,7 +158,7 @@ export async function executeVfsMkdir( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') assertMutationNotAborted(context) let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined @@ -232,7 +232,7 @@ async function executeVfsMutate( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') assertMutationNotAborted(context) const classified = classifyCategory(sources[0]) @@ -623,7 +623,7 @@ async function mutateWorkflows( id: duplicated.id, }) } else { - await ensureWorkflowAccess(wf.id, context.userId, 'write') + await ensureWorkflowAccess(wf.id, context, 'write') await assertWorkflowMutable(wf.id) const targetFolderId = await dest.ensureFolderId() await assertFolderMutable(targetFolderId) @@ -784,7 +784,7 @@ async function renameFlatResource( if (!match) { return { success: false, error: `Knowledge base not found at ${sources[0]}` } } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId, workspaceId) if (!access.hasAccess) { return { success: false, @@ -819,7 +819,7 @@ export async function executeVfsRm( } const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') assertMutationNotAborted(context) // Loaded at most once, and only when a workflows/ path in this call needs it. @@ -1068,7 +1068,7 @@ async function removeKnowledgeBasePath( if (!match) return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) + const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId, workspaceId) if (!access.hasAccess) { return { from: path, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 3d2d82ed9bd..3d492bb7ede 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -306,6 +306,33 @@ describe('vfs handlers oversize policy', () => { expect(result.success).toBe(false) expect(result.error).toContain('cannot be shared safely') }) + + it.each(['compiled', 'compiled-check', 'extract', 'render'])( + 'rejects /%s document execution paths in query-only mode', + async (suffix) => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: `files/reports/brief.pdf/${suffix}` }, + { ...GREP_CTX, queryOnly: true } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('query-only') + expect(vfs.readFileContent).not.toHaveBeenCalled() + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + } + ) + + it('requests a secretless VFS for credentialless execution contexts', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGlob({ pattern: 'workflows/**' }, { ...GREP_CTX, secretActorUserId: null }) + + expect(getOrMaterializeVFS).toHaveBeenCalledWith('ws-1', 'user-1', { secretless: true }) + }) }) describe('vfs grep workspace-file routing', () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 8593b714946..6b2ca3b4c3f 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -30,12 +30,15 @@ const logger = createLogger('VfsTools') async function getGatedVFS( workspaceId: string, userId: string, - secretMountPolicy?: SecretMountPolicy + secretMountPolicy?: SecretMountPolicy, + secretless = false ) { const vis = await getBlockVisibilityForCopilot(userId, workspaceId) - return withBlockVisibility(vis, () => - getOrMaterializeVFS(workspaceId, userId, { secretMountPolicy }) - ) + const options = { + ...(secretMountPolicy ? { secretMountPolicy } : {}), + ...(secretless ? { secretless: true } : {}), + } + return withBlockVisibility(vis, () => getOrMaterializeVFS(workspaceId, userId, options)) } /** @@ -170,7 +173,12 @@ export async function executeVfsGrep( result = envelope.value provenanceFile = envelope.file } else { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS( + workspaceId, + context.userId, + context.secretMountPolicy, + context.secretActorUserId === null + ) if (isWorkspaceFileGrepPath(rawPath)) { const envelope = await vfs.grepFileWithProvenance(rawPath, pattern, grepOptions) result = envelope.value @@ -238,7 +246,12 @@ export async function executeVfsGlob( } try { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS( + workspaceId, + context.userId, + context.secretMountPolicy, + context.secretActorUserId === null + ) let files = vfs.glob(pattern) if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { @@ -274,6 +287,17 @@ export async function executeVfsRead( return { success: false, error: 'No workspace context available' } } + if ( + context.queryOnly && + /\/(?:compiled|compiled-check|extract|render)\/?$/.test(path.trim().replace(/^\/+/, '')) + ) { + return { + success: false, + error: + 'read is query-only: document compilation, extraction, and rendering paths are not available; read the file content or metadata instead', + } + } + try { const parseOptionalNumber = (value: unknown): number | undefined => { if (typeof value === 'number' && Number.isFinite(value)) return value @@ -354,7 +378,12 @@ export async function executeVfsRead( } } - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS( + workspaceId, + context.userId, + context.secretMountPolicy, + context.secretActorUserId === null + ) // Plain canonical file leaves are metadata resources. Dynamic file content // and inspection paths use explicit suffixes like /content, /style, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index fa9dc2465e7..359dd4fa7fa 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -44,6 +44,7 @@ const { reserveExecutionSlotMock, releaseExecutionSlotMock, decryptSecretMock, + saveWorkflowToNormalizedTablesMock, } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), ensureWorkspaceAccessMock: vi.fn(), @@ -60,6 +61,7 @@ const { reserveExecutionSlotMock: vi.fn(), releaseExecutionSlotMock: vi.fn(), decryptSecretMock: vi.fn(), + saveWorkflowToNormalizedTablesMock: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -110,7 +112,7 @@ vi.mock('@/lib/workflows/orchestration', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: loadWorkflowFromNormalizedTablesMock, - saveWorkflowToNormalizedTables: vi.fn(), + saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ @@ -149,6 +151,7 @@ import { executeRunFromBlock, executeRunWorkflow, executeRunWorkflowUntilBlock, + executeSetBlockEnabled, executeSetGlobalWorkflowVariables, } from './mutations' @@ -280,6 +283,80 @@ describe('lock enforcement', () => { }) }) +describe('executeSetBlockEnabled secretless projection', () => { + const normalizedState = (enabled: boolean) => ({ + blocks: { + request: { + id: 'request', + type: 'unknown-integration', + name: 'Request', + enabled, + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: 'SENTINEL_API_KEY' }, + path: { id: 'path', type: 'short-input', value: '/users' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Workflow' }, + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) + }) + + it('omits raw state and credentials when the block already has the requested state', async () => { + loadWorkflowFromNormalizedTablesMock.mockResolvedValue(normalizedState(false)) + + const result = await executeSetBlockEnabled( + { workflowId: 'workflow-1', blockId: 'request', enabled: false }, + { userId: 'key-creator', secretActorUserId: null } as ExecutionContext + ) + + expect(result.success).toBe(true) + expect(result.output).not.toHaveProperty('workflowState') + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.subBlocks.apiKey.value', + null + ) + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.subBlocks.path.value', + '/users' + ) + expect(JSON.stringify(result.output)).not.toContain('SENTINEL_API_KEY') + expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() + }) + + it('omits raw state and credentials after persisting a state change', async () => { + loadWorkflowFromNormalizedTablesMock.mockResolvedValue(normalizedState(true)) + + const result = await executeSetBlockEnabled( + { workflowId: 'workflow-1', blockId: 'request', enabled: false }, + { userId: 'key-creator', secretActorUserId: null } as ExecutionContext + ) + + expect(result.success).toBe(true) + expect(result.output).not.toHaveProperty('workflowState') + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.subBlocks.apiKey.value', + null + ) + expect(result.output).toHaveProperty( + 'copilotSanitizedWorkflowState.blocks.request.enabled', + false + ) + expect(JSON.stringify(result.output)).not.toContain('SENTINEL_API_KEY') + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + }) +}) + describe('executeCreateWorkflow billing attribution', () => { beforeEach(() => { vi.clearAllMocks() @@ -473,26 +550,17 @@ describe('executeCreateWorkflow billing attribution', () => { expect(resolveBillingAttributionMock).not.toHaveBeenCalled() }) - it('keeps cross-workspace creation scoped while allowing explicit subsequent execution', async () => { + it('keeps creation in the trusted workspace when params name another workspace', async () => { const context: ExecutionContext = { ...executionContext, workflowId: '' } performCreateWorkflowMock.mockResolvedValue({ success: true, workflow: { id: 'created-workflow', - name: 'Other Workspace Workflow', - workspaceId: 'workspace-2', + name: 'Workspace Workflow', + workspaceId: 'workspace-1', folderId: null, }, }) - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'created-workflow', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) const createResult = await executeCreateWorkflow( { name: 'Other Workspace Workflow', workspaceId: 'workspace-2' }, @@ -500,51 +568,10 @@ describe('executeCreateWorkflow billing attribution', () => { ) expect(createResult.success).toBe(true) - applyCreateWorkflowOutputToContext(createResult.output, context) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('workspace-2', 'user-1', 'write') + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('workspace-1', context, 'write') expect(performCreateWorkflowMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-2' }) - ) - expect(context).toMatchObject({ - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - billingAttribution, - }) - expect(context.billingAttribution).toBe(billingAttribution) - const createOutput = createResult.output as { workflowId: string; workspaceId: string } - expect(createOutput).toEqual( - expect.objectContaining({ workflowId: 'created-workflow', workspaceId: 'workspace-2' }) - ) - - const runResult = await executeRunWorkflow( - { workflowId: createOutput.workflowId, useMockPayload: true }, - context - ) - - expect(runResult.success).toBe(true) - expect(resolveBillingAttributionMock).toHaveBeenCalledOnce() - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(executeWorkflowMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ id: 'created-workflow', workspaceId: 'workspace-2' }) - ) - expect(executeWorkflowMock.mock.calls[0]?.[3]).toBe('user-1') - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution: childBillingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledWith(childBillingAttribution) - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( - expect.objectContaining({ - billingEntity: childBillingAttribution.billingEntity, - executionId: executeWorkflowMock.mock.calls[0]?.[5], - }) + expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-1' }) ) - expect(context.billingAttribution).toBe(billingAttribution) }) }) @@ -616,6 +643,19 @@ describe('Copilot workflow execution billing attribution', () => { ) }) + it('forwards cancellation to headless workflow execution', async () => { + const controller = new AbortController() + + await executeRunWorkflow( + { workflowId: 'workflow-1', useMockPayload: true }, + { ...executionContext, abortSignal: controller.signal } + ) + + expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( + expect.objectContaining({ abortSignal: controller.signal }) + ) + }) + it('passes only input-crossing parent provenance to the child execution', async () => { const registry = new ResolvedSecretTraceRegistry( [ diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 260bf73fbd2..a1219d2b30c 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -10,6 +10,7 @@ import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { prepareWorkflowExecutionAdmission } from '@/lib/copilot/request/tools/workflow-context' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { buildVfsFolderPathMap, decodeVfsPathSegments, @@ -108,6 +109,7 @@ async function executeCopilotWorkflowTarget(params: { params.context.userId, { ...params.options, + abortSignal: params.context.abortSignal, billingAttribution: admission.billingAttribution, ...(trustedInitialResolvedSecretTraceProvenance ? { trustedInitialResolvedSecretTraceProvenance } @@ -358,6 +360,16 @@ function findDescendants(containerId: string, blocksById: Record ({ ensureWorkflowAccessMock: vi.fn(), getEffectiveBlockOutputPathsMock: vi.fn(), hasTriggerCapabilityMock: vi.fn(), getBlockMock: vi.fn(), + listCustomToolsMock: vi.fn(), + listWorkspaceCustomToolsMock: vi.fn(), + discoverMcpToolsMock: vi.fn(), })) const loadWorkflowFromNormalizedTablesMock = @@ -44,13 +50,23 @@ vi.mock('@/blocks/registry', () => ({ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -import { executeGetBlockOutputs } from './queries' +vi.mock('@/lib/workflows/custom-tools/operations', () => ({ + listCustomTools: listCustomToolsMock, + listWorkspaceCustomTools: listWorkspaceCustomToolsMock, +})) + +vi.mock('@/lib/mcp/service', () => ({ + mcpService: { discoverTools: discoverMcpToolsMock }, +})) + +import { executeGetBlockOutputs, executeGetWorkflowData } from './queries' describe('executeGetBlockOutputs', () => { beforeEach(() => { vi.clearAllMocks() ensureWorkflowAccessMock.mockResolvedValue({ workflow: { id: 'wf-1', userId: 'user-1', workspaceId: 'ws-1' }, + workspaceId: 'ws-1', }) getWorkflowByIdMock.mockResolvedValue({ variables: {} }) getBlockMock.mockReturnValue({ category: 'core' }) @@ -111,4 +127,51 @@ describe('executeGetBlockOutputs', () => { variables: [], }) }) + + it('lists only workspace custom tools for a credentialless context', async () => { + listWorkspaceCustomToolsMock.mockResolvedValue([ + { + id: 'tool-workspace', + title: 'Workspace tool', + schema: { function: { name: 'workspace_tool', description: 'Shared', parameters: {} } }, + }, + ]) + + const result = await executeGetWorkflowData({ workflowId: 'wf-1', data_type: 'custom_tools' }, { + workflowId: 'wf-1', + userId: 'user-1', + workspaceId: 'ws-1', + secretActorUserId: null, + } as any) + + expect(result.success).toBe(true) + expect(listWorkspaceCustomToolsMock).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) + expect(listCustomToolsMock).not.toHaveBeenCalled() + expect(result.output).toEqual({ + customTools: [ + { + id: 'tool-workspace', + title: 'Workspace tool', + functionName: 'workspace_tool', + description: 'Shared', + parameters: {}, + }, + ], + }) + }) + + it('does not discover MCP tools for a credentialless context', async () => { + const result = await executeGetWorkflowData({ workflowId: 'wf-1', data_type: 'mcp_tools' }, { + workflowId: 'wf-1', + userId: 'key-creator', + workspaceId: 'ws-1', + secretActorUserId: null, + } as any) + + expect(result).toEqual({ + success: false, + error: 'MCP tools are not available without credential access.', + }) + expect(discoverMcpToolsMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 41e1de535c3..a04b28eeb3c 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -7,7 +7,7 @@ import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' +import { listCustomTools, listWorkspaceCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, @@ -51,7 +51,7 @@ export async function executeGetWorkflowRunOptions( return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { @@ -132,7 +132,7 @@ export async function executeGetWorkflowData( const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess( workflowId, - context.userId + context ) if (dataType === 'global_variables') { @@ -152,10 +152,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const toolsRows = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + const toolsRows = + context.secretActorUserId === null + ? await listWorkspaceCustomTools({ workspaceId }) + : await listCustomTools({ userId: context.userId, workspaceId }) const customToolsData = toolsRows.map((tool) => { const schema = tool.schema as Record | null @@ -176,6 +176,12 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } + if (context.secretActorUserId === null) { + return { + success: false, + error: 'MCP tools are not available without credential access.', + } + } const tools = await mcpService.discoverTools(context.userId, workspaceId, false) const mcpTools = tools.map((tool) => ({ name: String(tool.name || ''), @@ -219,7 +225,7 @@ export async function executeGetBlockOutputs( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { @@ -307,7 +313,7 @@ export async function executeGetBlockUpstreamReferences( if (!Array.isArray(params.blockIds) || params.blockIds.length === 0) { return { success: false, error: 'blockIds array is required' } } - await ensureWorkflowAccess(workflowId, context.userId) + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { @@ -503,16 +509,19 @@ export async function executeGetDeployedWorkflowState( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) + const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context) try { const deployedState = await loadDeployedWorkflowState(workflowId) - const formatted = formatNormalizedWorkflowForCopilot({ - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops as Record, - parallels: deployedState.parallels as Record, - }) + const formatted = formatNormalizedWorkflowForCopilot( + { + blocks: deployedState.blocks, + edges: deployedState.edges, + loops: deployedState.loops as Record, + parallels: deployedState.parallels as Record, + }, + { secretless: context.secretActorUserId === null } + ) return { success: true, diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts new file mode 100644 index 00000000000..b53645a2ad3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' + +const { routeExecutionMock } = vi.hoisted(() => ({ routeExecutionMock: vi.fn() })) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution: routeExecutionMock })) + +import { createServerToolHandler } from './server-tool-adapter' + +describe('createServerToolHandler', () => { + it('propagates the secretless actor policy to server tools', async () => { + routeExecutionMock.mockResolvedValue({ success: true }) + const userStopController = new AbortController() + + await createServerToolHandler('edit_workflow')( + { workflowId: 'workflow-1' }, + { + userId: 'key-creator', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + secretActorUserId: null, + userStopSignal: userStopController.signal, + } + ) + + expect(routeExecutionMock).toHaveBeenCalledWith( + 'edit_workflow', + { workflowId: 'workflow-1', workspaceId: 'workspace-1' }, + expect.objectContaining({ + userId: 'key-creator', + workspaceId: 'workspace-1', + secretActorUserId: null, + userStopSignal: userStopController.signal, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 359ed2a4023..9ca193819d2 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -20,11 +20,13 @@ export function createServerToolHandler(toolId: string): ToolHandler { workspaceId: context.workspaceId, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, + secretActorUserId: context.secretActorUserId, chatId: context.chatId, messageId: context.messageId, parentToolCallId: context.parentToolCallId, abortSignal: context.abortSignal, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, + userStopSignal: context.userStopSignal, }) const rec = @@ -45,6 +47,7 @@ export function createServerToolHandler(toolId: string): ToolHandler { toolId, error: projectToolErrorMessageForCopilot(message, context.resolvedSecretTraceRegistry), abortSignalAborted: context.abortSignal?.aborted ?? false, + userStopSignalAborted: context.userStopSignal?.aborted ?? false, }) return { success: false, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index b1db4a7482d..7b0585545f4 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -7,6 +7,8 @@ export interface ServerToolContext { workspaceId?: string billingAttribution?: BillingAttributionSnapshot userPermission?: string + /** Undefined uses the execution actor; null explicitly disables raw secret access. */ + secretActorUserId?: string | null chatId?: string messageId?: string /** diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts index f61622fafff..ebb22736e81 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts @@ -15,9 +15,25 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ generateSearchEmbedding: mockGenerateSearchEmbedding, })) -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { + normalizeDocsTopK, + searchDocumentationServerTool, +} from '@/lib/copilot/tools/server/docs/search-documentation' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +describe('documentation search result limit', () => { + it.each([ + { input: undefined, expected: 10 }, + { input: 0, expected: 10 }, + { input: -1, expected: 10 }, + { input: 1.5, expected: 10 }, + { input: 12, expected: 12 }, + { input: 10_000, expected: 50 }, + ])('normalizes $input to $expected', ({ input, expected }) => { + expect(normalizeDocsTopK(input)).toBe(expected) + }) +}) + describe('documentation search model boundary', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index 9226e27b369..bdeb2ba4f52 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -14,14 +14,24 @@ interface DocsSearchParams { } const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_DOCS_TOP_K = 10 +const MAX_DOCS_TOP_K = 50 + +export function normalizeDocsTopK(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 + ? Math.min(value, MAX_DOCS_TOP_K) + : DEFAULT_DOCS_TOP_K +} export const searchDocumentationServerTool: BaseServerTool = { name: SearchDocumentation.id, async execute(params: DocsSearchParams, context?: ServerToolContext): Promise { const logger = createLogger('SearchDocumentationServerTool') - const { query, topK = 10, threshold } = params + const { query, threshold } = params if (!query || typeof query !== 'string') throw new Error('query is required') + const topK = normalizeDocsTopK(params.topK) + logger.info('Executing docs search', { queryLength: query.length, topK }) const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index cd950eb3406..05dcca16886 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -39,7 +39,7 @@ export const createFileServerTool: BaseServerTool if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') const nested = params.args const path = params.path || (nested?.path as string) || '' diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2215e414f39..52b7042e949 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -312,7 +312,7 @@ export const workspaceFileServerTool: BaseServerTool ({ generateSearchEmbedding: vi.fn(), recordSearchEmbeddingUsage: vi.fn(), })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn(), performDeleteKnowledgeBase: mockPerformDeleteKnowledgeBase, @@ -90,7 +93,10 @@ vi.mock('@/app/api/knowledge/utils', () => ({ import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { + knowledgeBaseServerTool, + normalizeKnowledgeQueryTopK, +} from '@/lib/copilot/tools/server/knowledge/knowledge-base' import { createSingleDocument } from '@/lib/knowledge/documents/service' import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' @@ -123,6 +129,19 @@ const CONTEXT = { billingAttribution: BILLING_ATTRIBUTION, } +describe('knowledge query result limit', () => { + it.each([ + { input: undefined, expected: 5 }, + { input: 0, expected: 5 }, + { input: -1, expected: 5 }, + { input: 1.5, expected: 5 }, + { input: 12, expected: 12 }, + { input: 10_000, expected: 50 }, + ])('normalizes $input to $expected', ({ input, expected }) => { + expect(normalizeKnowledgeQueryTopK(input)).toBe(expected) + }) +}) + describe('knowledge base connector Copilot operations', () => { afterAll(() => { resetDbChainMock() @@ -266,6 +285,10 @@ describe('knowledge base query model boundary', () => { isBYOK: false, }) vi.mocked(executeKnowledgeSearch).mockResolvedValue([]) + mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ + imported: true, + documentMetadata: {}, + }) vi.mocked(recordSearchEmbeddingUsage).mockResolvedValue(undefined) mockImportKnowledgeSearchResultSecretProvenance.mockResolvedValue({ imported: true, @@ -431,17 +454,19 @@ describe('knowledge base add_file usage gate', () => { }) }) - function addFile() { + function addFile( + context: Parameters[1] = { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + } + ) { return knowledgeBaseServerTool.execute( { operation: 'add_file', args: { knowledgeBaseId: 'knowledge-base-1', filePaths: ['files/report.pdf'] }, }, - { - userId: 'external-admin', - workspaceId: 'workspace-paid', - billingAttribution: BILLING_ATTRIBUTION, - } + context ) } @@ -497,4 +522,72 @@ describe('knowledge base add_file usage gate', () => { }) expect(createSingleDocument).not.toHaveBeenCalled() }) + + it('keeps billing on the retained actor when authorization uses the workspace-key owner', async () => { + vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ + isExceeded: false, + } as Awaited>) + vi.mocked(resolveWorkspaceFileReference).mockResolvedValue(null) + + const result = await addFile({ + userId: 'workspace-key-owner', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + }) + + expect(result.success).toBe(false) + expect(checkAttributedUsageLimits).toHaveBeenCalledWith(BILLING_ATTRIBUTION) + }) +}) + +describe('knowledge base query billing identity', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockAssertBillingAttributionSnapshot.mockReturnValue(BILLING_ATTRIBUTION) + vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: true }) + vi.mocked(getKnowledgeBaseById).mockResolvedValue({ + id: 'knowledge-base-1', + name: 'Paid KB', + workspaceId: 'workspace-paid', + embeddingModel: 'text-embedding-3-small', + } as Awaited>) + vi.mocked(checkAttributedUsageLimits).mockResolvedValue({ isExceeded: false } as Awaited< + ReturnType + >) + vi.mocked(generateSearchEmbedding).mockResolvedValue({ + embedding: [0.1, 0.2], + isBYOK: false, + }) + vi.mocked(executeKnowledgeSearch).mockResolvedValue([]) + }) + + it('authorizes as the key owner but meters the frozen workspace billing actor', async () => { + const result = await knowledgeBaseServerTool.execute( + { + operation: 'query', + args: { knowledgeBaseId: 'knowledge-base-1', query: 'refund policy' }, + }, + { + userId: 'workspace-key-owner', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + } + ) + + expect(result.success).toBe(true) + expect(checkKnowledgeBaseAccess).toHaveBeenCalledWith( + 'knowledge-base-1', + 'workspace-key-owner', + 'workspace-paid' + ) + expect(recordSearchEmbeddingUsage).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + }) + ) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index e7271c7e5af..1a3ae9afdb2 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -60,6 +60,14 @@ import { } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseServerTool') +const DEFAULT_KNOWLEDGE_QUERY_TOP_K = 5 +const MAX_KNOWLEDGE_QUERY_TOP_K = 50 + +export function normalizeKnowledgeQueryTopK(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 + ? Math.min(value, MAX_KNOWLEDGE_QUERY_TOP_K) + : DEFAULT_KNOWLEDGE_QUERY_TOP_K +} function requireKnowledgeBillingAttribution( context: ServerToolContext, @@ -69,7 +77,8 @@ function requireKnowledgeBillingAttribution( throw new Error('Billing attribution is required for knowledge operations') } const attribution = assertBillingAttributionSnapshot(context.billingAttribution) - if (attribution.actorUserId !== context.userId || attribution.workspaceId !== workspaceId) { + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId + if (attribution.actorUserId !== billingActorUserId || attribution.workspaceId !== workspaceId) { throw new Error('Knowledge billing attribution does not match its actor and workspace') } return attribution @@ -120,6 +129,7 @@ export const knowledgeBaseServerTool: BaseServerTool).workspaceId as string | undefined) + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId const assertNotAborted = () => assertServerToolNotAborted( context, @@ -193,7 +203,11 @@ export const knowledgeBaseServerTool: BaseServerTool = [] for (const kbId of kbIds) { - const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId) + const writeAccess = await checkKnowledgeBaseWriteAccess( + kbId, + context.userId, + workspaceId + ) if (!writeAccess.hasAccess) { notFound.push(kbId) continue @@ -618,7 +646,8 @@ export const knowledgeBaseServerTool: BaseServerTool ({ mockUpdateColumnType: vi.fn(), @@ -37,6 +44,11 @@ const { mockBatchInsertRows: vi.fn(), mockReplaceTableRows: vi.fn(), mockAddWorkflowGroup: vi.fn(), + mockUpdateWorkflowGroup: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockCancelWorkflowGroupRuns: vi.fn(), + mockLoadWorkflowFromNormalizedTables: vi.fn(), + mockFlattenWorkflowOutputs: vi.fn(), mockCreateTable: vi.fn(), mockDeleteTable: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), @@ -48,6 +60,7 @@ const { mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), + mockEnsureWorkflowAccess: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -62,6 +75,10 @@ const { }, })) +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: mockEnsureWorkflowAccess, +})) + vi.mock('@sim/utils/id', () => ({ generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), generateShortId: vi.fn().mockReturnValue('short-id'), @@ -93,7 +110,20 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ addWorkflowGroupOutput: vi.fn(), deleteWorkflowGroup: vi.fn(), deleteWorkflowGroupOutput: vi.fn(), - updateWorkflowGroup: vi.fn(), + updateWorkflowGroup: mockUpdateWorkflowGroup, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + cancelWorkflowGroupRuns: mockCancelWorkflowGroupRuns, + runWorkflowColumn: mockRunWorkflowColumn, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mockLoadWorkflowFromNormalizedTables, +})) + +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ + flattenWorkflowOutputs: mockFlattenWorkflowOutputs, })) vi.mock('@/lib/table/columns/service', () => ({ @@ -169,6 +199,16 @@ function buildTable(overrides: Partial = {}): TableDefinition { } } +const WORKSPACE_KEY_BILLING_ATTRIBUTION: BillingAttributionSnapshot = { + actorUserId: 'workspace-system-actor', + workspaceId: 'workspace-1', + organizationId: null, + billedAccountUserId: 'workspace-system-actor', + billingEntity: { type: 'user', id: 'workspace-system-actor' }, + billingPeriod: { start: '2026-07-01', end: '2026-08-01' }, + payerSubscription: null, +} + /** Lets a runDetached microtask chain run before asserting on the work it dispatched. */ async function flushDetached(): Promise { await Promise.resolve() @@ -629,6 +669,138 @@ describe('userTableServerTool.list_enrichments', () => { }) }) +describe('userTableServerTool workspace-key execution billing', () => { + const context = { + userId: 'workspace-key-owner', + workspaceId: 'workspace-1', + billingAttribution: WORKSPACE_KEY_BILLING_ATTRIBUTION, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue( + buildTable({ + schema: { + columns: [ + { name: 'name', type: 'string', required: true }, + { name: 'age', type: 'number' }, + ], + workflowGroups: [ + { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + }, + ], + }, + }) + ) + mockEnsureWorkflowAccess.mockResolvedValue({ + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + }) + mockAddWorkflowGroup.mockResolvedValue(buildTable()) + mockUpdateWorkflowGroup.mockResolvedValue(buildTable()) + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ + blocks: { + 'block-1': { id: 'block-1', type: 'agent', name: 'Agent', subBlocks: {} }, + }, + edges: [], + }) + mockFlattenWorkflowOutputs.mockReturnValue([ + { + blockId: 'block-1', + blockName: 'Agent', + path: 'content', + leafType: 'string', + }, + ]) + }) + + it('charges manual workflow-column dispatch to the frozen billing actor', async () => { + const result = await userTableServerTool.execute( + { + operation: 'run_column', + args: { tableId: 'tbl_1', groupIds: ['group-1'], runMode: 'incomplete' }, + }, + context + ) + + expect(result.success).toBe(true) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'tbl_1', + workspaceId: 'workspace-1', + triggeredByUserId: 'workspace-system-actor', + }) + ) + }) + + it('does not run a table workflow group outside the trusted workspace', async () => { + mockEnsureWorkflowAccess.mockRejectedValueOnce(new Error('Workflow workflow-1 not found')) + + const result = await userTableServerTool.execute( + { + operation: 'run_column', + args: { tableId: 'tbl_1', groupIds: ['group-1'], runMode: 'incomplete' }, + }, + context + ) + + expect(result).toEqual({ + success: false, + message: 'Operation failed: Workflow workflow-1 not found', + }) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('keeps group mutation ownership separate from auto-run billing', async () => { + const result = await userTableServerTool.execute( + { + operation: 'add_workflow_group', + args: { + tableId: 'tbl_1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + autoRun: true, + }, + }, + context + ) + + expect(result.success).toBe(true) + expect(mockAddWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + autoRun: true, + }), + expect.any(String) + ) + }) + + it('preserves separate mutation and billing actors when enabling auto-run', async () => { + const result = await userTableServerTool.execute( + { + operation: 'update_workflow_group', + args: { tableId: 'tbl_1', groupId: 'group-1', autoRun: true }, + }, + context + ) + + expect(result.success).toBe(true) + expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( + expect.objectContaining({ + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + autoRun: true, + }), + expect.any(String) + ) + }) +}) + describe('userTableServerTool.add_enrichment', () => { beforeEach(() => { vi.clearAllMocks() @@ -703,7 +875,11 @@ describe('userTableServerTool.add_enrichment', () => { autoRun: true, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + { + userId: 'workspace-key-owner', + workspaceId: 'workspace-1', + billingAttribution: WORKSPACE_KEY_BILLING_ATTRIBUTION, + } ) expect(result.success).toBe(true) @@ -711,6 +887,8 @@ describe('userTableServerTool.add_enrichment', () => { const call = mockAddWorkflowGroup.mock.calls[0][0] expect(call.autoRun).toBe(true) expect(call.group.autoRun).toBe(true) + expect(call.actorUserId).toBe('workspace-key-owner') + expect(call.billingActorUserId).toBe('workspace-system-actor') }) it('rejects an unknown enrichment id', async () => { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 894bb88a171..5fb555d4ee0 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import { ensureWorkflowAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, @@ -280,8 +281,10 @@ async function dispatchUpdateJob(params: { * so the AI can discover valid picks instead of guessing. */ async function loadFlattenedWorkflowOutputs( - workflowId: string + workflowId: string, + context: ServerToolContext ): Promise { + await ensureWorkflowAccess(workflowId, context) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) return null const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ @@ -294,6 +297,21 @@ async function loadFlattenedWorkflowOutputs( return flattenWorkflowOutputs(blocks, normalized.edges ?? []) } +async function ensureWorkflowGroupAccess( + table: TableDefinition, + groupId: string, + context: ServerToolContext +): Promise { + const group = table.schema.workflowGroups?.find((candidate) => candidate.id === groupId) + if (!group) { + throw new Error(`Workflow group not found: ${groupId}`) + } + if (group.workflowId) { + await ensureWorkflowAccess(group.workflowId, context) + } + return group +} + /** * Validates a list of `(blockId, path)` outputs against the live workflow. * Returns `null` on success; on failure returns an error message that lists @@ -426,6 +444,7 @@ export const userTableServerTool: BaseServerTool const { operation, args = {} } = params const workspaceId = context.workspaceId || ((args as Record).workspaceId as string | undefined) + const billingActorUserId = context.billingAttribution?.actorUserId ?? context.userId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') @@ -1824,7 +1843,7 @@ export const userTableServerTool: BaseServerTool message: 'workflowId is required for list_workflow_outputs', } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const flattened = await loadFlattenedWorkflowOutputs(workflowId, context) if (!flattened) { return { success: false, @@ -1873,7 +1892,7 @@ export const userTableServerTool: BaseServerTool } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const flattened = await loadFlattenedWorkflowOutputs(workflowId, context) if (!flattened) { return { success: false, @@ -1927,7 +1946,14 @@ export const userTableServerTool: BaseServerTool // can opt in by passing `autoRun: true`. const autoRun = args.autoRun === true const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, + { + tableId: args.tableId, + group, + outputColumns, + autoRun, + actorUserId: context.userId, + billingActorUserId, + }, requestId ) signalTableSchemaChanged(args.tableId) @@ -1952,22 +1978,17 @@ export const userTableServerTool: BaseServerTool if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } + const requestedWorkflowId = args.workflowId as string | undefined + if (requestedWorkflowId) { + await ensureWorkflowAccess(requestedWorkflowId, context) + } + const existingGroup = await ensureWorkflowGroupAccess(tableForUpdate, groupId, context) const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined if (updateOutputs && updateOutputs.length > 0) { // Resolve which workflow these outputs apply to: explicit override // wins, else the existing group's workflowId. - const existingGroup = tableForUpdate.schema.workflowGroups?.find( - (g) => g.id === groupId - ) - const targetWorkflowId = - (args.workflowId as string | undefined) ?? existingGroup?.workflowId - if (!targetWorkflowId) { - return { - success: false, - message: `Cannot validate outputs — workflow group ${groupId} not found and no workflowId provided`, - } - } - const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId) + const targetWorkflowId = requestedWorkflowId ?? existingGroup.workflowId + const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId, context) if (!flattened) { return { success: false, @@ -1990,7 +2011,8 @@ export const userTableServerTool: BaseServerTool tableId: args.tableId, groupId, actorUserId: context.userId, - workflowId: args.workflowId as string | undefined, + billingActorUserId, + workflowId: requestedWorkflowId, name: args.name as string | undefined, dependencies: args.dependencies as WorkflowGroupDependencies | undefined, outputs: updateOutputs, @@ -2050,6 +2072,7 @@ export const userTableServerTool: BaseServerTool if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) { return { success: false, message: `Table not found: ${args.tableId}` } } + await ensureWorkflowGroupAccess(tableForAdd, groupId, context) const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await addWorkflowGroupOutput( @@ -2122,6 +2145,13 @@ export const userTableServerTool: BaseServerTool message: `Invalid runMode "${runMode}". Must be "all" or "incomplete"`, } } + const tableForRun = await getTableById(args.tableId) + if (!tableForRun || tableForRun.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + await Promise.all( + groupIds.map((groupId) => ensureWorkflowGroupAccess(tableForRun, groupId, context)) + ) const rawRowIds = args.rowIds as unknown let rowIds: string[] | undefined if (rawRowIds !== undefined) { @@ -2146,7 +2176,7 @@ export const userTableServerTool: BaseServerTool mode: runMode, rowIds, requestId, - triggeredByUserId: context.userId, + triggeredByUserId: billingActorUserId, }) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { @@ -2303,7 +2333,14 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, + { + tableId: args.tableId, + group, + outputColumns, + autoRun, + actorUserId: context.userId, + billingActorUserId, + }, requestId ) signalTableSchemaChanged(args.tableId) diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts index e6b159a3da4..50a7c86e59a 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts @@ -50,7 +50,11 @@ describe('setEnvironmentVariablesServerTool', () => { } ) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'write') + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith( + 'ws-1', + { userId: 'user-1', workspaceId: 'ws-1' }, + 'write' + ) expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled() expect(result.scope).toBe('workspace') diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index daa8c19fe80..357a814c040 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -57,24 +57,23 @@ function normalizeVariables( async function resolveWorkspaceId( params: SetEnvironmentVariablesParams, - context: ServerToolContext | undefined, - userId: string + context: ServerToolContext ): Promise { if (params.workflowId) { - const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write') + const { workflow } = await ensureWorkflowAccess(params.workflowId, context, 'write') if (!workflow.workspaceId) { throw new Error(`Workflow ${params.workflowId} is not associated with a workspace`) } return workflow.workspaceId } - const workspaceId = params.workspaceId ?? context?.workspaceId + const workspaceId = context.workspaceId ?? params.workspaceId if (workspaceId) { - await ensureWorkspaceAccess(workspaceId, userId, 'write') + await ensureWorkspaceAccess(workspaceId, context, 'write') return workspaceId } - return getDefaultWorkspaceId(userId) + return getDefaultWorkspaceId(context.userId) } export const setEnvironmentVariablesServerTool: BaseServerTool< @@ -108,7 +107,7 @@ export const setEnvironmentVariablesServerTool: BaseServerTool< let resolvedWorkspaceId: string | undefined if (scope === 'workspace') { - resolvedWorkspaceId = await resolveWorkspaceId(params, context, authenticatedUserId) + resolvedWorkspaceId = await resolveWorkspaceId(params, context) workspaceUpdated = await upsertWorkspaceEnvVars( resolvedWorkspaceId, validatedVariables, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts new file mode 100644 index 00000000000..f3b1bc2d217 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + ensureWorkflowAccessMock, + applyOperationsToWorkflowStateMock, + saveWorkflowToNormalizedTablesMock, + assertWorkflowMutableMock, + validateWorkflowStateMock, + dbUpdateMock, + dbSetMock, + dbWhereMock, +} = vi.hoisted(() => { + const dbWhereMock = vi.fn() + const dbSetMock = vi.fn(() => ({ where: dbWhereMock })) + const dbUpdateMock = vi.fn(() => ({ set: dbSetMock })) + return { + ensureWorkflowAccessMock: vi.fn(), + applyOperationsToWorkflowStateMock: vi.fn(), + saveWorkflowToNormalizedTablesMock: vi.fn(), + assertWorkflowMutableMock: vi.fn(), + validateWorkflowStateMock: vi.fn(), + dbUpdateMock, + dbSetMock, + dbWhereMock, + } +}) + +vi.mock('@sim/db', () => ({ db: { update: dbUpdateMock } })) +vi.mock('@sim/db/schema', () => ({ + workflow: { id: 'id', lastSynced: 'lastSynced', updatedAt: 'updatedAt' }, +})) +vi.mock('drizzle-orm', () => ({ eq: vi.fn((left, right) => [left, right]) })) +vi.mock('@sim/platform-authz/workflow', () => ({ + assertWorkflowMutable: assertWorkflowMutableMock, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: vi.fn(async () => true), +})) +vi.mock('@/lib/copilot/block-visibility', () => ({ + getBlockVisibilityForCopilot: vi.fn(async () => null), +})) +vi.mock('@/lib/copilot/sim-sandbox-projection', () => ({ + operationsReferenceSimSandbox: vi.fn(() => false), +})) +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: ensureWorkflowAccessMock, +})) +vi.mock('@/lib/core/config/env', () => ({ env: { INTERNAL_API_SECRET: 'internal-secret' } })) +vi.mock('@/lib/core/utils/urls', () => ({ getSocketServerUrl: () => 'http://socket.test' })) +vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', () => ({ + MAX_PLAN_REQUIRED: 'Upgrade required', +})) +vi.mock('@/lib/workflows/autolayout', () => ({ + applyTargetedLayout: vi.fn((blocks) => blocks), + getTargetedLayoutImpact: vi.fn(() => ({ + layoutBlockIds: [], + resizedBlockIds: [], + shiftSourceBlockIds: [], + })), + transferBlockHeights: vi.fn(), +})) +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: vi.fn(async () => ({ saved: 0, errors: [] })), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: vi.fn(), + saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, +})) +vi.mock('@/lib/workflows/sanitization/validation', () => ({ + validateWorkflowState: validateWorkflowStateMock, +})) +vi.mock('@/blocks/visibility/server-context', () => ({ + withBlockVisibility: vi.fn(async (_visibility, execute) => execute()), +})) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: vi.fn(async () => null), +})) +vi.mock('@/stores/workflows/workflow/utils', () => ({ + generateLoopBlocks: vi.fn(() => ({})), + generateParallelBlocks: vi.fn(() => ({})), +})) +vi.mock('@/stores/workflows/workflow/validation', () => ({ normalizeWorkflowState: vi.fn() })) +vi.mock('./engine', () => ({ + applyOperationsToWorkflowState: applyOperationsToWorkflowStateMock, +})) +vi.mock('./lint', () => ({ + collectWorkflowFieldIssues: vi.fn(() => []), + formatWorkflowLintMessage: vi.fn(() => ''), + hasWorkflowLintIssues: vi.fn(() => false), + lintEditedWorkflowState: vi.fn(() => ({ + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + })), +})) +vi.mock('./validation', () => ({ + collectUnresolvedAgentToolReferences: vi.fn(async () => []), + collectUnresolvedReferences: vi.fn(async () => []), + preValidateCredentialInputs: vi.fn(async (operations) => ({ + filteredOperations: operations, + errors: [], + })), + UNRESOLVABLE_AT_LINT_NOTE: 'unresolvable', +})) + +vi.unmock('@/blocks/registry') + +import { editWorkflowServerTool } from './index' + +const workflowState = { + blocks: { + request: { + id: 'request', + type: 'unknown-integration', + name: 'Request', + enabled: true, + subBlocks: { + apiKey: { id: 'apiKey', type: 'short-input', value: 'SENTINEL_API_KEY' }, + path: { id: 'path', type: 'short-input', value: '/users' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, +} + +describe('editWorkflowServerTool secretless projection', () => { + beforeEach(() => { + vi.clearAllMocks() + global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + }) + assertWorkflowMutableMock.mockResolvedValue(undefined) + applyOperationsToWorkflowStateMock.mockImplementation((state) => ({ + state, + validationErrors: [], + skippedItems: [], + })) + validateWorkflowStateMock.mockImplementation((state) => ({ + valid: true, + errors: [], + warnings: [], + sanitizedState: state, + })) + saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) + dbWhereMock.mockResolvedValue(undefined) + }) + + async function execute(secretActorUserId?: string | null) { + return editWorkflowServerTool.execute( + { + workflowId: 'workflow-1', + currentUserWorkflow: JSON.stringify(workflowState), + operations: [{ operation_type: 'edit', block_id: 'request', params: {} }], + }, + { + userId: 'key-creator', + workspaceId: 'workspace-1', + secretActorUserId, + } + ) as Promise> + } + + it('redacts credentials from the returned state in secretless mode', async () => { + const result = await execute(null) + + expect(result.workflowState.blocks.request.subBlocks.apiKey.value).toBeNull() + expect(result.workflowState.blocks.request.subBlocks.path.value).toBe('/users') + expect(JSON.stringify(result)).not.toContain('SENTINEL_API_KEY') + }) + + it('preserves the existing returned state for a user-backed chat', async () => { + const result = await execute('user-1') + + expect(result.workflowState.blocks.request.subBlocks.apiKey.value).toBe('SENTINEL_API_KEY') + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 24c1510833b..f1b08cc1795 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -1,21 +1,20 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertWorkflowMutable, - authorizeWorkflowByWorkspacePermission, -} from '@sim/platform-authz/workflow' +import { assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' import { operationsReferenceSimSandbox } from '@/lib/copilot/sim-sandbox-projection' +import { ensureWorkflowAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { projectWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' @@ -106,19 +105,12 @@ export const editWorkflowServerTool: BaseServerTool throw new Error('Unauthorized workflow access') } - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: context.userId, - action: 'write', - }) - if (!authorization.allowed) { - throw new Error(authorization.message || 'Unauthorized workflow access') - } + const { workflow } = await ensureWorkflowAccess(workflowId, context, 'write') await assertWorkflowMutable(workflowId) - const workspaceId = authorization.workflow?.workspaceId ?? undefined - const workflowName = authorization.workflow?.name ?? undefined + const workspaceId = workflow.workspaceId ?? undefined + const workflowName = workflow.name ?? undefined if ( operationsReferenceSimSandbox(operations) && @@ -403,11 +395,16 @@ export const editWorkflowServerTool: BaseServerTool const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined + const outputWorkflowState = projectWorkflowStateForCopilot( + { ...finalWorkflowState, blocks: layoutedBlocks }, + { secretless: context.secretActorUserId === null } + ) + return { success: true, workflowId, workflowName: workflowName ?? 'Workflow', - workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, + workflowState: outputWorkflowState, workflowLint, ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 9ab073f7dd8..804d86eccf4 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -69,7 +69,7 @@ const queryLogsArgsSchema = z.discriminatedUnion('view', [ type QueryLogsArgs = z.infer function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { - const workspaceId = args.workspaceId ?? context?.workspaceId + const workspaceId = context?.workspaceId ?? args.workspaceId if (!workspaceId) { throw new Error('workspaceId is required') } diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts new file mode 100644 index 00000000000..ed1a15f8c7e --- /dev/null +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import { formatNormalizedWorkflowForCopilot } from './workflow-utils' + +vi.unmock('@/blocks/registry') + +describe('formatNormalizedWorkflowForCopilot', () => { + it('redacts credentials from secretless deployed-state projections', () => { + const formatted = formatNormalizedWorkflowForCopilot( + { + blocks: { + slack: { + id: 'slack', + type: 'slack', + name: 'Slack', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-private' }, + manualCredential: { + id: 'manualCredential', + type: 'short-input', + value: 'cred-private-advanced', + }, + message: { id: 'message', type: 'long-input', value: 'hello' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + { secretless: true } + ) + + expect(formatted).not.toContain('cred-private') + expect(formatted).toContain('hello') + }) +}) diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts index 07c1d8f54c8..e119a074b4b 100644 --- a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts +++ b/apps/sim/lib/copilot/tools/shared/workflow-utils.ts @@ -1,3 +1,4 @@ +import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' import { type CopilotSanitizationOptions, sanitizeForCopilot, @@ -10,9 +11,23 @@ type CopilotWorkflowState = { parallels?: Record } +type CopilotWorkflowProjectionOptions = CopilotSanitizationOptions & { secretless?: boolean } + +export function projectWorkflowStateForCopilot( + state: T, + options?: CopilotWorkflowProjectionOptions +): T { + return options?.secretless + ? (sanitizeWorkflowForSharing(state, { + preserveEnvVars: false, + preserveWorkspaceReferences: true, + }) as T) + : state +} + export function formatWorkflowStateForCopilot( state: CopilotWorkflowState, - options?: CopilotSanitizationOptions + options?: CopilotWorkflowProjectionOptions ): string { const workflowState = { blocks: state.blocks || {}, @@ -20,13 +35,14 @@ export function formatWorkflowStateForCopilot( loops: state.loops || {}, parallels: state.parallels || {}, } - const sanitized = sanitizeForCopilot(workflowState, options) + const credentialSafeState = projectWorkflowStateForCopilot(workflowState, options) + const sanitized = sanitizeForCopilot(credentialSafeState as typeof workflowState, options) return JSON.stringify(sanitized, null, 2) } export function formatNormalizedWorkflowForCopilot( normalized: CopilotWorkflowState | null | undefined, - options?: CopilotSanitizationOptions + options?: CopilotWorkflowProjectionOptions ): string | null { if (!normalized) return null return formatWorkflowStateForCopilot(normalized, options) diff --git a/apps/sim/lib/copilot/tools/subagent-display.ts b/apps/sim/lib/copilot/tools/subagent-display.ts new file mode 100644 index 00000000000..d074f6878fb --- /dev/null +++ b/apps/sim/lib/copilot/tools/subagent-display.ts @@ -0,0 +1,29 @@ +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +/** Canonical user-facing labels for Mothership subagent lanes. */ +export const SUBAGENT_LABELS: Readonly> = { + workflow: 'Workflow Agent', + debug: 'Debug Agent', + deploy: 'Deploy Agent', + auth: 'Auth Agent', + research: 'Research Agent', + knowledge: 'Knowledge Agent', + table: 'Table Agent', + custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', + search: 'Search Agent', + superagent: 'Superagent', + run: 'Run Agent', + agent: 'Tools Agent', + scheduled_task: 'Scheduled Task Agent', + /** Backward-compatible label for historical transcripts. */ + job: 'Job Agent', + file: 'File Agent', + media: 'Media Agent', + browser: 'Browser Agent', +} as const + +/** Resolves a server-owned subagent id without exposing raw identifier casing. */ +export function getSubagentDisplayTitle(agentId: string): string { + return SUBAGENT_LABELS[agentId] ?? humanizeToolName(agentId || 'subagent') +} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 9fd352aff15..3f33f9df299 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -71,6 +71,21 @@ describe('humanizeToolName', () => { }) describe('getToolDisplayTitle natural-language coverage', () => { + it('uses the same glanceable target names as the web read row', () => { + expect(getToolDisplayTitle('read', { path: 'workflows/Folder/forceful-arm/state.json' })).toBe( + 'Reading forceful-arm' + ) + expect(getToolDisplayTitle('read', { path: 'files/Reports/Q4%20Report.pdf/content' })).toBe( + 'Reading Q4 Report.pdf' + ) + expect(getToolDisplayTitle('read', { path: 'components/blocks/gmail_v2.json' }, 'Gmail')).toBe( + 'Reading Gmail' + ) + expect( + getToolDisplayTitle('read', { path: 'components/integrations/gmail/send.json' }, 'Gmail') + ).toBe('Reading Gmail') + }) + it('gives gerund titles to tools that previously fell through to humanize', () => { expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API') expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index e81ab9a54e2..92c1eb5e262 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,6 @@ import { stripVersionSuffix } from '@sim/utils/string' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' /** * Single source of truth for copilot tool-call display titles. @@ -433,6 +435,62 @@ function workspaceFileTitle(args: ToolArgs): string { return `${verb} ${title}` } +const READ_FILE_FACET_LABELS: Record = { + content: '', + 'meta.json': 'metadata for', + style: 'style details for', + 'compiled-check': 'the final file check for', +} + +function stripReadTargetExtension(value: string): string { + return value.replace(/\.[^/.]+$/, '') +} + +function readResourceLeaf(segments: string[]): string { + const lastSegment = segments.at(-1) ?? '' + if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) { + return segments.at(-2) ?? lastSegment + } + return lastSegment +} + +function describeFileReadTarget(segments: string[]): string { + const lastSegment = segments.at(-1) ?? '' + const facetLabel = READ_FILE_FACET_LABELS[lastSegment] + if (facetLabel !== undefined && segments.length > 2) { + const fileName = segments.at(-2) ?? '' + return facetLabel ? `${facetLabel} ${fileName}` : fileName + } + return lastSegment +} + +/** Resolves the glanceable VFS target shared by web and public chat tool rows. */ +export function describeReadTarget( + path: string | undefined, + resolvedBlockName?: string +): string | undefined { + if (!path) return undefined + if (resolvedBlockName) return resolvedBlockName + + const segments = path + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + .map(decodeVfsSegmentSafe) + if (segments.length === 0) return undefined + + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType) { + return humanizeDisplayIdentifier(stripReadTargetExtension(segments.at(-1) ?? ''), 'sentence') + } + if (resourceType === 'file') return describeFileReadTarget(segments) + if (resourceType === 'workflow') { + return stripReadTargetExtension(readResourceLeaf(segments)) + } + + return stripReadTargetExtension(segments[1] ?? segments.at(-1) ?? '') +} + /** Static fallback titles for tools without an argument-aware title. */ const TOOL_TITLES: Record = { // Gateway rows brand from the streamed toolId as soon as it resolves; this @@ -664,7 +722,11 @@ function terminalTitle(args: ToolArgs): string { * cases come first, then the static map, then a humanized fallback. This never * returns an empty string. */ -export function getToolDisplayTitle(name: string, args?: Record): string { +export function getToolDisplayTitle( + name: string, + args?: Record, + resolvedReadTargetName?: string +): string { const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) if (mcpToolMatch?.[1]) { return humanizeToolName(mcpToolMatch[1]) @@ -945,6 +1007,8 @@ export function getToolDisplayTitle(name: string, args?: Record if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { return 'Validating workflow state' } + const target = describeReadTarget(stringArg(args, 'path'), resolvedReadTargetName) + if (target) return `Reading ${target}` break } case 'workspace_file': diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 5e86b445895..3544b5ef5fd 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -18,6 +18,7 @@ import { serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, + serializeMcpServer, serializeSandbox, serializeSandboxCatalog, serializeTableMeta, @@ -66,6 +67,20 @@ describe('VFS metadata serializers', () => { expect(deployment).toEqual({ api: { isDeployed: false } }) }) + it('omits an MCP URL when the caller projects a secretless server', () => { + const server = JSON.parse( + serializeMcpServer({ + id: 'mcp-1', + name: 'Private MCP', + transport: 'sse', + enabled: true, + connectionStatus: 'connected', + }) + ) + + expect(server).not.toHaveProperty('url') + }) + it('includes the authoritative file update timestamp', () => { const metadata = JSON.parse( serializeFileMeta({ diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0fc76e89926..f4924f01f90 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -972,7 +972,7 @@ export function serializeCustomTool(tool: { export function serializeMcpServer(server: { id: string name: string - url: string | null + url?: string | null transport: string | null enabled: boolean connectionStatus: string | null diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 4a7b340a07c..7a9a67e876d 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -2,7 +2,6 @@ import { trace } from '@opentelemetry/api' import { db } from '@sim/db' import { chat as chatTable, - customTools as customToolsTable, document, folder as folderTable, knowledgeBaseTagDefinitions, @@ -16,7 +15,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' +import { and, desc, eq, inArray, isNotNull, isNull } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { @@ -47,6 +46,7 @@ import { lintEditedWorkflowState, } from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' +import { formatWorkflowStateForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' @@ -128,9 +128,12 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { + getCustomToolById, + getWorkspaceCustomTool, + listCustomToolSummaries, +} from '@/lib/workflows/custom-tools/operations' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' import { @@ -582,6 +585,7 @@ export class WorkspaceVFS { >() private deploymentCache = new Map>() private _workspaceId = '' + private _secretless = false /** * Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block * still resolves/renders). Populated by {@link materializeCustomBlocks}; used to @@ -739,7 +743,7 @@ export class WorkspaceVFS { async materialize( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; secretless?: boolean } ): Promise { const start = Date.now() this.files = new Map() @@ -748,6 +752,7 @@ export class WorkspaceVFS { this.deploymentCache = new Map() this._customBlockTypes = null this._workspaceId = workspaceId + this._secretless = options?.secretless === true // Per-phase wall-clock, stamped on the span so a slow materialize in a // trace names its bottleneck instead of showing up as unattributed dead @@ -1577,15 +1582,15 @@ export class WorkspaceVFS { // loadWorkflowFromNormalizedTables returns null for a zero-block // workflow; it still exists and must be readable, so emit an // empty-but-valid state.json rather than a 404. - const sanitized = normalized - ? sanitizeForCopilot({ + const state = normalized + ? { blocks: normalized.blocks, edges: normalized.edges, loops: normalized.loops, parallels: normalized.parallels, - } as any) - : sanitizeForCopilot({ blocks: {}, edges: [], loops: {}, parallels: {} } as any) - return JSON.stringify(sanitized, null, 2) + } + : { blocks: {}, edges: [], loops: {}, parallels: {} } + return formatWorkflowStateForCopilot(state, { secretless: this._secretless }) }) this.registerLazy(`${prefix}lint.json`, async () => { @@ -2024,26 +2029,21 @@ export class WorkspaceVFS { ): Promise> { try { // Metadata only — tool code can be large; keep it out of the eager map. - // Visibility matches listCustomTools: workspace tools + legacy user-owned. - const toolRows = await db - .select({ - id: customToolsTable.id, - title: customToolsTable.title, - }) - .from(customToolsTable) - .where( - or( - eq(customToolsTable.workspaceId, workspaceId), - and(isNull(customToolsTable.workspaceId), eq(customToolsTable.userId, userId)) - ) - ) - .orderBy(desc(customToolsTable.createdAt)) + // Normal chats retain legacy user-owned tools. Secretless projections are + // workspace-only so a shared key cannot inherit its creator's private code. + const toolRows = await listCustomToolSummaries({ + userId, + workspaceId, + workspaceOnly: this._secretless, + }) for (const tool of toolRows) { const safeName = sanitizeName(tool.title) const toolId = tool.id const load = async () => { - const full = await getCustomToolById({ toolId, userId, workspaceId }) + const full = this._secretless + ? await getWorkspaceCustomTool({ toolId, workspaceId }) + : await getCustomToolById({ toolId, userId, workspaceId }) if (!full) return null return serializeCustomTool({ id: full.id, @@ -2134,7 +2134,7 @@ export class WorkspaceVFS { serializeMcpServer({ id: server.id, name: server.name, - url: server.url, + ...(this._secretless ? {} : { url: server.url }), transport: server.transport, enabled: server.enabled, connectionStatus: server.connectionStatus, @@ -2142,7 +2142,12 @@ export class WorkspaceVFS { ) } - return servers.map((s) => ({ id: s.id, name: s.name, url: s.url, enabled: s.enabled })) + return servers.map((server) => ({ + id: server.id, + name: server.name, + enabled: server.enabled, + ...(this._secretless ? {} : { url: server.url }), + })) } catch (err) { logger.warn('Failed to materialize MCP servers', { workspaceId, @@ -2389,6 +2394,13 @@ export class WorkspaceVFS { oauthIntegrations: WorkspaceMdData['oauthIntegrations'] envVariables: WorkspaceMdData['envVariables'] }> { + if (this._secretless) { + this.files.set('environment/credentials.json', serializeCredentials([])) + this.files.set('environment/api-keys.json', serializeApiKeys([])) + this.files.set('environment/variables.json', serializeEnvironmentVariables([], [])) + return { oauthIntegrations: [], envVariables: [] } + } + try { const isWorkspaceAdmin = await hasWorkspaceAdminAccess(userId, workspaceId) const [envCredentials, oauthCredentials, apiKeyRows, envData, permissionConfig] = @@ -2488,7 +2500,7 @@ export class WorkspaceVFS { export async function getOrMaterializeVFS( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; secretless?: boolean } ): Promise { await assertActiveWorkspaceAccess(workspaceId, userId) const vfs = new WorkspaceVFS() diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index cc67764b07e..c1a554a036d 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -911,8 +911,14 @@ export interface AddWorkflowGroupData { * `true` (UI behavior). Mothership passes `false` so groups can be staged * without firing every dep-satisfied row. */ autoRun?: boolean - /** The member adding the group — billed/gated for the auto-run enrichment pass. */ + /** The member adding the group, retained for authorization/audit work. */ actorUserId?: string | null + /** + * Frozen billing actor for the post-add auto-run. This differs from + * `actorUserId` when a workspace API key authorizes tools as its owner but + * charges executions to the workspace system account. + */ + billingActorUserId?: string | null } /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ @@ -941,8 +947,10 @@ export interface UpdateWorkflowGroupData { type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ autoRun?: boolean - /** The member updating the group — billed/gated for any triggered re-run. */ + /** The member updating the group, retained for authorization/audit/backfill work. */ actorUserId?: string | null + /** Frozen billing actor for a false -> true auto-run transition. */ + billingActorUserId?: string | null } export interface DeleteWorkflowGroupData { diff --git a/apps/sim/lib/table/workflow-groups/service.test.ts b/apps/sim/lib/table/workflow-groups/service.test.ts new file mode 100644 index 00000000000..c34bfd300f5 --- /dev/null +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -0,0 +1,192 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { mockRunWorkflowColumn, mockWithLockedTable } = vi.hoisted(() => ({ + mockRunWorkflowColumn: vi.fn(), + mockWithLockedTable: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ + getTableById: vi.fn(), + withLockedTable: mockWithLockedTable, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + assertValidSchema: vi.fn(), + runWorkflowColumn: mockRunWorkflowColumn, + stripGroupDeps: vi.fn((group: unknown) => group), +})) + +vi.mock('@/lib/table/backfill-runner', () => ({ + maybeBackfillGroupOutputs: vi.fn(), +})) + +import { addWorkflowGroup, updateWorkflowGroup } from '@/lib/table/workflow-groups/service' + +function table(autoRun?: boolean): TableDefinition { + return { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [], + ...(autoRun === undefined + ? {} + : { + workflowGroups: [ + { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [], + autoRun, + }, + ], + }), + }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'workspace-key-owner', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } +} + +describe('workflow-group auto-run billing actor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) + mockWithLockedTable.mockImplementation( + async ( + _tableId: string, + callback: ( + value: TableDefinition, + transaction: { + update: () => { + set: () => { where: () => Promise } + } + execute: () => Promise + } + ) => Promise + ) => + callback(table(), { + update: () => ({ + set: () => ({ where: async () => undefined }), + }), + execute: async () => undefined, + }) + ) + }) + + it('uses the frozen billing actor without replacing mutation ownership', async () => { + await addWorkflowGroup( + { + tableId: 'table-1', + group: { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + }, + outputColumns: [ + { + name: 'result', + type: 'string', + required: false, + unique: false, + workflowGroupId: 'group-1', + }, + ], + autoRun: true, + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + }, + 'request-1' + ) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + triggeredByUserId: 'workspace-system-actor', + }) + ) + }) + + it('preserves the existing member-attribution fallback for ordinary callers', async () => { + await addWorkflowGroup( + { + tableId: 'table-1', + group: { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], + }, + outputColumns: [ + { + name: 'result', + type: 'string', + required: false, + unique: false, + workflowGroupId: 'group-1', + }, + ], + autoRun: true, + actorUserId: 'interactive-member', + }, + 'request-2' + ) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ triggeredByUserId: 'interactive-member' }) + ) + }) + + it('uses the frozen billing actor when enabling auto-run on an existing group', async () => { + mockWithLockedTable.mockImplementationOnce( + async ( + _tableId: string, + callback: ( + value: TableDefinition, + transaction: { + update: () => { + set: () => { where: () => Promise } + } + execute: () => Promise + } + ) => Promise + ) => + callback(table(false), { + update: () => ({ + set: () => ({ where: async () => undefined }), + }), + execute: async () => undefined, + }) + ) + + await updateWorkflowGroup( + { + tableId: 'table-1', + groupId: 'group-1', + autoRun: true, + actorUserId: 'workspace-key-owner', + billingActorUserId: 'workspace-system-actor', + }, + 'request-3' + ) + + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + groupIds: ['group-1'], + triggeredByUserId: 'workspace-system-actor', + }) + ) + }) +}) diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index f15421c4c66..35617f8590a 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -38,6 +38,15 @@ import type { import { assertValidSchema, runWorkflowColumn, stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableWorkflowGroupsService') + +/** Keeps mutation ownership separate from the account charged for an auto-run. */ +function resolveTriggerBillingActor(data: { + actorUserId?: string | null + billingActorUserId?: string | null +}): string | null | undefined { + return data.billingActorUserId === undefined ? data.actorUserId : data.billingActorUserId +} + /** * Drops references to deleted blocks from every workflow group on every table * that targets the just-deployed workflow. Called from the workflow deploy @@ -213,7 +222,7 @@ export async function addWorkflowGroup( isManualRun: false, groupIds: [data.group.id], requestId, - triggeredByUserId: data.actorUserId, + triggeredByUserId: resolveTriggerBillingActor(data), }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) } @@ -591,7 +600,7 @@ export async function updateWorkflowGroup( isManualRun: false, groupIds: [data.groupId], requestId, - triggeredByUserId: data.actorUserId, + triggeredByUserId: resolveTriggerBillingActor(data), }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) ) diff --git a/apps/sim/lib/workflows/credentials/constants.ts b/apps/sim/lib/workflows/credentials/constants.ts new file mode 100644 index 00000000000..df9cac1f370 --- /dev/null +++ b/apps/sim/lib/workflows/credentials/constants.ts @@ -0,0 +1,8 @@ +/** Legacy and current subblock IDs that persist credential references. */ +export const CREDENTIAL_SUBBLOCK_IDS = new Set([ + 'credential', + 'manualCredential', + 'triggerCredentials', + 'customBotCredential', + 'manualBotCredential', +]) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts new file mode 100644 index 00000000000..60b027033f8 --- /dev/null +++ b/apps/sim/lib/workflows/credentials/credential-extractor.secretless.test.ts @@ -0,0 +1,293 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +vi.unmock('@/blocks/registry') + +function workflowState(): WorkflowState { + return { + blocks: { + slack: { + id: 'slack', + type: 'slack', + name: 'Slack', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-basic' }, + manualCredential: { + id: 'manualCredential', + type: 'short-input', + value: 'cred-advanced', + }, + botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-private' }, + channel: { id: 'channel', type: 'channel-selector', value: 'C_PRIVATE' }, + manualChannel: { + id: 'manualChannel', + type: 'short-input', + value: 'C_PRIVATE_ADVANCED', + }, + message: { id: 'message', type: 'long-input', value: 'hello' }, + }, + }, + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'slack', + params: { + credential: 'cred-tool', + oauthCredential: 'cred-tool-canonical', + channel: 'C_TOOL_PRIVATE', + message: 'keep me', + knowledgeBaseId: 'kb-workspace', + paginationToken: 'not-a-credential', + }, + }, + ], + }, + }, + }, + knowledge: { + id: 'knowledge', + type: 'knowledge', + name: 'Knowledge', + enabled: true, + subBlocks: { + knowledgeBaseId: { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + value: 'kb-workspace', + }, + }, + }, + googleDocs: { + id: 'googleDocs', + type: 'google_docs', + name: 'Google Docs', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'google-credential' }, + documentId: { id: 'documentId', type: 'file-selector', value: 'google-document' }, + manualDocumentId: { + id: 'manualDocumentId', + type: 'short-input', + value: 'google-document-manual', + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +describe('sanitizeWorkflowForSharing credential projection', () => { + it('clears canonical credential groups and credential-scoped selectors', () => { + const sanitized = sanitizeWorkflowForSharing(workflowState(), { + preserveWorkspaceReferences: true, + }) + const slack = sanitized.blocks?.slack + + expect(slack?.subBlocks.credential?.value).toBeNull() + expect(slack?.subBlocks.manualCredential?.value).toBeNull() + expect(slack?.subBlocks.botToken?.value).toBeNull() + expect(slack?.subBlocks.channel?.value).toBeNull() + expect(slack?.subBlocks.manualChannel?.value).toBeNull() + expect(slack?.subBlocks.message?.value).toBe('hello') + expect(sanitized.blocks?.knowledge.subBlocks.knowledgeBaseId?.value).toBe('kb-workspace') + expect(sanitized.blocks?.googleDocs.subBlocks.documentId?.value).toBeNull() + expect(sanitized.blocks?.googleDocs.subBlocks.manualDocumentId?.value).toBeNull() + }) + + it('removes credentials and account-scoped selectors from stored Agent tools', () => { + const sanitized = sanitizeWorkflowForSharing(workflowState(), { + preserveWorkspaceReferences: true, + }) + const tools = sanitized.blocks?.agent.subBlocks.tools?.value as Array<{ + params: Record + }> + + expect(tools[0].params).toEqual({ + message: 'keep me', + knowledgeBaseId: 'kb-workspace', + paginationToken: 'not-a-credential', + }) + }) + + it('uses registered metadata instead of treating non-secret GitLab switches as passwords', () => { + const state = { + blocks: { + gitlab: { + id: 'gitlab', + type: 'gitlab', + name: 'GitLab', + enabled: true, + subBlocks: { + userAdminPassword: { + id: 'userAdminPassword', + type: 'short-input', + value: 'SENTINEL_GITLAB_PASSWORD', + }, + resetPassword: { id: 'resetPassword', type: 'switch', value: true }, + forceRandomPassword: { id: 'forceRandomPassword', type: 'switch', value: true }, + unknownPassword: { + id: 'unknownPassword', + type: 'short-input', + value: 'SENTINEL_UNKNOWN_PASSWORD', + }, + }, + data: { + userAdminPassword: 'SENTINEL_GITLAB_DATA_PASSWORD', + resetPassword: true, + forceRandomPassword: true, + }, + }, + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'gitlab', + params: { + userAdminPassword: 'SENTINEL_STORED_GITLAB_PASSWORD', + resetPassword: true, + forceRandomPassword: true, + }, + }, + ], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState + + const sanitized = sanitizeWorkflowForSharing(state) + const gitlab = sanitized.blocks?.gitlab + const tools = sanitized.blocks?.agent.subBlocks.tools?.value as Array<{ + params: Record + }> + + expect(gitlab?.subBlocks.userAdminPassword?.value).toBeNull() + expect(gitlab?.subBlocks.unknownPassword?.value).toBeNull() + expect(gitlab?.subBlocks.resetPassword?.value).toBe(true) + expect(gitlab?.subBlocks.forceRandomPassword?.value).toBe(true) + expect(gitlab?.data).toEqual({ + userAdminPassword: null, + resetPassword: true, + forceRandomPassword: true, + }) + expect(tools[0].params).toEqual({ resetPassword: true, forceRandomPassword: true }) + }) + + it('redacts registered raw-secret fields and reactive credential dependents', () => { + const secretFields: Array<[string, string, unknown]> = [ + ['ssh', 'privateKey', 'SENTINEL_SSH_PRIVATE_KEY'], + ['sftp', 'privateKey', 'SENTINEL_SFTP_PRIVATE_KEY'], + ['pi', 'privateKey', 'SENTINEL_PI_PRIVATE_KEY'], + ['zoom', 'password', 'SENTINEL_ZOOM_PASSWORD'], + ['secrets_manager', 'secretValue', 'SENTINEL_SECRET_VALUE'], + ['browser_use', 'variables', [['API_KEY', 'SENTINEL_BROWSER_VARIABLE']]], + ['sts', 'webIdentityToken', 'SENTINEL_WEB_IDENTITY_TOKEN'], + ['sts', 'samlAssertion', 'SENTINEL_SAML_ASSERTION'], + ['sts', 'tokenCode', 'SENTINEL_TOKEN_CODE'], + ['discord', 'webhookToken', 'SENTINEL_WEBHOOK_TOKEN'], + ['codepipeline', 'approvalToken', 'SENTINEL_APPROVAL_TOKEN'], + ] + const blocks: Record = {} + for (const [index, [type, field, value]] of secretFields.entries()) { + const id = `${type}-${index}` + blocks[id] = { + id, + type, + name: type, + enabled: true, + subBlocks: { [field]: { id: field, type: 'short-input', value } }, + } + } + blocks.google = { + id: 'google', + type: 'google_docs', + name: 'Google Docs', + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'SENTINEL_CREDENTIAL' }, + impersonateUserEmail: { + id: 'impersonateUserEmail', + type: 'short-input', + value: 'SENTINEL_IMPERSONATED_EMAIL', + }, + }, + } + + const sanitized = sanitizeWorkflowForSharing({ + blocks, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState) + + expect(JSON.stringify(sanitized)).not.toContain('SENTINEL_') + expect(sanitized.blocks?.google.subBlocks.impersonateUserEmail?.value).toBeNull() + }) + + it('removes Function secret-mount policy from stored Agent tools', () => { + const state = { + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'function', + params: { + code: 'return 1', + language: 'javascript', + secretScope: 'all', + mountedSecrets: ['PRIVATE_API_KEY'], + }, + }, + ], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState + + const sanitized = sanitizeWorkflowForSharing(state) + const tools = sanitized.blocks?.agent.subBlocks.tools?.value as Array<{ + params: Record + }> + + expect(tools[0].params).toEqual({ code: 'return 1', language: 'javascript' }) + }) +}) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 8e9e99bd9c1..178d477784b 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,3 +1,4 @@ +import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { buildCanonicalIndex, @@ -77,6 +78,18 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ 'folderId', ]) +// Internal secretless Copilot views may retain resources owned by the current +// workspace. Provider-scoped selectors (channels, projects, external files and +// folders) stay redacted because they belong to a credential/account context. +const PRESERVABLE_WORKSPACE_TYPES = new Set([ + 'knowledge-base-selector', + 'knowledge-tag-filters', + 'document-selector', + 'document-tag-entry', + 'file-upload', + 'mcp-server-selector', +]) + /** * Extract required credentials from a workflow state * This analyzes all blocks and their subblocks to identify credential requirements @@ -248,6 +261,155 @@ interface SanitizedWorkflowState { [key: string]: unknown } +function dependencyFields(config: SubBlockConfig): string[] { + const { dependsOn } = config + const staticDependencies = !dependsOn + ? [] + : Array.isArray(dependsOn) + ? dependsOn + : [...(dependsOn.all ?? []), ...(dependsOn.any ?? [])] + return [...staticDependencies, ...(config.reactiveCondition?.watchFields ?? [])] +} + +function isCredentialKey(key: string): boolean { + const normalized = key.replace(/[_-]/g, '').replace(/\d+$/, '').toLowerCase() + return ( + normalized === 'auth' || + normalized === 'authorization' || + normalized.endsWith('credential') || + normalized.endsWith('credentialid') || + normalized.endsWith('apikey') || + normalized.endsWith('accesstoken') || + normalized.endsWith('refreshtoken') || + normalized.endsWith('idtoken') || + normalized.endsWith('authtoken') || + normalized.endsWith('bottoken') || + normalized.endsWith('bearertoken') || + normalized.endsWith('secret') || + normalized.endsWith('password') + ) +} + +/** + * Resolve credential fields and every credential-scoped dependent (for example + * a Slack channel selected under one OAuth account). Canonical groups are + * cleared as a unit so dormant advanced/manual values cannot survive. + */ +function credentialSensitiveSubBlockIds(subBlocks: SubBlockConfig[]): Set { + const sensitive = new Set() + const canonicalIndex = buildCanonicalIndex(subBlocks) + + const addCanonicalGroup = (subBlockId: string) => { + sensitive.add(subBlockId) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] + if (!canonicalId) return + sensitive.add(canonicalId) + const group = canonicalIndex.groupsById[canonicalId] + if (group?.basicId) sensitive.add(group.basicId) + for (const advancedId of group?.advancedIds ?? []) sensitive.add(advancedId) + } + + for (const config of subBlocks) { + if (config.type === 'oauth-input' || config.password === true) { + addCanonicalGroup(config.id) + } + } + + // Dependents can chain (credential -> project -> folder), so close over the + // dependency graph rather than clearing only the first level. + let changed = true + while (changed) { + changed = false + for (const config of subBlocks) { + if (sensitive.has(config.id)) continue + if (dependencyFields(config).some((field) => sensitive.has(field))) { + addCanonicalGroup(config.id) + changed = true + } + } + } + + return sensitive +} + +/** + * Resolve workspace-owned selectors and their canonical basic/advanced peers. + * Matching by field name alone is unsafe: common IDs such as `documentId` + * also identify provider-owned resources (for example Google Docs). + */ +function preservableWorkspaceSubBlockIds(subBlocks: SubBlockConfig[]): Set { + const preservable = new Set() + const canonicalIndex = buildCanonicalIndex(subBlocks) + + for (const config of subBlocks) { + if (!PRESERVABLE_WORKSPACE_TYPES.has(config.type)) continue + preservable.add(config.id) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[config.id] + if (!canonicalId) continue + preservable.add(canonicalId) + const group = canonicalIndex.groupsById[canonicalId] + if (group?.basicId) preservable.add(group.basicId) + for (const advancedId of group?.advancedIds ?? []) preservable.add(advancedId) + } + + return preservable +} + +function registeredSubBlockIds(subBlocks: SubBlockConfig[]): Set { + const registered = new Set() + for (const config of subBlocks) { + registered.add(config.id) + if (config.canonicalParamId) registered.add(config.canonicalParamId) + } + return registered +} + +function sanitizeStoredToolCredentials(value: unknown): unknown { + let tools: unknown[] + let wasJson = false + if (Array.isArray(value)) { + tools = value + } else if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) as unknown + if (!Array.isArray(parsed)) return value + tools = parsed + wasJson = true + } catch { + return value + } + } else { + return value + } + + const sanitized = tools.map((tool) => { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) return tool + const record = tool as Record + if (!record.params || typeof record.params !== 'object' || Array.isArray(record.params)) { + return tool + } + + const toolConfig = typeof record.type === 'string' ? getBlock(record.type) : undefined + const toolSubBlocks = toolConfig?.subBlocks ?? [] + const registeredParams = registeredSubBlockIds(toolSubBlocks) + const sensitive = credentialSensitiveSubBlockIds(toolSubBlocks) + const params = record.params as Record + const nextParams = Object.fromEntries( + Object.entries(params).filter(([key]) => { + if (record.type === 'function' && (key === 'secretScope' || key === 'mountedSecrets')) { + return false + } + if (sensitive.has(key)) return false + if (registeredParams.has(key)) return true + return !CREDENTIAL_SUBBLOCK_IDS.has(key) && !isCredentialKey(key) + }) + ) + return { ...record, params: nextParams } + }) + + return wasJson ? JSON.stringify(sanitized) : sanitized +} + /** * Sanitize workflow state by removing all credentials and workspace-specific data * This is used for both template creation and workflow export to ensure consistency @@ -259,6 +421,7 @@ export function sanitizeWorkflowForSharing( state: Partial | null | undefined, options: { preserveEnvVars?: boolean // Keep {{VAR}} references for export + preserveWorkspaceReferences?: boolean // Keep workspace-owned resource IDs for internal views } = {} ): SanitizedWorkflowState { const sanitized = structuredClone(state) as SanitizedWorkflowState @@ -274,6 +437,16 @@ export function sanitizeWorkflowForSharing( removeMalformedSubBlocks(block) const blockConfig = getBlock(block.type) + const blockConfigById = new Map( + (blockConfig?.subBlocks ?? []).map((subBlock) => [subBlock.id, subBlock]) + ) + const registeredIds = registeredSubBlockIds(blockConfig?.subBlocks ?? []) + const credentialSensitiveIds = blockConfig + ? credentialSensitiveSubBlockIds(blockConfig.subBlocks ?? []) + : new Set() + const preservableWorkspaceIds = blockConfig + ? preservableWorkspaceSubBlockIds(blockConfig.subBlocks ?? []) + : new Set() // Process subBlocks with config if (blockConfig) { @@ -281,12 +454,28 @@ export function sanitizeWorkflowForSharing( if (block.subBlocks?.[subBlockConfig.id]) { const subBlock = block.subBlocks[subBlockConfig.id] - // Clear OAuth credentials (type: 'oauth-input') - if (subBlockConfig.type === 'oauth-input') { + const preserveWorkspaceReference = + options.preserveWorkspaceReferences === true && + preservableWorkspaceIds.has(subBlockConfig.id) + const preserveSecretEnvRef = + subBlockConfig.password === true && + options.preserveEnvVars === true && + typeof subBlock?.value === 'string' && + subBlock.value.startsWith('{{') && + subBlock.value.endsWith('}}') + + // Clear credentials, their canonical peers, and selectors scoped to + // those credentials. Workspace-owned references may be retained for + // internal secretless Copilot projections. + if ( + credentialSensitiveIds.has(subBlockConfig.id) && + !preserveWorkspaceReference && + !preserveSecretEnvRef + ) { block.subBlocks[subBlockConfig.id]!.value = null } - // Clear secret fields (password: true) + // Secret fields may preserve an env reference only for explicit export. else if (subBlockConfig.password === true) { // Preserve environment variable references if requested if ( @@ -302,12 +491,18 @@ export function sanitizeWorkflowForSharing( } // Clear workspace-specific selectors - else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) { + else if ( + WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type) && + !preserveWorkspaceReference + ) { block.subBlocks[subBlockConfig.id]!.value = null } // Clear workspace-specific fields by ID - else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) { + else if ( + WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id) && + !preserveWorkspaceReference + ) { block.subBlocks[subBlockConfig.id]!.value = null } } @@ -317,8 +512,32 @@ export function sanitizeWorkflowForSharing( // Process subBlocks without config (fallback) if (block.subBlocks) { Object.entries(block.subBlocks).forEach(([key, subBlock]) => { + if (!subBlock) return + + if (key === 'tools' || subBlock.type === 'tool-input') { + subBlock.value = sanitizeStoredToolCredentials(subBlock.value) as SubBlockState['value'] + } + + const preserveSecretEnvRef = + blockConfigById.get(key)?.password === true && + options.preserveEnvVars === true && + typeof subBlock.value === 'string' && + subBlock.value.startsWith('{{') && + subBlock.value.endsWith('}}') + const isRegistered = registeredIds.has(key) + if ( + (credentialSensitiveIds.has(key) || + (!isRegistered && (CREDENTIAL_SUBBLOCK_IDS.has(key) || isCredentialKey(key)))) && + !preserveSecretEnvRef + ) { + subBlock.value = null + } + // Clear workspace-specific fields by key name - if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { + if ( + WORKSPACE_SPECIFIC_FIELDS.has(key) && + !(options.preserveWorkspaceReferences && preservableWorkspaceIds.has(key)) + ) { subBlock.value = null } }) @@ -327,12 +546,17 @@ export function sanitizeWorkflowForSharing( // Clear data field (for backward compatibility) if (block.data) { Object.entries(block.data).forEach(([key]) => { - // Clear anything that looks like credentials - if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { + const isSensitive = registeredIds.has(key) + ? credentialSensitiveIds.has(key) + : CREDENTIAL_SUBBLOCK_IDS.has(key) || isCredentialKey(key) + if (isSensitive) { block.data![key] = null } // Clear workspace-specific data - if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if ( + WORKSPACE_SPECIFIC_FIELDS.has(key) && + !(options.preserveWorkspaceReferences && preservableWorkspaceIds.has(key)) + ) { block.data![key] = null } }) diff --git a/apps/sim/lib/workflows/custom-tools/operations.ts b/apps/sim/lib/workflows/custom-tools/operations.ts index 32e2ba8bf3c..e77f2651fec 100644 --- a/apps/sim/lib/workflows/custom-tools/operations.ts +++ b/apps/sim/lib/workflows/custom-tools/operations.ts @@ -131,6 +131,30 @@ export async function listCustomTools(params: { userId: string; workspaceId?: st .orderBy(desc(customTools.createdAt)) } +/** + * List only the metadata needed by workspace inventories. Normal views include + * the viewer's legacy personal tools; secretless views opt into workspace-only + * rows so a shared credential cannot reveal private tool metadata. + */ +export async function listCustomToolSummaries(params: { + userId: string + workspaceId: string + workspaceOnly?: boolean +}) { + const ownership = params.workspaceOnly + ? eq(customTools.workspaceId, params.workspaceId) + : or( + eq(customTools.workspaceId, params.workspaceId), + and(isNull(customTools.workspaceId), eq(customTools.userId, params.userId)) + ) + + return db + .select({ id: customTools.id, title: customTools.title }) + .from(customTools) + .where(ownership) + .orderBy(desc(customTools.createdAt), desc(customTools.id)) +} + /** * Workspace-scoped reads and deletes. * diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index fff063f8ab3..04ed157fc14 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -23,6 +23,7 @@ import { LRUCache } from 'lru-cache' import type { Edge } from 'reactflow' import { releaseWebhookPathClaims } from '@/lib/webhooks/path-claims' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' +import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology' import { backfillCanonicalModes, @@ -366,13 +367,7 @@ export function migrateAgentBlocksToMessagesFormat( ) } -export const CREDENTIAL_SUBBLOCK_IDS = new Set([ - 'credential', - 'manualCredential', - 'triggerCredentials', - 'customBotCredential', - 'manualBotCredential', -]) +export { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/credentials/constants' async function migrateCredentialIds( blocks: Record, diff --git a/bun.lock b/bun.lock index 4d0bbc62d43..c2c5543f6e9 100644 --- a/bun.lock +++ b/bun.lock @@ -596,6 +596,7 @@ "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", "typescript": "^7.0.2", "vitest": "^3.2.4", }, diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index fe265772d95..6d3e0bebafd 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -11,7 +11,7 @@ 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 +profile, selected with `-P`, `--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`: @@ -84,20 +84,14 @@ 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. + Personal key, defaulting to ws_local. Override per command with --workspace. ``` 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 | +so it cannot list them for you. `sim login` issues a personal key, and whichever +workspace you pick becomes only the profile's default `workspace`; it does not +limit the key to that workspace. Use `--workspace` to target another workspace +the key can access. `sim login --workspace ` preselects a workspace in the picker, and an existing profile's workspace preselects itself on re-login. @@ -116,6 +110,9 @@ spellings. `document`. ```bash +sim chat [prompt...] [-f ...] [--read-only] +sim chat -p [prompt...] [-f ...] [--read-only] + sim workflows ls [path] [--search ] [--limit ] sim workflows list [--folder ] [--deployed-only] [--limit ] sim workflows get @@ -184,7 +181,103 @@ For a paused execution, its status includes the context ID needed by `resume`. `logs get` is the full diagnostic resource. It keeps the default human output concise; add `--trace` for the expanded recursive trace with span inputs, outputs, errors, timing, and cost. JSON and YAML retain the complete structured -response: +response. + +### Ask Sim Chat + +`sim chat` opens a terminal conversation about the workspace saved by `sim +login`. It streams answers with a compact working indicator, keeps the +conversation across turns, and provides input history. In a real TTY, the +transcript and current activity stay in the upper viewport while the +free-form `❯` composer remains pinned at the bottom. Use the global +`--workspace` flag to target another workspace the active key can access. +Structured questions use a separate compact panel: Up/Down moves, Enter selects, +Space toggles multi-select items, typing supplies a custom answer, and Esc returns +to the ordinary composer. Suggested follow-up metadata is omitted. + +The composer stays editable while Sim is working. Press Enter with a follow-up +to queue it and immediately steer the active turn (the TUI performs the web +chat's queue-then-send-now handoff in one step); additional submitted prompts +remain FIFO. Press Up on an empty composer to recall the newest queued prompt. +Shift+Enter, Option/Meta+Enter, or a trailing `\` followed by Enter inserts a +newline instead of submitting. + +Type `@` at the start of a token to tag a workspace workflow, table, file, or +knowledge base. The latest 50 execution logs appear after those primary +resources instead of expanding an unbounded logs tree. Past chats never enter +the `@` list; use `/chats` to open their searchable picker. Type `/` to invoke a +workspace skill or an enabled MCP server; read-only chat omits MCP servers, +and CLI control commands remain in that menu at the start of the composer. +These are structured tags, not decorative prompt text: Sim receives the +selected resource id, and a tagged MCP server remains enabled for later turns +in the same terminal conversation. + +```bash +sim chat +sim chat "Start by explaining this workspace" +sim chat --file screenshot.png "What is failing here?" +sim chat --read-only "Summarize this workspace without changing it" +``` + +Inside the chat, `/attach ` attaches up to five local images, PDFs, or +UTF-8 text files to the next turn. A pasted or dragged file path preloads an +`/attach` command; review it and press Enter before the CLI reads the file. On +macOS, press Ctrl+V or use `/paste-image` to attach a clipboard image; +any draft text remains in the prompt. `/chats` loads the chat history and opens +a searchable picker. Selecting one restores its transcript and continues it +with a fresh opaque token. The header shows the active chat title and keeps the +`/chats` switch hint visible; a new chat's generated title appears there as soon +as the server publishes it. `/rename ` retitles the active synced chat in +both the terminal and Sim Home. `/clear` clears the visible transcript and +starts a new conversation, `/help` lists commands, and `/exit` or Ctrl+D exits. +Ctrl+C clears idle input or cancels the active generation and returns to the +prompt. + +Chats sent with the personal API key issued by `sim login` use the same history +as Sim Home, so a CLI conversation appears in the web UI and a web conversation +can be resumed in the terminal. Shared workspace keys intentionally do not +expose their creator's private chat history. Profiles created by an older login +flow may still contain a workspace-scoped key; run `sim login` again for that +profile to replace it with a personal key and enable synchronized history and +`/chats`. + +Chat uses the full Mothership toolset by default. Add `--read-only` in either +interactive or print mode when the conversation must be restricted to +workspace-reading tools. + +`sim chat -p` is the non-interactive form. It never opens a prompt: the +completed, terminal-safe answer is the only thing written to stdout, so it +composes cleanly with shell tools. Bare `sim chat` requires a real terminal; +pipelines and redirected output must use `-p`. + +```bash +sim chat -p "Which workflows handle support tickets?" +cat incident.txt | sim chat -p "Which workflow is most likely involved?" +sim chat -p < question.txt +sim chat -p --file report.pdf "Summarize this in workspace context" +``` + +When both a positional prompt and stdin are present, the positional prompt comes +first and the piped content follows on the next line. This matches Claude Code's +print-mode input behavior. Combined input is limited to 10 MiB of UTF-8 text. +Files are sent inline by basename only: local paths never cross the API +boundary. Images and PDFs are limited to 5 MiB each, text files to 200 KiB, and +all attachments in a turn to 10 MiB total. + +On an auth-disabled self-hosted Sim deployment, configure the endpoint and +workspace without logging in locally: + +```bash +sim configure --set-endpoint http://localhost:3000 --set-workspace ws_local +sim chat -p "What is in this workspace?" +``` + +That deployment must enable `V2_API=true` and set `COPILOT_API_KEY` server-side. +A CLI API key, when one is present, authenticates only the public Sim request +and is never reused as the deployment's Mothership key. + +`sim logs get` keeps the default human output concise. Use JSON or YAML to +inspect its complete `executionData` and recursive `traceSpans` tree: ```bash sim logs get <executionId> --trace diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index 15f721ae031..d65ac913894 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -40,6 +40,7 @@ "@sim/tsconfig": "workspace:*", "@types/js-yaml": "4.0.9", "@types/node": "24.2.1", + "@xterm/headless": "6.0.0", "typescript": "^7.0.2", "vitest": "^3.2.4" } diff --git a/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts new file mode 100644 index 00000000000..ee98e5cc066 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-attachment-tag.test.ts @@ -0,0 +1,64 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) +const TAG = `${ESC}[38;2;51;196;130m` + +function harness() { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = 80 + output.rows = 20 + output.on('data', () => {}) + const terminal = new ReadlineChatTerminal(input as never, output as never) + const probe = terminal as never as { + draft: string + buildPanel(rows: number): { lines: string[] } + } + return { + input, + terminal, + draft: () => probe.draft, + row: () => probe.buildPanel(20).lines.join('\n'), + } +} + +describe('pasted image tag', () => { + it('inserts a numbered tag at the cursor and highlights it', () => { + const { input, terminal, draft, row } = harness() + void terminal.read('> ') + input.write('look at') + terminal.noteAttachment() + expect(draft()).toBe('look at [Image #1] ') + expect(row()).toContain(`${TAG}[Image #1]`) + terminal.close() + }) + + it('numbers successive attachments', () => { + const { terminal, draft } = harness() + void terminal.read('> ') + terminal.noteAttachment() + terminal.noteAttachment() + expect(draft()).toBe('[Image #1] [Image #2] ') + terminal.close() + }) + + it('stops highlighting once the tag is deleted', () => { + const { input, terminal, row } = harness() + void terminal.read('> ') + terminal.noteAttachment() + expect(row()).toContain(TAG) + const BACKSPACE = String.fromCharCode(127) + for (let i = 0; i < 12; i++) input.write(BACKSPACE) + expect(row()).not.toContain(TAG) + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts new file mode 100644 index 00000000000..d0de466d569 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + combineChatAttachments, + existingAttachmentPaths, + loadChatAttachment, + loadChatAttachments, + parseAttachmentPaths, +} from './chat-attachments.js' + +const temporaryDirectories: string[] = [] + +function pngBytes(size: number): Buffer { + const bytes = Buffer.alloc(size) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes) + return bytes +} + +async function fixture(name: string, value: Uint8Array | string): Promise<string> { + const directory = await mkdtemp(join(tmpdir(), 'sim-cli-chat-test-')) + temporaryDirectories.push(directory) + const path = join(directory, name) + await writeFile(path, value) + return path +} + +afterEach(async () => { + for (const path of temporaryDirectories.splice(0)) await rm(path, { recursive: true }) +}) + +describe('chat attachments', () => { + it('infers media types from bytes and sends only the basename', async () => { + const png = await fixture( + 'renamed.dat', + Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ) + const markdown = await fixture('notes.md', '# hello') + + await expect(loadChatAttachment(png)).resolves.toEqual({ + name: 'renamed.dat', + mediaType: 'image/png', + data: 'iVBORw0KGgo=', + }) + await expect(loadChatAttachment(markdown)).resolves.toEqual({ + name: 'notes.md', + mediaType: 'text/markdown', + data: 'IyBoZWxsbw==', + }) + }) + + it('rejects binary and oversized text locally', async () => { + const binary = await fixture('payload.bin', Uint8Array.from([0xff, 0x00, 0xfe])) + const large = await fixture('large.txt', 'x'.repeat(200 * 1024 + 1)) + const tooLargeForAnyType = await fixture('huge.png', Buffer.alloc(5 * 1024 * 1024 + 1)) + + await expect(loadChatAttachment(binary)).rejects.toThrow(/Unsupported attachment/) + await expect(loadChatAttachment(large)).rejects.toThrow(/200 KiB/) + await expect(loadChatAttachment(tooLargeForAnyType)).rejects.toThrow(/5 MiB/) + }) + + it('enforces count and aggregate limits', async () => { + const small = { name: 'a.txt', mediaType: 'text/plain', data: 'eA==' } + expect(() => + combineChatAttachments( + [], + Array.from({ length: 6 }, () => small) + ) + ).toThrow(/at most 5/) + + const fiveMiB = Buffer.alloc(5 * 1024 * 1024).toString('base64') + expect(() => + combineChatAttachments( + [], + [ + { name: 'a.png', mediaType: 'image/png', data: fiveMiB }, + { name: 'b.png', mediaType: 'image/png', data: fiveMiB }, + { name: 'c.txt', mediaType: 'text/plain', data: 'eA==' }, + ] + ) + ).toThrow(/aggregate limit/) + }) + + it('loads multiple attachments and rejects missing paths', async () => { + const one = await fixture('one.txt', 'one') + const two = await fixture('two.json', '{}') + await expect(loadChatAttachments([one, two])).resolves.toHaveLength(2) + await expect(loadChatAttachment(join(tmpdir(), 'definitely-missing-sim-file'))).rejects.toThrow( + /Could not read attachment/ + ) + }) + + it('rejects too many paths before attempting to open any of them', async () => { + const missing = join(tmpdir(), 'definitely-missing-sim-file') + + await expect(loadChatAttachments(Array.from({ length: 6 }, () => missing))).rejects.toThrow( + /at most 5/ + ) + }) + + it('stops loading as soon as the aggregate byte limit is exceeded', async () => { + const first = await fixture('first.png', pngBytes(5 * 1024 * 1024)) + const second = await fixture('second.png', pngBytes(5 * 1024 * 1024)) + const third = await fixture('third.png', pngBytes(8)) + const missing = join(tmpdir(), 'missing-after-aggregate-limit.png') + + await expect(loadChatAttachments([first, second, third, missing])).rejects.toThrow( + /aggregate limit/ + ) + }) +}) + +describe('attachment path parsing', () => { + it('supports quoted and terminal-escaped paths', () => { + expect(parseAttachmentPaths("'/tmp/one two.md' /tmp/three\\ four.png")).toEqual([ + '/tmp/one two.md', + '/tmp/three four.png', + ]) + expect(() => parseAttachmentPaths("'/tmp/open")).toThrow(/Unclosed quote/) + }) + + it('recognizes a pasted path containing spaces before trying shell splitting', async () => { + const path = await fixture('a file.txt', 'hello') + await expect(existingAttachmentPaths(path)).resolves.toEqual([path]) + await expect(existingAttachmentPaths('this is a normal question')).resolves.toBeNull() + }) + + it('rejects pasted candidate lists beyond the per-turn limit', async () => { + const candidates = Array.from({ length: 6 }, (_, index) => `/tmp/sim-file-${index}`).join(' ') + + await expect(existingAttachmentPaths(candidates)).resolves.toBeNull() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.ts new file mode 100644 index 00000000000..7e313c74bc9 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.ts @@ -0,0 +1,324 @@ +import { execFile } from 'node:child_process' +import { type FileHandle, mkdtemp, open, rmdir, stat, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, extname, join } from 'node:path' +import { promisify } from 'node:util' +import { SimApiError } from '../../http/client.js' + +export interface ChatAttachment { + name: string + mediaType: string + data: string +} + +export const MAX_CHAT_ATTACHMENTS = 5 +export const MAX_CHAT_ATTACHMENT_BYTES = 5 * 1024 * 1024 +export const MAX_CHAT_TEXT_ATTACHMENT_BYTES = 200 * 1024 +export const MAX_CHAT_ATTACHMENTS_TOTAL_BYTES = 10 * 1024 * 1024 + +const execFileAsync = promisify(execFile) +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) + +const TEXT_MEDIA_TYPES_BY_EXTENSION: Record<string, string> = { + '.css': 'text/css', + '.csv': 'text/csv', + '.htm': 'text/html', + '.html': 'text/html', + '.js': 'text/javascript', + '.json': 'application/json', + '.jsonl': 'application/jsonl', + '.jsx': 'text/javascript', + '.log': 'text/plain', + '.markdown': 'text/markdown', + '.md': 'text/markdown', + '.mjs': 'text/javascript', + '.ndjson': 'application/x-ndjson', + '.toml': 'application/toml', + '.ts': 'text/typescript', + '.tsv': 'text/tab-separated-values', + '.tsx': 'text/typescript', + '.txt': 'text/plain', + '.xml': 'application/xml', + '.yaml': 'application/yaml', + '.yml': 'application/yaml', +} + +function attachmentError(message: string): SimApiError { + return new SimApiError(message, 0) +} + +function sniffImageMediaType(bytes: Uint8Array): string | null { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return 'image/png' + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + if (bytes.length >= 6) { + const signature = Buffer.from(bytes.subarray(0, 6)).toString('ascii') + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif' + } + if ( + bytes.length >= 12 && + Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && + Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP' + ) { + return 'image/webp' + } + return null +} + +function isPdf(bytes: Uint8Array): boolean { + return Buffer.from(bytes.subarray(0, 1024)).toString('latin1').includes('%PDF') +} + +function assertAttachmentName(name: string): void { + if ( + !name || + name === '.' || + name === '..' || + name.length > 255 || + /[\\/\u0000-\u001f\u007f]/.test(name) + ) { + throw attachmentError(`Attachment name ${JSON.stringify(name)} must be a safe file basename.`) + } +} + +function textMediaType(path: string): string { + return TEXT_MEDIA_TYPES_BY_EXTENSION[extname(path).toLowerCase()] ?? 'text/plain' +} + +function inspectAttachment(path: string, bytes: Uint8Array): { mediaType: string; limit: number } { + const imageMediaType = sniffImageMediaType(bytes) + if (imageMediaType) return { mediaType: imageMediaType, limit: MAX_CHAT_ATTACHMENT_BYTES } + if (isPdf(bytes)) return { mediaType: 'application/pdf', limit: MAX_CHAT_ATTACHMENT_BYTES } + + try { + const value = utf8Decoder.decode(bytes) + if (value.includes('\0')) throw new Error('NUL byte') + } catch { + throw attachmentError( + `Unsupported attachment ${JSON.stringify(basename(path))}. Use PNG, JPEG, GIF, WebP, PDF, or UTF-8 text.` + ) + } + return { mediaType: textMediaType(path), limit: MAX_CHAT_TEXT_ATTACHMENT_BYTES } +} + +async function readBounded(handle: FileHandle, limit: number): Promise<Buffer> { + const bytes = Buffer.allocUnsafe(limit + 1) + let offset = 0 + while (offset < bytes.byteLength) { + const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset) + if (result.bytesRead === 0) break + offset += result.bytesRead + } + return bytes.subarray(0, offset) +} + +/** Reads and validates one local file without ever putting its path on the wire. */ +export async function loadChatAttachment(path: string): Promise<ChatAttachment> { + let handle: FileHandle + try { + handle = await open(path, 'r') + } catch { + throw attachmentError(`Could not read attachment ${JSON.stringify(path)}.`) + } + + const name = basename(path) + try { + const info = await handle.stat() + if (!info.isFile()) throw attachmentError(`Attachment ${JSON.stringify(path)} is not a file.`) + // Metadata rejects obvious mistakes without allocating for them. The read + // itself is independently capped because a file can grow after fstat. + if (info.size > MAX_CHAT_ATTACHMENT_BYTES) { + throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the 5 MiB limit.`) + } + + assertAttachmentName(name) + const bytes = await readBounded(handle, MAX_CHAT_ATTACHMENT_BYTES) + if (bytes.byteLength > MAX_CHAT_ATTACHMENT_BYTES) { + throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the 5 MiB limit.`) + } + if (bytes.byteLength === 0) { + throw attachmentError(`Attachment ${JSON.stringify(name)} is empty.`) + } + const { mediaType, limit } = inspectAttachment(path, bytes) + if (bytes.byteLength > limit) { + const label = limit === MAX_CHAT_TEXT_ATTACHMENT_BYTES ? '200 KiB' : '5 MiB' + throw attachmentError(`Attachment ${JSON.stringify(name)} exceeds the ${label} limit.`) + } + + return { name, mediaType, data: bytes.toString('base64') } + } catch (error) { + if (error instanceof SimApiError) throw error + throw attachmentError(`Could not read attachment ${JSON.stringify(path)}.`) + } finally { + await handle.close().catch(() => {}) + } +} + +export function decodedAttachmentBytes(attachment: ChatAttachment): number { + return Buffer.from(attachment.data, 'base64').byteLength +} + +/** Enforces count and aggregate limits whenever pending attachments are combined. */ +export function combineChatAttachments( + current: ChatAttachment[], + additions: ChatAttachment[] +): ChatAttachment[] { + const combined = [...current, ...additions] + if (combined.length > MAX_CHAT_ATTACHMENTS) { + throw attachmentError(`Sim Chat accepts at most ${MAX_CHAT_ATTACHMENTS} attachments per turn.`) + } + const bytes = combined.reduce((total, item) => total + decodedAttachmentBytes(item), 0) + if (bytes > MAX_CHAT_ATTACHMENTS_TOTAL_BYTES) { + throw attachmentError('Sim Chat attachments exceed the 10 MiB aggregate limit.') + } + return combined +} + +export async function loadChatAttachments(paths: string[]): Promise<ChatAttachment[]> { + if (paths.length > MAX_CHAT_ATTACHMENTS) { + throw attachmentError(`Sim Chat accepts at most ${MAX_CHAT_ATTACHMENTS} attachments per turn.`) + } + + const attachments: ChatAttachment[] = [] + let totalBytes = 0 + for (const path of paths) { + const attachment = await loadChatAttachment(path) + totalBytes += decodedAttachmentBytes(attachment) + if (totalBytes > MAX_CHAT_ATTACHMENTS_TOTAL_BYTES) { + throw attachmentError('Sim Chat attachments exceed the 10 MiB aggregate limit.') + } + attachments.push(attachment) + } + return attachments +} + +/** + * Splits `/attach` input using the subset terminals produce for dragged paths: + * whitespace separation, single/double quotes, and backslash escapes. + */ +export function parseAttachmentPaths(input: string): string[] { + const paths: string[] = [] + let value = '' + let quote: 'single' | 'double' | null = null + let escaped = false + + const push = () => { + if (value) paths.push(value) + value = '' + } + + for (const character of input.trim()) { + if (escaped) { + value += character + escaped = false + continue + } + if (character === '\\' && quote !== 'single') { + escaped = true + continue + } + if (character === "'" && quote !== 'double') { + quote = quote === 'single' ? null : 'single' + continue + } + if (character === '"' && quote !== 'single') { + quote = quote === 'double' ? null : 'double' + continue + } + if (/\s/.test(character) && quote === null) { + push() + continue + } + value += character + } + + if (escaped) value += '\\' + if (quote !== null) throw attachmentError('Unclosed quote in attachment path.') + push() + return paths +} + +/** + * Writes the clipboard image to a path given as argv[1]. + * + * Deliberately performs no size check: AppleScript cannot take `length of` raw + * data ("Can't make length of «data PNGf…»"), so a guard here throws for every + * image and the whole read fails. `loadChatAttachment` caps the size on fstat + * and again on read, which is where the limit belongs anyway. + */ +const APPLE_SCRIPT = [ + 'on run argv', + 'set outputPath to item 1 of argv', + 'try', + 'set imageData to the clipboard as «class PNGf»', + 'set outputFile to open for access POSIX file outputPath with write permission', + 'set eof outputFile to 0', + 'write imageData to outputFile', + 'close access outputFile', + 'on error', + 'try', + 'close access POSIX file outputPath', + 'end try', + 'error number -1700', + 'end try', + 'end run', +] + +/** Best-effort macOS clipboard image extraction, used by the paste keystroke. */ +export async function readClipboardImage(): Promise<ChatAttachment | null> { + if (process.platform !== 'darwin') return null + + const directory = await mkdtemp(join(tmpdir(), 'sim-chat-clipboard-')) + const path = join(directory, 'clipboard.png') + try { + const args = APPLE_SCRIPT.flatMap((line) => ['-e', line]) + args.push(path) + await execFileAsync('osascript', args, { timeout: 5_000 }) + return await loadChatAttachment(path) + } catch { + return null + } finally { + await unlink(path).catch(() => {}) + await rmdir(directory).catch(() => {}) + } +} + +/** True when every parsed path names an existing regular file. */ +export async function existingAttachmentPaths(input: string): Promise<string[] | null> { + let wholePath + try { + wholePath = await stat(input.trim()) + } catch { + wholePath = null + } + if (wholePath?.isFile()) return [input.trim()] + + let paths: string[] + try { + paths = parseAttachmentPaths(input) + } catch { + return null + } + if (paths.length === 0 || paths.length > MAX_CHAT_ATTACHMENTS) return null + for (const path of paths) { + try { + if (!(await stat(path)).isFile()) return null + } catch { + return null + } + } + return paths +} diff --git a/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts b/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts new file mode 100644 index 00000000000..c6a8f533451 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-markdown.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { ChatMarkdownStream } from './chat-markdown.js' + +const ESC = String.fromCharCode(27) + +describe('ChatMarkdownStream', () => { + it('styles headings, emphasis, lists, quotes, inline code, and fences across chunks', () => { + const stream = new ChatMarkdownStream(true) + const output = [ + stream.push('## Work'), + stream.push('space\n- **default'), + stream.push('-agent** with `code`\n> note\n```ts\nconst x = 1\n```'), + stream.finish(), + ].join('') + + expect(output).toContain(`${ESC}[1mWorkspace`) + expect(output).toContain(`${ESC}[2m•${ESC}[0m `) + expect(output).toContain('default-agent') + expect(output).not.toContain('**') + expect(output).not.toContain('`code`') + expect(output).toContain(`${ESC}[2m│${ESC}[0m note`) + expect(output).toContain(`${ESC}[2m┌─ ts${ESC}[0m`) + expect(output).toContain(`${ESC}[2mconst x = 1${ESC}[0m`) + expect(output).toContain(`${ESC}[2m└─${ESC}[0m`) + }) + + it('renders workspace summaries without exposing Markdown or styling identifier underscores', () => { + const stream = new ChatMarkdownStream(true) + const output = [ + stream.push("Here's what's in your workspace:\n\n**Workflows (3)**\n- forceful-arm\n"), + stream.push( + '- Table: cobalt_cloud\n- File: Mothership_Capability_Overview.pptx\n- **default-agent**' + ), + stream.finish(), + ].join('') + + expect(output).toContain(`${ESC}[1mWorkflows (3)${ESC}[0m`) + expect(output).toContain('cobalt_cloud') + expect(output).toContain('Mothership_Capability_Overview.pptx') + expect(output).toContain('default-agent') + expect(output).not.toContain('**') + expect(output).not.toContain(`${ESC}[3mcloud`) + expect(output).not.toContain(`${ESC}[3mCapability`) + }) + + it('renders Markdown links as visible labels without terminal hyperlinks or destinations', () => { + const stream = new ChatMarkdownStream(true) + expect(stream.push('[Sim](https://sim.ai/work')).toBe('') + expect(stream.push('space)')).toBe('Sim') + + const misleading = new ChatMarkdownStream(true) + const misleadingOutput = misleading.push('[notexample.com](https://example.com/)') + expect(misleadingOutput).toBe('notexample.com') + + const unsafe = new ChatMarkdownStream(true) + const unsafeOutput = unsafe.push('[bad](javascript:alert(1))') + expect(unsafeOutput).toBe('bad') + + const userInfo = new ChatMarkdownStream(true) + const userInfoOutput = userInfo.push('[login](https://trusted.example@evil.example/)') + expect(userInfoOutput).toBe('login') + expect(`${misleadingOutput}${unsafeOutput}${userInfoOutput}`).not.toContain(`${ESC}]8;;`) + }) + + it('never prefixes streamed list items with an undefined renderer value', () => { + const stream = new ChatMarkdownStream(true) + const output = `${stream.push('- ')}${stream.flushInline()}default-agent${stream.finish()}` + + expect(output).toContain('default-agent') + expect(output).not.toContain('undefined') + }) + + it('bounds incomplete link candidates and does not hide multiline prose', () => { + const longLabel = `[${'x'.repeat(300)}` + const labelStream = new ChatMarkdownStream(true) + expect(labelStream.push(longLabel)).toBe(longLabel) + + const longDestination = `[label](https://example.com/${'x'.repeat(2_100)}` + const destinationStream = new ChatMarkdownStream(true) + expect(destinationStream.push(longDestination)).toBe(longDestination) + + const multiline = new ChatMarkdownStream(true) + expect(multiline.push('[not a link\nnext line')).toBe('[not a link\nnext line') + }) + + it('sanitizes model controls before applying renderer-owned terminal styling', () => { + const stream = new ChatMarkdownStream(true) + const output = stream.push(`**safe${ESC}]0;owned\u0007**`) + expect(output).toContain('safe') + expect(output).not.toContain('owned') + expect(output).not.toContain(`${ESC}]0;`) + }) + + it('is a sanitized byte-preserving stream when terminal styling is disabled', () => { + const stream = new ChatMarkdownStream(false) + expect(stream.push('**plain**\n')).toBe('**plain**\n') + expect(stream.finish()).toBe('') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-markdown.ts b/packages/sim-cli/src/commands/protocol/chat-markdown.ts new file mode 100644 index 00000000000..1070f9ad7fa --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-markdown.ts @@ -0,0 +1,244 @@ +import { sanitize } from '../../output/render.js' + +const ESC = String.fromCharCode(27) +const RESET = `${ESC}[0m` +const BOLD = `${ESC}[1m` +const DIM = `${ESC}[2m` +const ITALIC = `${ESC}[3m` +const CYAN = `${ESC}[36m` +const MAX_LINK_LABEL_LENGTH = 256 +const MAX_LINK_DESTINATION_LENGTH = 2_048 + +interface InlineStyle { + bold: boolean + italic: boolean + code: boolean +} + +/** + * A deliberately small streaming Markdown renderer for interactive chat. + * + * It does not parse HTML and it never accepts terminal escapes from the model: + * input is sanitized first, then the renderer adds its own fixed SGR + * sequences. Markdown links intentionally render as their visible label only; + * terminal hyperlink support varies and hidden destinations are surprising in + * a CLI transcript. Unlike a whole-document Markdown parser, this keeps ordinary prose + * streaming as soon as it arrives. Only a possible Markdown link is buffered + * until its closing `)` makes the URL safe to validate. + */ +export class ChatMarkdownStream { + private readonly style: InlineStyle = { bold: false, italic: false, code: false } + private pending = '' + private atLineStart = true + private inFence = false + + constructor(private readonly enabled: boolean) {} + + push(fragment: string): string { + const safe = sanitize(fragment) + if (!this.enabled) return safe + this.pending += safe + return this.drain(false) + } + + /** Flushes an inline prefix before a trusted structured tag is written. */ + flushInline(): string { + if (!this.enabled || !this.pending) return '' + return this.drain(true) + } + + finish(): string { + if (!this.enabled) return '' + const rendered = this.drain(true) + return rendered + (this.hasStyle() ? this.resetStyles() : '') + } + + private drain(final: boolean): string { + let output = '' + + while (this.pending) { + if (this.atLineStart) { + const prefix = this.consumeLinePrefix(final) + if (prefix === null) break + output += prefix + if (!this.pending) break + } + + if (this.pending.startsWith('\n')) { + this.pending = this.pending.slice(1) + output += this.resetStyles() + output += '\n' + this.atLineStart = true + continue + } + + if (this.inFence) { + const newline = this.pending.indexOf('\n') + const amount = newline === -1 ? (final ? this.pending.length : 0) : newline + if (amount === 0) break + output += `${DIM}${this.pending.slice(0, amount)}${RESET}` + this.pending = this.pending.slice(amount) + continue + } + + const link = this.tryMarkdownLink(final) + if (link.kind === 'wait') break + if (link.kind === 'rendered') { + output += link.value + continue + } + + if (this.pending.startsWith('`')) { + this.pending = this.pending.slice(1) + this.style.code = !this.style.code + output += this.applyStyles() + continue + } + // Workspace identifiers and file names commonly contain underscores, so + // only asterisks act as emphasis delimiters in this compact renderer. + if (!this.style.code && this.pending.startsWith('**')) { + this.pending = this.pending.slice(2) + this.style.bold = !this.style.bold + output += this.applyStyles() + continue + } + if (!this.style.code && this.pending.startsWith('*')) { + if (!final && this.pending.length === 1) break + this.pending = this.pending.slice(1) + this.style.italic = !this.style.italic + output += this.applyStyles() + continue + } + + // A trailing marker may be the first half of a delimiter in the next SSE + // chunk. Hold it for one beat instead of briefly printing raw Markdown. + if (!final && this.pending.length === 1 && /[[\]*`]/u.test(this.pending)) break + + output += this.pending[0] + this.pending = this.pending.slice(1) + } + + return output + } + + private consumeLinePrefix(final: boolean): string | null { + const newline = this.pending.indexOf('\n') + const candidate = newline === -1 ? this.pending : this.pending.slice(0, newline) + if (!final && newline === -1 && candidate.length < 4 && /^[#>*+\-\d. `]*$/u.test(candidate)) { + return null + } + + const fence = candidate.match(/^\s*```\s*([^\s`]*)\s*$/u) + if (fence) { + this.pending = this.pending.slice(candidate.length) + this.inFence = !this.inFence + this.atLineStart = false + return this.inFence ? `${DIM}┌─${fence[1] ? ` ${fence[1]}` : ''}${RESET}` : `${DIM}└─${RESET}` + } + + if (this.inFence) { + this.atLineStart = false + return '' + } + + const heading = candidate.match(/^\s{0,3}#{1,6}\s+/u) + if (heading) { + this.pending = this.pending.slice(heading[0].length) + this.style.bold = true + this.atLineStart = false + return BOLD + } + + const bullet = candidate.match(/^(\s{0,8})[-+*]\s+/u) + if (bullet) { + this.pending = this.pending.slice(bullet[0].length) + this.atLineStart = false + return `${bullet[1]}${DIM}•${RESET} ${this.applyStyles(false)}` + } + + const quote = candidate.match(/^(\s{0,3})>\s?/u) + if (quote) { + this.pending = this.pending.slice(quote[0].length) + this.atLineStart = false + return `${quote[1]}${DIM}│${RESET} ` + } + + const rule = candidate.match(/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u) + if (rule) { + this.pending = this.pending.slice(candidate.length) + this.atLineStart = false + return `${DIM}${'─'.repeat(24)}${RESET}` + } + + this.atLineStart = false + return '' + } + + private tryMarkdownLink( + final: boolean + ): { kind: 'none' } | { kind: 'wait' } | { kind: 'rendered'; value: string } { + if (!this.pending.startsWith('[') || this.style.code || this.inFence) return { kind: 'none' } + + const labelEnd = this.pending.indexOf('](') + if (labelEnd === -1) { + const couldStillBeLink = + !final && !this.pending.includes('\n') && this.pending.length <= MAX_LINK_LABEL_LENGTH + 2 + return couldStillBeLink ? { kind: 'wait' } : { kind: 'none' } + } + const label = this.pending.slice(1, labelEnd) + if (!label || label.includes('\n') || label.length > MAX_LINK_LABEL_LENGTH) { + return { kind: 'none' } + } + + let depth = 1 + let escaped = false + let end = labelEnd + 2 + for (; end < this.pending.length; end += 1) { + if (end - labelEnd - 2 > MAX_LINK_DESTINATION_LENGTH) return { kind: 'none' } + const character = this.pending[end] + if (escaped) { + escaped = false + continue + } + if (character === '\\') { + escaped = true + continue + } + if (character === '(') depth += 1 + if (character === ')') { + depth -= 1 + if (depth === 0) break + } + if (character === '\n') return { kind: 'none' } + } + if (end >= this.pending.length) { + const destinationLength = this.pending.length - labelEnd - 2 + return !final && destinationLength <= MAX_LINK_DESTINATION_LENGTH + ? { kind: 'wait' } + : { kind: 'none' } + } + + this.pending = this.pending.slice(end + 1) + return { kind: 'rendered', value: label } + } + + private hasStyle(): boolean { + return this.style.bold || this.style.italic || this.style.code + } + + private resetStyles(): string { + const hadStyle = this.hasStyle() + this.style.bold = false + this.style.italic = false + this.style.code = false + return hadStyle ? RESET : '' + } + + private applyStyles(reset = true): string { + let output = reset ? RESET : '' + if (this.style.bold) output += BOLD + if (this.style.italic) output += ITALIC + if (this.style.code) output += `${CYAN}${DIM}` + return output + } +} diff --git a/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts b/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts new file mode 100644 index 00000000000..8e857695ab7 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-mentions.test.ts @@ -0,0 +1,121 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) +const MENTION = `${ESC}[38;2;51;196;130m` +const BODY_TEXT = `${ESC}[38;2;242;242;242m` +const BACKSPACE = String.fromCharCode(127) + +function harness() { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = 80 + output.rows = 20 + const terminal = new ReadlineChatTerminal(input as never, output as never) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'w1', + value: 'code-review', + displayText: 'code-review', + tag: 'workflow', + context: { + kind: 'workflow', + workflowId: 'w1', + label: 'code-review', + }, + }, + { + id: 'w2', + value: 'release notes', + displayText: 'release notes', + tag: 'workflow', + context: { + kind: 'workflow', + workflowId: 'w2', + label: 'release notes', + }, + }, + ], + slash: [ + { + id: 's1', + value: 'review', + displayText: '/review', + tag: 'skill', + context: { kind: 'skill', skillId: 's1', label: 'review' }, + }, + ], + }) + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + return { + input, + terminal, + draftRow: () => probe.buildPanel(20).lines.find((line) => line.includes('>')) ?? '', + } +} + +describe('mention highlighting', () => { + it('lights a mention that resolves to a candidate', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('run @code\tnow') + expect(draftRow()).toContain(`${MENTION}@code-review${BODY_TEXT}`) + terminal.close() + }) + + it('goes plain once the mention is half-deleted', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('run @code\t') + expect(draftRow()).toContain(MENTION) + for (let i = 0; i < 3; i++) input.write(BACKSPACE) + expect(draftRow()).not.toContain(MENTION) + terminal.close() + }) + + it('does not light an unknown mention or an email address', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('ping @nobody and me@example.com') + expect(draftRow()).not.toContain(MENTION) + terminal.close() + }) + + it('lights the client-style literal mention containing a space', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('draft @release\tplease') + expect(draftRow()).toContain(`${MENTION}@release notes${BODY_TEXT}`) + terminal.close() + }) + + it('lights a typed exact slash skill once it resolves', () => { + const { input, terminal, draftRow } = harness() + void terminal.read('> ') + input.write('use /review now') + expect(draftRow()).toContain(`${MENTION}/review${BODY_TEXT}`) + terminal.close() + }) + + it('closes the style at a row break so it cannot leak', () => { + const { input, terminal } = harness() + void terminal.read('> ') + input.write(`${'x'.repeat(75)} @code\ttail`) + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + for (const line of probe.buildPanel(20).lines) { + const opens = line.split(MENTION).length - 1 + const closes = line.split(`${ESC}[0m`).length - 1 + expect(closes).toBeGreaterThanOrEqual(opens) + } + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-paste.test.ts b/packages/sim-cli/src/commands/protocol/chat-paste.test.ts new file mode 100644 index 00000000000..a1e993f8f42 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-paste.test.ts @@ -0,0 +1,78 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) +const PASTE_START = `${ESC}[200~` +const PASTE_END = `${ESC}[201~` + +function harness() { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = 80 + output.rows = 20 + const terminal = new ReadlineChatTerminal(input as never, output as never) + return { input, terminal, draft: () => (terminal as never as { draft: string }).draft } +} + +describe('bracketed paste', () => { + it('inserts a short single-line paste literally', () => { + const { input, terminal, draft } = harness() + void terminal.read('> ') + input.write(`${PASTE_START}hello world${PASTE_END}`) + expect(draft()).toBe('hello world') + terminal.close() + }) + + it('collapses a multi-line paste to a placeholder and expands it on submit', async () => { + const { input, terminal, draft } = harness() + const result = terminal.read('> ') + const body = 'line one\nline two\nline three\nline four' + input.write(`${PASTE_START}${body}${PASTE_END}`) + expect(draft()).toBe('[Pasted text #1 +3 lines]') + input.write('\r') + await expect(result).resolves.toEqual({ + kind: 'line', + value: body, + display: '[Pasted text #1 +3 lines]', + pastes: new Map([[1, body]]), + }) + terminal.close() + }) + + it('collapses a long single-line paste', () => { + const { input, terminal, draft } = harness() + void terminal.read('> ') + input.write(`${PASTE_START}${'x'.repeat(900)}${PASTE_END}`) + expect(draft()).toBe('[Pasted text #1]') + terminal.close() + }) + + it('drops a stashed body when its placeholder is deleted', async () => { + const { input, terminal, draft } = harness() + const result = terminal.read('> ') + input.write(`${PASTE_START}a\nb\nc\nd${PASTE_END}`) + const BACKSPACE = String.fromCharCode(127) + let guard = 200 + while (draft().length > 0 && guard-- > 0) input.write(BACKSPACE) + input.write('plain') + input.write('\r') + await expect(result).resolves.toEqual({ kind: 'line', value: 'plain' }) + terminal.close() + }) + + it('routes an empty paste to the clipboard, for macOS cmd+v of an image', async () => { + const { input, terminal } = harness() + const result = terminal.read('> ') + input.write(`${PASTE_START}${PASTE_END}`) + await expect(result).resolves.toEqual({ kind: 'clipboard', value: '' }) + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-structured.test.ts b/packages/sim-cli/src/commands/protocol/chat-structured.test.ts new file mode 100644 index 00000000000..1c95e3f826e --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-structured.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest' +import { + ChatStructuredParser, + parseChatStructured, + renderChatStructured, +} from './chat-structured.js' + +const ESC = String.fromCharCode(27) + +function parseChunks(chunks: string[]) { + const parser = new ChatStructuredParser() + return [...chunks.flatMap((chunk) => parser.push(chunk)), ...parser.finish()] +} + +describe('ChatStructuredParser', () => { + it('parses every official tag when wrappers are split across chunks', () => { + const content = [ + 'Answer ', + '<thinking>private</thinking>', + '<options>{"1":{"title":"Next","description":"Continue"}}</options>', + '<question>{"type":"single_select","prompt":"Choose","options":[{"id":"a","label":"A"}]}</question>', + '<credential>{"type":"link","provider":"Slack","value":"https://sim.ai/connect?id=1"}</credential>', + '<workspace_resource>{"type":"workflow","id":"wf_1","title":"Daily sync"}</workspace_resource>', + '<usage_upgrade>{"reason":"quota","action":"upgrade_plan","message":"Upgrade now"}</usage_upgrade>', + '<mothership-error>{"message":"Unavailable","code":"MODEL_DOWN"}</mothership-error>', + ].join('') + + const segments = parseChunks([...content]) + + expect(segments.map((segment) => segment.kind).filter((kind) => kind !== 'text')).toEqual([ + 'thinking', + 'options', + 'question', + 'credential', + 'workspace_resource', + 'usage_upgrade', + 'mothership-error', + ]) + }) + + it('does not treat a closing marker inside a JSON string as the tag boundary', () => { + const segments = parseChunks([ + '<opt', + 'ions>{"1":{"title":"Show </options> literally","description":"escaped \\\"quote\\\""}}</opt', + 'ions>', + ]) + + expect(segments).toEqual([ + { + kind: 'options', + choices: [ + { + value: 'Show </options> literally', + label: 'Show </options> literally', + description: 'escaped "quote"', + }, + ], + }, + ]) + }) + + it('preserves valid-looking structured examples inside inline and fenced code', () => { + const inline = '`<options>{"1":{"title":"A","description":"B"}}</options>`' + const fenced = + '```json\n<question>{"type":"single_select","prompt":"P","options":[{"id":"a","label":"A"}]}</question>\n```' + const content = `${inline}\n${fenced}` + + expect(renderChatStructured(parseChunks([...content])).text).toBe(content) + }) + + it('strips malformed options and preserves unknown tags as sanitized text', () => { + const content = `before <options>{bad${ESC}[2A</options> <future>${ESC}]0;pwned\u0007ok</future>` + + const result = renderChatStructured(parseChatStructured(content)) + + expect(result.text).toContain('before<future>') + expect(result.text).toContain('<future>ok</future>') + expect(result.text).not.toContain('interactive response') + expect(result.text).not.toContain(ESC) + }) + + it('holds incomplete wrappers until finish and then preserves them', () => { + const parser = new ChatStructuredParser() + + expect(parser.push('answer <quest')).toEqual([{ kind: 'text', text: 'answer ' }]) + expect(parser.push('ion>{"type":"single_select"')).toEqual([]) + expect(parser.finish()).toEqual([ + { kind: 'text', text: 'Sim Chat requested an interactive response.' }, + ]) + }) + + it('drops an unclosed thinking wrapper when the stream finishes', () => { + const parser = new ChatStructuredParser() + + expect(parser.push('answer <thinking>still reasoning about')).toEqual([ + { kind: 'text', text: 'answer ' }, + ]) + expect(parser.finish()).toEqual([]) + }) + + it('recovers useful prompts from invalid but parseable question payloads', () => { + const segments = parseChatStructured( + '<question>[{"type":"single_select","prompt":"Which\\nservice?","options":[]},{"prompt":"Deploy where?"}]</question>' + ) + + expect(segments).toEqual([{ kind: 'text', text: 'Which service?\n\nDeploy where?' }]) + }) + + it('recovers a prompt from an otherwise complete question missing its closing tag', () => { + const parser = new ChatStructuredParser() + + expect( + parser.push( + '<question>{"type":"single_select","prompt":"Continue?","options":[{"id":"yes","label":"Yes"}]}' + ) + ).toEqual([]) + expect(parser.finish()).toEqual([{ kind: 'text', text: 'Continue?' }]) + }) + + it('rejects question payloads beyond the interaction bounds and bounds prompt recovery', () => { + const fourQuestions = Array.from({ length: 4 }, (_, index) => ({ + type: 'single_select', + prompt: `Question ${index + 1}`, + options: [{ id: 'yes', label: 'Yes' }], + })) + const tooManyOptions = { + type: 'single_select', + prompt: 'Pick one', + options: Array.from({ length: 21 }, (_, index) => ({ + id: `option-${index}`, + label: `Option ${index}`, + })), + } + + expect(parseChatStructured(`<question>${JSON.stringify(fourQuestions)}</question>`)).toEqual([ + { kind: 'text', text: 'Question 1\n\nQuestion 2\n\nQuestion 3' }, + ]) + expect(parseChatStructured(`<question>${JSON.stringify(tooManyOptions)}</question>`)).toEqual([ + { kind: 'text', text: 'Pick one' }, + ]) + }) + + it('accepts question values at their limits and rejects overlong prompt, id, and label fields', () => { + const boundedQuestion = { + type: 'multi_select', + prompt: 'p'.repeat(1024), + options: Array.from({ length: 20 }, (_, index) => ({ + id: `${index}-${'i'.repeat(157)}`, + label: 'l'.repeat(160), + })), + } + const atLimits = parseChatStructured( + `<question>${JSON.stringify([boundedQuestion, boundedQuestion, boundedQuestion])}</question>` + ) + + expect(atLimits).toHaveLength(1) + expect(atLimits[0]?.kind).toBe('question') + if (atLimits[0]?.kind !== 'question') throw new Error('Expected a question segment') + expect(atLimits[0].questions).toHaveLength(3) + expect(atLimits[0].questions[0]?.options).toHaveLength(20) + + for (const invalid of [ + { ...boundedQuestion, prompt: 'p'.repeat(1025) }, + { ...boundedQuestion, options: [{ id: 'i'.repeat(161), label: 'Valid' }] }, + { ...boundedQuestion, options: [{ id: 'valid', label: 'l'.repeat(161) }] }, + ]) { + const segments = parseChatStructured(`<question>${JSON.stringify(invalid)}</question>`) + expect(segments.some((segment) => segment.kind === 'question')).toBe(false) + } + }) + + it('strips an incomplete options wrapper at end of stream', () => { + const parser = new ChatStructuredParser() + + expect(parser.push('answer <options>{"1":{"title":"Next"')).toEqual([ + { kind: 'text', text: 'answer ' }, + ]) + expect(parser.finish()).toEqual([{ kind: 'options', choices: [] }]) + }) + + it('keeps a CRLF stable when its bytes arrive in separate string fragments', () => { + expect(renderChatStructured(parseChunks(['first\r', '\nsecond'])).text).toBe('first\nsecond') + }) + + it('strips options even when their decoded values contain controls', () => { + const result = renderChatStructured( + '<options>{"1":{"title":"Safe\\u001b[2A title","description":"D"}}</options>' + ) + + expect(result).toMatchObject({ text: '', interactions: [] }) + }) + + it('sanitizes directly supplied segments as a defense-in-depth boundary', () => { + const result = renderChatStructured([ + { kind: 'text', text: `safe${ESC}[2A text` }, + { + kind: 'options', + choices: [{ value: `next${ESC}c`, label: `Next${ESC}]0;x\u0007`, description: 'D' }], + }, + ]) + + expect(result).toMatchObject({ text: 'safe text', interactions: [] }) + }) + + it('flattens interactive prompts, labels, and descriptions onto terminal-safe lines', () => { + const result = renderChatStructured( + [ + '<options>{"1":{"title":"Inspect\\nlogs","description":"Find\\t recent\\nerrors"}}</options>', + '<question>{"type":"single_select","prompt":"Which\\nservice?","options":[{"id":"a\\nb","label":"API\\nworker"}]}</question>', + ].join(''), + { printMode: false } + ) + + expect(result.interactions).toEqual([ + { + kind: 'question', + questions: [ + { + type: 'single_select', + prompt: 'Which service?', + options: [{ id: 'a b', label: 'API worker' }], + }, + ], + }, + ]) + }) +}) + +describe('renderChatStructured', () => { + it('strips options while preserving question interactions and print text', () => { + const result = renderChatStructured( + [ + '<options>{"1":{"title":"Inspect logs","description":"Find errors"}}</options>', + '<question>[{"type":"multi_select","prompt":"Pick services","options":[{"id":"api","label":"API"},{"id":"other","label":"Something else"}]}]</question>', + ].join('\n') + ) + + expect(result.text).toBe('Pick services') + expect(result.interactions).toEqual([ + { + kind: 'question', + questions: [ + { + type: 'multi_select', + prompt: 'Pick services', + options: [{ id: 'api', label: 'API' }], + }, + ], + }, + ]) + }) + + it('strips options without producing an interaction outside print mode', () => { + const result = renderChatStructured( + 'before<options>{"1":{"title":"Next","description":"Continue"}}</options>after', + { printMode: false } + ) + + expect(result.text).toBe('beforeafter') + expect(result.interactions).toEqual([]) + }) + + it('removes whitespace surrounding hidden options at the end of print output', () => { + const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' + + expect(renderChatStructured(`Answer\n\n${options}\n\n`).text).toBe('Answer') + expect(renderChatStructured(`before \n${options}\n after`).text).toBe('beforeafter') + expect(renderChatStructured('Answer\n\n<options>{"1":{"title":"Next"').text).toBe('Answer') + }) + + it('renders workspace resources as names without terminal links or URL suffixes', () => { + const result = renderChatStructured( + '<workspace_resource>{"type":"workflow","id":"wf /1","title":"My workflow"}</workspace_resource>' + ) + + expect(result.text).toBe('My workflow') + expect(result.text).not.toContain(ESC) + expect(result.text).not.toContain('https://') + }) + + it('shows a path-only file title without resolving or appending its VFS path', () => { + const result = renderChatStructured( + '<workspace_resource>{"type":"file","path":"files/Reports/Q4%20Report.csv","title":"Q4"}</workspace_resource>' + ) + + expect(result.text).toBe('Q4') + }) + + it('rejects unsafe credential protocols and control-bearing links', () => { + const unsafeProtocol = renderChatStructured( + '<credential>{"type":"link","provider":"Slack","value":"javascript:alert(1)"}</credential>' + ) + const controlBearing = renderChatStructured( + '<credential>{"type":"link","provider":"Slack","value":"https://safe.test/\\u001b]8;;https://evil.test"}</credential>' + ) + + expect(unsafeProtocol.text).toBe('Open Sim to connect Slack.') + expect(unsafeProtocol.text).not.toContain(ESC) + expect(controlBearing.text).toBe('Open Sim to complete the requested credential action.') + expect(controlBearing.text).not.toContain(ESC) + }) + + it('renders credential links as a plain action without exposing the destination', () => { + const content = + '<credential>{"type":"link","provider":"Slack","value":"https://sim.example.evil.test/connect"}</credential>' + const result = renderChatStructured(content) + + expect(result.text).toBe('Open Sim to connect Slack.') + expect(result.text).not.toContain('sim.example.evil.test') + expect(result.text).not.toContain(ESC) + }) + + it('sanitizes workspace titles before rendering a plain resource name', () => { + const result = renderChatStructured( + '<workspace_resource>{"type":"table","id":"table_1","title":"Orders\\u001b]0;owned\\u0007 safe"}</workspace_resource>' + ) + + expect(result.text).toBe('Orders safe') + expect(result.text).not.toContain(ESC) + }) + + it('never renders credential secret values', () => { + const result = renderChatStructured( + '<credential>{"type":"sim_key","provider":"Sim","value":"secret-value"}</credential>' + ) + + expect(result.text).toBe('Open Sim to configure a Sim API key.') + expect(result.text).not.toContain('secret-value') + }) + + it.each([ + ['env_key', 'Open Sim to configure Slack environment credentials.'], + ['oauth_key', 'Open Sim to connect Slack with OAuth.'], + ['credential_id', 'Open Sim to select Slack credentials.'], + ])('renders %s as a safe action without its value', (type, expected) => { + const result = renderChatStructured( + `<credential>{"type":"${type}","provider":"Slack","value":"never-print-me"}</credential>` + ) + + expect(result.text).toBe(expected) + expect(result.text).not.toContain('never-print-me') + }) + + it('hides thinking and safely renders usage and mothership errors', () => { + const result = renderChatStructured( + 'Answer<thinking>secret reasoning</thinking><usage_upgrade>{"reason":"quota","action":"increase_limit","message":"Increase limit"}</usage_upgrade><mothership-error>{"message":"Retry later","code":"BUSY","provider":"x"}</mothership-error>' + ) + + expect(result.text).toBe('Answer\n\nUsage limit reached: Increase limit\n\nRetry later (BUSY)') + expect(result.text).not.toContain('secret reasoning') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-structured.ts b/packages/sim-cli/src/commands/protocol/chat-structured.ts new file mode 100644 index 00000000000..999de64e7d1 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-structured.ts @@ -0,0 +1,748 @@ +import { sanitize } from '../../output/render.js' + +export const OFFICIAL_CHAT_TAG_NAMES = [ + 'thinking', + 'options', + 'question', + 'credential', + 'workspace_resource', + 'usage_upgrade', + 'mothership-error', +] as const + +export type OfficialChatTagName = (typeof OFFICIAL_CHAT_TAG_NAMES)[number] + +export interface ChatChoice { + value: string + label: string + description: string +} + +export interface ChatQuestionOption { + id: string + label: string +} + +export interface ChatQuestion { + type: 'single_select' | 'multi_select' + prompt: string + options: ChatQuestionOption[] +} + +export interface ChatOptionsInteraction { + kind: 'options' + choices: ChatChoice[] +} + +export interface ChatQuestionInteraction { + kind: 'question' + questions: ChatQuestion[] +} + +export type ChatInteraction = ChatOptionsInteraction | ChatQuestionInteraction + +export type ChatCredentialType = + | 'env_key' + | 'oauth_key' + | 'sim_key' + | 'credential_id' + | 'link' + | 'secret_input' + | 'folder_access' + | 'browser_takeover' + | 'terminal_handoff' + | 'service_account' + +export interface ChatCredential { + type: ChatCredentialType + provider?: string + value?: string + name?: string + scope?: 'personal' | 'workspace' + credentialId?: string +} + +export interface ChatWorkspaceResource { + type: 'workflow' | 'table' | 'file' + id?: string + path?: string + title?: string +} + +export interface ChatUsageUpgrade { + reason: string + action: 'upgrade_plan' | 'increase_limit' + message: string +} + +export interface ChatMothershipError { + message: string + code?: string + provider?: string +} + +export type ChatStructuredSegment = + | { kind: 'text'; text: string } + | { kind: 'thinking'; content: string } + | { kind: 'options'; choices: ChatChoice[] } + | { kind: 'question'; questions: ChatQuestion[] } + | { kind: 'credential'; credential: ChatCredential } + | { kind: 'workspace_resource'; resource: ChatWorkspaceResource } + | { kind: 'usage_upgrade'; upgrade: ChatUsageUpgrade } + | { kind: 'mothership-error'; error: ChatMothershipError } + +export interface ChatStructuredRenderOptions { + printMode?: boolean +} + +export interface ChatStructuredRenderResult { + text: string + interactions: ChatInteraction[] + /** + * The parts `text` was joined from, each tagged block or inline. + * + * Exposed so an incremental renderer can reuse this classification instead of + * re-deriving it per segment kind — two copies of that rule drift apart and + * nothing catches it, since only one of them is exercised by the one-shot path. + */ + parts: readonly RenderPart[] +} + +interface OpeningTagMatch { + index: number + name: OfficialChatTagName +} + +export interface RenderPart { + block: boolean + value: string +} + +type JsonRecord = Record<string, unknown> + +const OPENING_TAGS = OFFICIAL_CHAT_TAG_NAMES.map((name) => ({ + marker: `<${name}>`, + name, +})) + +const QUESTION_TYPES = new Set(['single_select', 'multi_select']) +const MAX_QUESTIONS = 3 +const MAX_QUESTION_OPTIONS = 20 +const MAX_QUESTION_PROMPT_LENGTH = 1024 +const MAX_QUESTION_OPTION_FIELD_LENGTH = 160 +const CREDENTIAL_TYPES = new Set<ChatCredentialType>([ + 'env_key', + 'oauth_key', + 'sim_key', + 'credential_id', + 'link', + 'secret_input', + 'folder_access', + 'browser_takeover', + 'terminal_handoff', + 'service_account', +]) +const QUESTION_CATCH_ALL_LABELS = new Set([ + 'other', + 'others', + 'something else', + 'none of the above', + 'none of these', +]) +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u + +/** Sanitizes one server-owned fragment before it enters parser state. */ +function sanitizeServerString(value: string): string { + return sanitize(value).replace(/\r/g, '') +} + +function oneLine(value: string): string { + return sanitizeServerString(value) + .replace(/[\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function cleanOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? sanitizeServerString(value) : undefined +} + +function parseJson(body: string): unknown | undefined { + try { + return JSON.parse(body) as unknown + } catch { + return undefined + } +} + +function parseChoices(value: unknown): ChatChoice[] | null { + if (value === null || typeof value !== 'object') return null + + const choices: ChatChoice[] = [] + for (const item of Object.values(value)) { + if (!isRecord(item) || typeof item.title !== 'string' || typeof item.description !== 'string') { + return null + } + const title = oneLine(item.title) + choices.push({ + value: title, + label: title, + description: oneLine(item.description), + }) + } + return choices +} + +function parseQuestion(value: unknown): ChatQuestion | null { + if (!isRecord(value) || !QUESTION_TYPES.has(String(value.type))) return null + if (typeof value.prompt !== 'string') return null + + const prompt = oneLine(value.prompt) + if ( + !prompt || + prompt.length > MAX_QUESTION_PROMPT_LENGTH || + !Array.isArray(value.options) || + value.options.length === 0 || + value.options.length > MAX_QUESTION_OPTIONS + ) { + return null + } + + const options: ChatQuestionOption[] = [] + for (const option of value.options) { + if (!isRecord(option) || typeof option.id !== 'string' || typeof option.label !== 'string') { + return null + } + const id = oneLine(option.id) + const label = oneLine(option.label) + if ( + !id || + !label || + id.length > MAX_QUESTION_OPTION_FIELD_LENGTH || + label.length > MAX_QUESTION_OPTION_FIELD_LENGTH + ) { + return null + } + if (QUESTION_CATCH_ALL_LABELS.has(label.trim().toLowerCase())) continue + options.push({ id, label }) + } + if (options.length === 0) return null + + return { + type: value.type as ChatQuestion['type'], + prompt, + options, + } +} + +function parseQuestions(value: unknown): ChatQuestion[] | null { + const values = Array.isArray(value) ? value : [value] + if (values.length === 0 || values.length > MAX_QUESTIONS) return null + + const questions: ChatQuestion[] = [] + for (const candidate of values) { + const question = parseQuestion(candidate) + if (!question) return null + questions.push(question) + } + return questions +} + +function recoverQuestionPrompts(body: string): string | null { + const payload = parseJson(body) + if (payload === undefined) return null + + const values = (Array.isArray(payload) ? payload : [payload]).slice(0, MAX_QUESTIONS) + const prompts: string[] = [] + for (const value of values) { + if (!isRecord(value) || typeof value.prompt !== 'string') continue + const prompt = oneLine(value.prompt) + if (prompt && prompt.length <= MAX_QUESTION_PROMPT_LENGTH) prompts.push(prompt) + } + return prompts.length > 0 ? prompts.join('\n\n') : null +} + +function parseCredential(value: unknown): ChatCredential | null { + if (!isRecord(value) || typeof value.type !== 'string') return null + if (!CREDENTIAL_TYPES.has(value.type as ChatCredentialType)) return null + if (value.provider !== undefined && typeof value.provider !== 'string') return null + + const type = value.type as ChatCredentialType + const provider = cleanOptionalString(value.provider) + + if (type === 'secret_input') { + if (typeof value.name !== 'string' || !sanitizeServerString(value.name).trim()) return null + if (value.scope !== undefined && value.scope !== 'personal' && value.scope !== 'workspace') { + return null + } + return { + type, + provider, + name: sanitizeServerString(value.name), + scope: value.scope as ChatCredential['scope'], + } + } + + if (type === 'folder_access' || type === 'browser_takeover' || type === 'terminal_handoff') { + if (value.name !== undefined && typeof value.name !== 'string') return null + return { type, provider, name: cleanOptionalString(value.name) } + } + + if (type === 'service_account') { + if (!provider?.trim()) return null + if ( + value.credentialId !== undefined && + (typeof value.credentialId !== 'string' || !sanitizeServerString(value.credentialId).trim()) + ) { + return null + } + return { + type, + provider, + credentialId: cleanOptionalString(value.credentialId), + } + } + + if (type === 'sim_key') return { type, provider } + if (typeof value.value !== 'string') return null + if (type === 'link' && TERMINAL_CONTROL_PATTERN.test(value.value)) return null + return { type, provider, value: sanitizeServerString(value.value) } +} + +function cleanLinkIdentifier(value: unknown): string | undefined { + if (typeof value !== 'string' || TERMINAL_CONTROL_PATTERN.test(value)) return undefined + const cleaned = sanitizeServerString(value).trim() + return cleaned || undefined +} + +function parseWorkspaceResource(value: unknown): ChatWorkspaceResource | null { + if ( + !isRecord(value) || + (value.type !== 'workflow' && value.type !== 'table' && value.type !== 'file') + ) { + return null + } + if (value.id !== undefined && typeof value.id !== 'string') return null + if (value.path !== undefined && typeof value.path !== 'string') return null + if (value.title !== undefined && typeof value.title !== 'string') return null + + const id = cleanLinkIdentifier(value.id) + const path = cleanLinkIdentifier(value.path) + if ((value.type === 'workflow' || value.type === 'table') && !id) return null + if (value.type === 'file' && !id && !path) return null + + return { + type: value.type, + id, + path, + title: cleanOptionalString(value.title), + } +} + +function parseUsageUpgrade(value: unknown): ChatUsageUpgrade | null { + if (!isRecord(value)) return null + if (typeof value.reason !== 'string' || typeof value.message !== 'string') return null + if (value.action !== 'upgrade_plan' && value.action !== 'increase_limit') return null + return { + reason: sanitizeServerString(value.reason), + action: value.action, + message: sanitizeServerString(value.message), + } +} + +function parseMothershipError(value: unknown): ChatMothershipError | null { + if (!isRecord(value) || typeof value.message !== 'string') return null + if (value.code !== undefined && typeof value.code !== 'string') return null + if (value.provider !== undefined && typeof value.provider !== 'string') return null + return { + message: sanitizeServerString(value.message), + code: cleanOptionalString(value.code), + provider: cleanOptionalString(value.provider), + } +} + +function parseTag(name: OfficialChatTagName, body: string): ChatStructuredSegment | null { + if (name === 'thinking') { + return body.trim() ? { kind: 'thinking', content: sanitizeServerString(body) } : null + } + + const payload = parseJson(body) + if (payload === undefined) return null + + if (name === 'options') { + const choices = parseChoices(payload) + return choices ? { kind: 'options', choices } : null + } + if (name === 'question') { + const questions = parseQuestions(payload) + return questions ? { kind: 'question', questions } : null + } + if (name === 'credential') { + const credential = parseCredential(payload) + return credential ? { kind: 'credential', credential } : null + } + if (name === 'workspace_resource') { + const resource = parseWorkspaceResource(payload) + return resource ? { kind: 'workspace_resource', resource } : null + } + if (name === 'usage_upgrade') { + const upgrade = parseUsageUpgrade(payload) + return upgrade ? { kind: 'usage_upgrade', upgrade } : null + } + + const error = parseMothershipError(payload) + return error ? { kind: 'mothership-error', error } : null +} + +function invalidTagFallback( + name: OfficialChatTagName, + body?: string +): ChatStructuredSegment | null { + if (name === 'thinking') return null + if (name === 'credential') { + return { kind: 'text', text: 'Open Sim to complete the requested credential action.' } + } + // Keep an internal empty marker so renderers can discard whitespace that was + // emitted before a malformed or incomplete suggestions wrapper. The marker + // itself still renders as nothing and never becomes an interaction. + if (name === 'options') return { kind: 'options', choices: [] } + if (name === 'question') { + return { + kind: 'text', + text: + (body === undefined ? null : recoverQuestionPrompts(body)) ?? + 'Sim Chat requested an interactive response.', + } + } + if (name === 'workspace_resource') { + return { kind: 'text', text: 'Sim Chat referenced a workspace resource.' } + } + if (name === 'usage_upgrade') return { kind: 'text', text: 'Usage limit reached.' } + return { kind: 'text', text: 'Sim Chat reported an error.' } +} + +function nextMarkdownDelimiter(value: string, initialDelimiter: number): number { + let delimiter = initialDelimiter + let index = 0 + while (index < value.length) { + if (value[index] !== '`') { + index += 1 + continue + } + let end = index + 1 + while (end < value.length && value[end] === '`') end += 1 + const runLength = end - index + if (delimiter === 0) delimiter = runLength + else if (runLength >= delimiter) delimiter = 0 + index = end + } + return delimiter +} + +function findOpeningTag(value: string, initialDelimiter: number): OpeningTagMatch | null { + let delimiter = initialDelimiter + let index = 0 + while (index < value.length) { + if (value[index] === '`') { + let end = index + 1 + while (end < value.length && value[end] === '`') end += 1 + const runLength = end - index + if (delimiter === 0) delimiter = runLength + else if (runLength >= delimiter) delimiter = 0 + index = end + continue + } + + if (delimiter === 0 && value[index] === '<') { + for (const opening of OPENING_TAGS) { + if (value.startsWith(opening.marker, index)) return { index, name: opening.name } + } + } + index += 1 + } + return null +} + +function findClosingTag(value: string, start: number, name: OfficialChatTagName): number { + const closing = `</${name}>` + if (name === 'thinking') return value.indexOf(closing, start) + + let inString = false + let escaped = false + for (let index = start; index < value.length; index += 1) { + const character = value[index] + if (inString) { + if (escaped) escaped = false + else if (character === '\\') escaped = true + else if (character === '"') inString = false + continue + } + if (character === '"') { + inString = true + continue + } + if (value.startsWith(closing, index)) return index + } + return -1 +} + +function trailingBacktickRun(value: string): number { + let index = value.length + while (index > 0 && value[index - 1] === '`') index -= 1 + return value.length - index +} + +function partialOpeningSuffix(value: string): number { + let longest = 0 + for (const { marker } of OPENING_TAGS) { + const limit = Math.min(marker.length - 1, value.length) + for (let length = limit; length > longest; length -= 1) { + if (value.endsWith(marker.slice(0, length))) { + longest = length + break + } + } + } + return longest +} + +function appendText(segments: ChatStructuredSegment[], text: string): void { + if (!text) return + const previous = segments[segments.length - 1] + if (previous?.kind === 'text') previous.text += text + else segments.push({ kind: 'text', text }) +} + +/** + * Incrementally parses structured Sim Chat tags while retaining possible openers + * and incomplete wrappers across arbitrary transport chunk boundaries. + */ +export class ChatStructuredParser { + private buffer = '' + private markdownDelimiter = 0 + private finished = false + + push(fragment: string): ChatStructuredSegment[] { + if (this.finished) throw new Error('Cannot push to a finished chat parser.') + this.buffer += sanitizeServerString(fragment) + return this.drain(false) + } + + finish(): ChatStructuredSegment[] { + if (this.finished) return [] + this.finished = true + return this.drain(true) + } + + private consumeText(length: number, segments: ChatStructuredSegment[]): void { + const text = this.buffer.slice(0, length) + this.buffer = this.buffer.slice(length) + this.markdownDelimiter = nextMarkdownDelimiter(text, this.markdownDelimiter) + appendText(segments, text) + } + + private drain(final: boolean): ChatStructuredSegment[] { + const segments: ChatStructuredSegment[] = [] + + while (this.buffer) { + const opening = findOpeningTag(this.buffer, this.markdownDelimiter) + if (!opening) { + const retained = final + ? 0 + : Math.max(partialOpeningSuffix(this.buffer), trailingBacktickRun(this.buffer)) + const consumable = this.buffer.length - retained + if (consumable > 0) this.consumeText(consumable, segments) + break + } + + if (opening.index > 0) { + this.consumeText(opening.index, segments) + continue + } + + const openingMarker = `<${opening.name}>` + const closingMarker = `</${opening.name}>` + const closingIndex = findClosingTag(this.buffer, openingMarker.length, opening.name) + if (closingIndex === -1) { + if (final) { + if (opening.name === 'thinking') { + // Thinking is intentionally hidden from terminal output. If the stream + // ends before the wrapper closes, fail closed instead of exposing its + // potentially private contents as ordinary text. + this.buffer = '' + } else { + const body = this.buffer.slice(openingMarker.length) + this.buffer = '' + const fallback = invalidTagFallback(opening.name, body) + if (fallback) segments.push(fallback) + } + } + break + } + + const end = closingIndex + closingMarker.length + const body = this.buffer.slice(openingMarker.length, closingIndex) + const parsed = parseTag(opening.name, body) + if (!parsed) { + this.buffer = this.buffer.slice(end) + const fallback = invalidTagFallback(opening.name, body) + if (fallback) segments.push(fallback) + continue + } + + this.buffer = this.buffer.slice(end) + segments.push(parsed) + } + + return segments + } +} + +/** Parses a completed Sim Chat response into sanitized structured segments. */ +export function parseChatStructured(content: string): ChatStructuredSegment[] { + const parser = new ChatStructuredParser() + return [...parser.push(content), ...parser.finish()] +} + +function resourceLabel(resource: ChatWorkspaceResource): string { + const title = oneLine(resource.title ?? '') + if (title) return title + if (resource.type === 'file') return oneLine(resource.path ?? resource.id ?? 'File') || 'File' + return resource.type === 'workflow' ? 'Workflow' : 'Table' +} + +function renderResource(resource: ChatWorkspaceResource): string { + return resourceLabel(resource) +} + +function renderCredential(credential: ChatCredential): string { + const provider = oneLine(credential.provider ?? '') || 'account' + const name = oneLine(credential.name ?? '') + + if (credential.type === 'link' && credential.value) { + return `Open Sim to connect ${provider}.` + } + if (credential.type === 'service_account') { + return `Open Sim to connect ${provider} with a service account.` + } + if (credential.type === 'secret_input') { + return `Open Sim to provide ${name || 'the requested secret'}.` + } + if (credential.type === 'folder_access') { + return `Open Sim Desktop to grant access to ${name || 'the requested folder'}.` + } + if (credential.type === 'browser_takeover') { + return `Open Sim Desktop to continue ${name || 'the browser task'}.` + } + if (credential.type === 'terminal_handoff') { + return `Open Sim Desktop to continue ${name || 'the terminal task'}.` + } + if (credential.type === 'env_key') { + return `Open Sim to configure ${provider} environment credentials.` + } + if (credential.type === 'oauth_key') return `Open Sim to connect ${provider} with OAuth.` + if (credential.type === 'credential_id') return `Open Sim to select ${provider} credentials.` + if (credential.type === 'sim_key') return 'Open Sim to configure a Sim API key.' + return `Open Sim to configure ${provider} credentials.` +} + +function renderQuestions(questions: ChatQuestion[]): string { + return questions.map((question) => oneLine(question.prompt)).join('\n\n') +} + +function addPart(parts: RenderPart[], value: string, block: boolean): void { + if (value) parts.push({ block, value: sanitizeServerString(value) }) +} + +function trimRenderedEnd(parts: RenderPart[]): void { + while (parts.length > 0) { + const last = parts[parts.length - 1] + last.value = last.value.trimEnd() + if (last.value) return + parts.pop() + } +} + +function joinRenderParts(parts: RenderPart[]): string { + let output = '' + let previous: RenderPart | undefined + for (const part of parts) { + if (output && (part.block || previous?.block)) { + const trailing = output.match(/\n*$/u)?.[0].length ?? 0 + const leading = part.value.match(/^\n*/u)?.[0].length ?? 0 + output += '\n'.repeat(Math.max(0, 2 - trailing - leading)) + } + output += part.value + previous = part + } + return output +} + +/** + * Renders structured chat as deterministic terminal-safe text. + */ +export function renderChatStructured( + input: string | readonly ChatStructuredSegment[], + options: ChatStructuredRenderOptions = {} +): ChatStructuredRenderResult { + const segments = typeof input === 'string' ? parseChatStructured(input) : input + const printMode = options.printMode !== false + const parts: RenderPart[] = [] + const interactions: ChatInteraction[] = [] + let strippedOptions = false + + for (const segment of segments) { + if (segment.kind === 'text') { + const text = strippedOptions ? segment.text.replace(/^\s+/u, '') : segment.text + if (strippedOptions && !text) continue + strippedOptions = false + addPart(parts, text, false) + continue + } + if (segment.kind === 'thinking') continue + if (segment.kind === 'workspace_resource') { + addPart(parts, renderResource(segment.resource), false) + continue + } + if (segment.kind === 'options') { + // Follow-up suggestions are browser UI metadata, not answer text. The + // terminal composer stays ordinary free-form input, so omit them fully. + trimRenderedEnd(parts) + strippedOptions = true + continue + } + strippedOptions = false + if (segment.kind === 'question') { + const questions = segment.questions.map((question) => ({ + type: question.type, + prompt: oneLine(question.prompt), + options: question.options.map((option) => ({ + id: oneLine(option.id), + label: oneLine(option.label), + })), + })) + interactions.push({ kind: 'question', questions }) + if (printMode) addPart(parts, renderQuestions(questions), true) + continue + } + if (segment.kind === 'credential') { + addPart(parts, renderCredential(segment.credential), true) + continue + } + if (segment.kind === 'usage_upgrade') { + addPart(parts, `Usage limit reached: ${oneLine(segment.upgrade.message)}`, true) + continue + } + addPart( + parts, + `${oneLine(segment.error.message)}${segment.error.code ? ` (${oneLine(segment.error.code)})` : ''}`, + true + ) + } + + return { text: joinRenderParts(parts), interactions, parts } +} diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts new file mode 100644 index 00000000000..f271ff071dd --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import { + applySuggestion, + type ChatContext, + contextSpans, + extractCompletionToken, + formatMention, + presentContexts, + rankSuggestions, + resolveSlashContexts, + SLASH_COMMANDS, + type SuggestionItem, + suggestionWindow, +} from './chat-suggestions.js' + +const item = (value: string, description?: string): SuggestionItem => ({ + id: value, + value, + displayText: value, + description, +}) + +describe('slash commands', () => { + it('offers chat switching and renaming without requiring arguments to open the menu', () => { + expect(SLASH_COMMANDS).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: '/chats', displayText: '/chats' }), + expect.objectContaining({ value: '/rename', displayText: '/rename <title>' }), + ]) + ) + }) +}) + +describe('extractCompletionToken', () => { + it('opens a slash context at the start of any token', () => { + expect(extractCompletionToken('/att', 4)).toMatchObject({ trigger: '/', query: 'att' }) + expect(extractCompletionToken('hi /att', 7)).toMatchObject({ + trigger: '/', + query: 'att', + startPos: 3, + }) + }) + + it('closes the slash context once an argument is typed', () => { + expect(extractCompletionToken('/attach ', 8)).toBeNull() + }) + + it('opens a mention at the start or after whitespace', () => { + expect(extractCompletionToken('@rev', 4)).toMatchObject({ + trigger: '@', + query: 'rev', + startPos: 0, + }) + expect(extractCompletionToken('use @rev', 8)).toMatchObject({ + trigger: '@', + query: 'rev', + startPos: 4, + }) + }) + + it('closes a mention once a slash is typed, matching the client editor', () => { + expect(extractCompletionToken('@logs/incident', 14)).toBeNull() + }) + + it('does not treat an email address as a mention', () => { + expect(extractCompletionToken('mail foo@bar.com', 16)).toBeNull() + }) + + it('reads from the cursor, not the end of the draft', () => { + expect(extractCompletionToken('@rev trailing', 4)).toMatchObject({ query: 'rev' }) + }) + + it('returns null for a bare draft', () => { + expect(extractCompletionToken('hello world', 11)).toBeNull() + }) +}) + +describe('rankSuggestions', () => { + const candidates = [ + item('attach'), + item('clear'), + item('help'), + item('paste-image'), + item('chat'), + ] + + it('returns everything for an empty query', () => { + expect(rankSuggestions('', candidates)).toHaveLength(5) + }) + + it('preserves source order while filtering by substring', () => { + const filtered = rankSuggestions('c', [item('clear'), item('c'), item('chat')]) + expect(filtered.map((entry) => entry.value)).toEqual(['clear', 'c', 'chat']) + }) + + it('matches substrings but not fuzzy subsequences', () => { + expect(rankSuggestions('image', candidates)[0]?.value).toBe('paste-image') + expect(rankSuggestions('pti', candidates)).toEqual([]) + }) + + it('does not search descriptions', () => { + expect( + rankSuggestions('clipboard', [item('paste-image', 'attach from the clipboard')]) + ).toEqual([]) + }) + + it('drops non-matches', () => { + expect(rankSuggestions('zzz', candidates)).toEqual([]) + }) +}) + +describe('suggestionWindow', () => { + it('shows everything when the list fits', () => { + expect(suggestionWindow(3, 0, 5)).toEqual({ start: 0, end: 3 }) + }) + + it('centres the window on the selection', () => { + expect(suggestionWindow(20, 10, 5)).toEqual({ start: 8, end: 13 }) + }) + + it('clamps at both ends', () => { + expect(suggestionWindow(20, 0, 5)).toEqual({ start: 0, end: 5 }) + expect(suggestionWindow(20, 19, 5)).toEqual({ start: 15, end: 20 }) + }) +}) + +describe('applySuggestion', () => { + it('replaces the trigger token and leaves a trailing space', () => { + const token = extractCompletionToken('/att', 4) + expect(token).not.toBeNull() + expect(applySuggestion('/att', token!, '/attach')).toEqual({ draft: '/attach ', cursor: 8 }) + }) + + it('preserves text after the cursor without doubling the separator', () => { + const token = extractCompletionToken('use @rev and go', 8) + expect(applySuggestion('use @rev and go', token!, '@reviewer')).toEqual({ + draft: 'use @reviewer and go', + cursor: 13, + }) + }) +}) + +describe('formatMention', () => { + it('matches the client literal insertion for single and multiword labels', () => { + expect(formatMention('reviewer')).toBe('@reviewer') + expect(formatMention('code reviewer')).toBe('@code reviewer') + }) +}) + +describe('structured tag contexts', () => { + const workflow: ChatContext = { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + } + const skill: ChatContext = { kind: 'skill', skillId: 'skill-1', label: 'review' } + const mcp: ChatContext = { kind: 'mcp', serverId: 'mcp-1', label: 'review' } + + it('finds literal multiword resource and slash spans', () => { + expect(contextSpans('use @Release notes with /review', [workflow, skill])).toEqual([ + { start: 4, end: 18 }, + { start: 24, end: 31 }, + ]) + }) + + it('drops a selected context when its exact token is gone', () => { + expect(presentContexts('use @Release notes', [workflow])).toEqual([workflow]) + expect(presentContexts('use @Release note', [workflow])).toEqual([]) + }) + + it('auto-resolves typed slash tags with skill precedence over a same-name MCP', () => { + const candidates: SuggestionItem[] = [ + { id: 'skill', value: 'review', displayText: '/review', context: skill }, + { id: 'mcp', value: 'review', displayText: '/review', context: mcp }, + ] + expect(resolveSlashContexts('please /REVIEW this', candidates)).toEqual([skill]) + expect(resolveSlashContexts('path/to/review', candidates)).toEqual([]) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts new file mode 100644 index 00000000000..c4ccbf17cf5 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts @@ -0,0 +1,223 @@ +/** + * Composer autocomplete: trigger detection, ranking and windowing. + * + * Pure and ANSI-free so it can be unit tested without a terminal; the caller + * owns painting. Tags stay plain draft text while their selected identities + * travel beside the draft. Submit keeps an identity only while its exact tag + * remains, so a half-deleted tag degrades to literal text instead of a dangling + * resource reference. + */ + +import type { ChatBody } from '../../generated/v2-api.js' + +export type ChatContext = NonNullable<ChatBody['contexts']>[number] + +/** One row in the suggestion list. */ +export interface SuggestionItem { + /** Stable across refreshes — selection is tracked by id, never by index. */ + id: string + /** What the user picks, and what gets written into the draft. */ + value: string + displayText: string + description?: string + tag?: string + /** Exact identity sent beside the prompt when this tag remains present. */ + context?: ChatContext +} + +export interface ChatSuggestionCandidates { + resources: SuggestionItem[] + slash: SuggestionItem[] +} + +export interface CompletionToken { + /** Includes the trigger character. */ + token: string + /** Index of the trigger character within the draft. */ + startPos: number + /** Text after the trigger, i.e. what to filter on. */ + query: string + trigger: '/' | '@' +} + +const MENTION_QUERY_BOUNDARY = /[\s.,;:!?(){}[\]"'`/\\<>]/u +const SLASH_QUERY_BOUNDARY = /[\s.,;:!?(){}[\]"'`\\<>]/u +const TAG_END_BOUNDARY = /^[\s.,;:!?(){}[\]"'`/\\<>]/u + +/** + * Walk backwards from the cursor to find an open completion context. + * + * A trigger only counts at the start of the draft or after whitespace, so an + * email address in prose cannot open a mention. Returns null when the cursor is + * not inside a completion. + */ +export function extractCompletionToken(text: string, cursor: number): CompletionToken | null { + const before = text.slice(0, Math.max(0, Math.min(cursor, text.length))) + for (let index = before.length - 1; index >= 0; index -= 1) { + const trigger = before[index] + if (trigger !== '@' && trigger !== '/') continue + if (index > 0 && !/\s/u.test(before[index - 1] as string)) continue + + const query = before.slice(index + 1) + const boundary = trigger === '@' ? MENTION_QUERY_BOUNDARY : SLASH_QUERY_BOUNDARY + if (boundary.test(query)) return null + return { token: before.slice(index), startPos: index, query, trigger } + } + return null +} + +/** + * Filter like the home composer: a case-insensitive name substring while + * preserving source order. A leading trigger on local CLI commands is ignored. + */ +export function rankSuggestions(query: string, candidates: SuggestionItem[]): SuggestionItem[] { + const needle = query.trim().toLowerCase() + if (!needle) return [...candidates] + return candidates.filter((item) => + item.value.replace(/^[/@]/u, '').toLowerCase().includes(needle) + ) +} + +/** + * Visible slice for a list taller than the panel, centred on the selection so + * the cursor sits mid-list rather than only scrolling at the edges. + */ +export function suggestionWindow( + total: number, + selected: number, + maxVisible: number +): { start: number; end: number } { + if (total <= maxVisible) return { start: 0, end: total } + const start = Math.max(0, Math.min(selected - Math.floor(maxVisible / 2), total - maxVisible)) + return { start, end: start + maxVisible } +} + +/** The home composer inserts literal multiword labels and carries identity separately. */ +export function formatMention(value: string): string { + return `@${value}` +} + +/** Splice an accepted suggestion over the trigger token. */ +export function applySuggestion( + draft: string, + token: CompletionToken, + replacement: string +): { draft: string; cursor: number } { + const head = draft.slice(0, token.startPos) + const tail = draft.slice(token.startPos + token.token.length) + /* Only add the separating space when the draft does not already have one. */ + const inserted = /^\s/.test(tail) ? replacement : `${replacement} ` + return { draft: `${head}${inserted}${tail}`, cursor: head.length + inserted.length } +} + +export function contextToken(context: ChatContext): string { + return `${context.kind === 'skill' || context.kind === 'mcp' ? '/' : '@'}${context.label}` +} + +function hasContextToken(text: string, context: ChatContext): boolean { + const token = contextToken(context).toLowerCase() + const haystack = text.toLowerCase() + let start = haystack.indexOf(token) + while (start >= 0) { + const before = start === 0 ? '' : text[start - 1] + const after = text[start + token.length] + if ((!before || /\s/u.test(before)) && (!after || TAG_END_BOUNDARY.test(after))) return true + start = haystack.indexOf(token, start + 1) + } + return false +} + +/** Keeps selected identities only while their exact visible tag remains. */ +export function presentContexts(text: string, contexts: ChatContext[]): ChatContext[] { + return contexts.filter((context) => hasContextToken(text, context)) +} + +/** + * Mirrors the client's typed/pasted `/name` auto-registration. Candidate order + * is significant: skills precede MCP servers, so a same-name skill wins. + */ +export function resolveSlashContexts(text: string, candidates: SuggestionItem[]): ChatContext[] { + const contexts: ChatContext[] = [] + const seenLabels = new Set<string>() + for (const candidate of candidates) { + const context = candidate.context + if (!context || (context.kind !== 'skill' && context.kind !== 'mcp')) continue + const label = context.label.toLowerCase() + if (seenLabels.has(label)) continue + seenLabels.add(label) + if (hasContextToken(text, context)) contexts.push(context) + } + return contexts +} + +/** Exact context-backed tags to paint as chips, longest first for overlaps. */ +export function contextSpans( + text: string, + contexts: ChatContext[] +): Array<{ start: number; end: number }> { + const tokens = [...new Set(contexts.map(contextToken))].sort( + (left, right) => right.length - left.length + ) + const ranges: Array<{ start: number; end: number }> = [] + const lower = text.toLowerCase() + for (const token of tokens) { + const needle = token.toLowerCase() + let start = lower.indexOf(needle) + while (start >= 0) { + const before = start === 0 ? '' : text[start - 1] + const after = text[start + token.length] + const overlaps = ranges.some( + (range) => start < range.end && start + token.length > range.start + ) + if ( + (!before || /\s/u.test(before)) && + (!after || TAG_END_BOUNDARY.test(after)) && + !overlaps + ) { + ranges.push({ start, end: start + token.length }) + } + start = lower.indexOf(needle, start + 1) + } + } + return ranges.sort((left, right) => left.start - right.start) +} + +/** Composer slash commands, the source for the `/` menu. */ +export const SLASH_COMMANDS: SuggestionItem[] = [ + { + id: 'attach', + value: '/attach', + displayText: '/attach <paths>', + description: 'attach local files to the next turn', + tag: 'command', + }, + { + id: 'clear', + value: '/clear', + displayText: '/clear', + description: 'start a new conversation', + tag: 'command', + }, + { + id: 'chats', + value: '/chats', + displayText: '/chats', + description: 'view and switch chats', + tag: 'command', + }, + { + id: 'rename', + value: '/rename', + displayText: '/rename <title>', + description: 'rename the active chat', + tag: 'command', + }, + { id: 'help', value: '/help', displayText: '/help', description: 'show help', tag: 'command' }, + { + id: 'exit', + value: '/exit', + displayText: '/exit', + description: 'leave Sim Chat (alias: /quit)', + tag: 'command', + }, +] diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts new file mode 100644 index 00000000000..089ffc54601 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts @@ -0,0 +1,2148 @@ +import { PassThrough } from 'node:stream' +import { Terminal as HeadlessTerminal } from '@xterm/headless' +import { describe, expect, it, vi } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +interface TTYInput extends PassThrough { + isTTY: boolean + isRaw: boolean + setRawMode: ReturnType<typeof vi.fn<(mode: boolean) => void>> +} + +interface TTYOutput extends PassThrough { + isTTY: boolean + columns: number + rows: number +} + +function terminalStreams( + columns = 80, + rows = 24 +): { + input: TTYInput + output: TTYOutput + chunks: string[] +} { + const input = new PassThrough() as TTYInput + input.isTTY = true + input.isRaw = false + input.setRawMode = vi.fn((mode: boolean) => { + input.isRaw = mode + }) + + const output = new PassThrough() as TTYOutput + output.isTTY = true + output.columns = columns + output.rows = rows + const chunks: string[] = [] + output.on('data', (chunk) => chunks.push(String(chunk))) + return { input, output, chunks } +} + +function key(input: TTYInput, character: string, value: Record<string, unknown>): void { + input.emit('keypress', character, value) +} + +function mirrorToHeadless( + output: TTYOutput, + columns: number, + rows: number +): { terminal: HeadlessTerminal; flush: () => Promise<void> } { + const terminal = new HeadlessTerminal({ cols: columns, rows, allowProposedApi: true }) + let writes = Promise.resolve() + output.on('data', (chunk) => { + writes = writes.then( + () => new Promise<void>((resolve) => terminal.write(String(chunk), resolve)) + ) + }) + return { terminal, flush: () => writes } +} + +function paintedPayloads(frame: string): string[] { + const starts = [...frame.matchAll(/\u001b\[\d+;1H\u001b\[2K/gu)] + return starts.map((start, index) => { + const contentStart = (start.index ?? 0) + start[0].length + const nextPaint = starts[index + 1]?.index ?? frame.length + const remainder = frame.slice(contentStart, nextPaint) + const nextControl = remainder.search(/\u001b\[\d+;\d+H|\u001b\[\?25[hl]|\u001b\[\?2026l/u) + return remainder.slice(0, nextControl < 0 ? remainder.length : nextControl) + }) +} + +function payloadDisplayWidth(value: string): number { + const plain = value.replace(/\u001b\[[0-9;:]*m/gu, '') + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + let width = 0 + for (const { segment } of segmenter.segment(plain)) { + if (/^\p{Mark}+$/u.test(segment)) continue + const codePoint = segment.codePointAt(0) ?? 0 + const wide = + segment.includes('\u200d') || + /\p{Extended_Pictographic}/u.test(segment) || + (codePoint >= 0x1100 && + (codePoint <= 0x115f || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff))) + width += wide ? 2 : 1 + } + return width +} + +function plainTerminalText(value: string): string { + return value.replace(/\u001b\[[0-9;:]*m/gu, '') +} + +function visibleTerminalLines(terminal: HeadlessTerminal, rows: number): string[] { + return Array.from( + { length: rows }, + (_, row) => + terminal.buffer.active + .getLine(row) + ?.translateToString(true) + .replace(/\u00a0/gu, ' ') + .trimEnd() ?? '' + ) +} + +function expectUserPanelRow(terminal: HeadlessTerminal, row: number, columns: number): void { + const line = terminal.buffer.active.getLine(row) + expect(line).toBeDefined() + expect(line?.getCell(0)?.isBgDefault()).toBe(true) + for (let column = 1; column < columns - 2; column += 1) { + const cell = line?.getCell(column) + expect(cell?.isBgRGB()).toBe(true) + expect(cell?.getBgColor()).toBe(0x3a3c46) + } + expect(line?.getCell(columns - 2)?.isBgDefault()).toBe(true) + expect(line?.getCell(columns - 1)?.isBgDefault()).toBe(true) +} + +describe('ReadlineChatTerminal', () => { + it('opens with the active chat and switch hint, then reflows for narrow terminals', () => { + const { input, output, chunks } = terminalStreams(80, 16) + const terminal = new ReadlineChatTerminal(input, output) + + terminal.welcome({ chatTitle: 'New chat\u001b]0;owned\u0007' }) + + const wideFrame = chunks.at(-1) ?? '' + expect(wideFrame).toContain('\u001b[97m') + expect(wideFrame).toContain('⠤⠶⠶⠮⣤⣽⠤⠴⡋ ⢘⡦⠤⢤⡏ ⠹⣄⣀⣀⡴⠋⠉⢹⢺⡄') + expect(wideFrame).toContain(' ⠈⠓⠤⢄⣹⡤⠤⢜ ⠳⣄⣀⣀⡞ ⢙⣦⣖⠾⠋') + expect(wideFrame).not.toContain('▐██▄███████████▌') + expect(wideFrame).not.toContain('\u001b[38;2;128;47;222m') + expect(wideFrame).toContain('\u001b[1mSim Chat\u001b[0m') + expect(wideFrame).toContain('╭') + expect(wideFrame).toContain('╰') + expect(wideFrame).toContain('chat: New chat') + expect(wideFrame).not.toContain('workspace') + expect(wideFrame).not.toContain('owned') + const welcomeRows = paintedPayloads(wideFrame).map(plainTerminalText) + expect(welcomeRows.findIndex((row) => row.includes('profile:'))).toBe( + welcomeRows.findIndex((row) => row.includes('Sim Chat')) + 1 + ) + + terminal.setChatTitle('Release investigation') + expect(chunks.at(-1) ?? '').toContain('chat: Release investigation') + + output.columns = 30 + output.emit('resize') + const narrowFrame = chunks.at(-1) ?? '' + expect(narrowFrame).toContain(' ⠈⠉⠉⣝⣀⣀⣚⣀⡔⠒⠛⠓⠊⠉⠉') + expect(narrowFrame).not.toContain('▐██▄███████████▌') + expect(narrowFrame).toContain('Sim Chat') + expect(narrowFrame).toContain('chat Release investigation') + expect(narrowFrame).not.toContain('ws_local') + expect(narrowFrame).not.toContain('╭') + terminal.close() + }) + + it('pins a balanced padded composer to the bottom of an alternate-screen viewport', async () => { + const columns = 80 + const rows = 14 + const { input, output, chunks } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('hello') + await screen.flush() + + const buffer = screen.terminal.buffer.active + const lines = visibleTerminalLines(screen.terminal, rows) + expect(lines[rows - 3]).toBe(' ❯ hello') + expect(lines[rows - 1]).toBe('') + for (const row of [rows - 4, rows - 3, rows - 2]) { + expectUserPanelRow(screen.terminal, row, columns) + } + expect( + buffer + .getLine(rows - 3) + ?.getCell(0) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(rows - 3) + ?.getCell(1) + ?.getChars() + ).toBe('❯') + expect( + buffer + .getLine(rows - 3) + ?.getCell(1) + ?.getFgColor() + ).toBe(0xa0a0a0) + expect( + buffer + .getLine(rows - 3) + ?.getCell(3) + ?.getFgColor() + ).toBe(0xf2f2f2) + expect( + buffer + .getLine(rows - 3) + ?.getCell(columns - 3) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(rows - 1) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + expect(buffer.cursorY).toBe(rows - 3) + expect(buffer.cursorX).toBe(8) + + input.write('\r') + + await expect(result).resolves.toEqual({ kind: 'line', value: 'hello' }) + const rendered = chunks.join('') + expect(rendered).toContain('\u001b[?1049h') + + terminal.close() + await screen.flush() + expect(chunks.join('')).toContain('\u001b[?1049l') + expect(input.setRawMode).toHaveBeenNthCalledWith(1, true) + expect(input.setRawMode).toHaveBeenLastCalledWith(false) + expect(input.isPaused()).toBe(true) + screen.terminal.dispose() + }) + + it('submits an exact slash command with one Enter', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('/exit\r') + + await expect(result).resolves.toEqual({ kind: 'line', value: '/exit' }) + terminal.close() + }) + + it('keeps the composer background continuous across a highlighted mention', async () => { + const columns = 50 + const rows = 12 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release', + displayText: 'Release', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release', + }, + }, + ], + slash: [], + }) + void terminal.read('❯ ') + + input.write('@rel\tthen') + await screen.flush() + + const composerRow = visibleTerminalLines(screen.terminal, rows).findIndex((line) => + line?.startsWith(' ❯ @Release then') + ) + expect(composerRow).toBeGreaterThanOrEqual(0) + expectUserPanelRow(screen.terminal, composerRow, columns) + expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(3)?.isFgRGB()).toBe(true) + expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(12)?.getFgColor()).toBe( + 0xf2f2f2 + ) + expect(screen.terminal.buffer.active.getLine(composerRow)?.getCell(12)?.isFgRGB()).toBe(true) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('submits the exact resource identity selected from @ with literal client text', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release notes', + displayText: 'Release notes', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + }, + }, + ], + slash: [], + }) + const result = terminal.read('❯ ') + + input.write('@rel\t\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Release notes ', + contexts: [ + { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + }, + ], + }) + terminal.close() + }) + + it('sanitizes server-provided suggestion text before rendering or submitting it', async () => { + const { input, output, chunks } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const injected = '\u001b]2;suggestion-owned\u0007' + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: `Release${injected}\nnotes`, + displayText: `Release${injected}\nnotes`, + description: `workflow${injected}`, + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: `Release${injected}\nnotes`, + }, + }, + ], + slash: [], + }) + const result = terminal.read('❯ ') + + input.write('@rel\t\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Release notes ', + contexts: [ + { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release notes', + }, + ], + }) + expect(chunks.join('')).not.toContain('suggestion-owned') + terminal.close() + }) + + it('clips long suggestion labels before the description column', () => { + const { input, output } = terminalStreams(50, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'logs:execution-1', + value: 'x'.repeat(80), + displayText: 'x'.repeat(80), + description: 'log', + tag: 'logs', + context: { + kind: 'logs', + executionId: 'execution-1', + label: 'x'.repeat(80), + }, + }, + ], + slash: [], + }) + void terminal.read('❯ ') + input.write('@') + + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + const row = probe + .buildPanel(14) + .lines.find((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '').includes('log')) + expect(row?.replace(/\u001b\[[0-9;:]*m/gu, '')).toMatch(/… {2}log/u) + terminal.close() + }) + + it('shows recent logs at the top level after the other @ resources', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release workflow', + displayText: 'Release workflow', + tag: 'workflow', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release workflow', + }, + }, + { + id: 'logs:execution-1', + value: 'Incident run', + displayText: 'Incident run', + description: 'log', + tag: 'logs', + context: { + kind: 'logs', + executionId: 'execution-1', + label: 'Incident run', + }, + }, + ], + slash: [], + }) + const result = terminal.read('❯ ') + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + + input.write('@') + const bare = probe.buildPanel(14).lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const workflowRow = bare.findIndex((line) => line.includes('Release workflow')) + const logRow = bare.findIndex((line) => line.includes('Incident run')) + expect(workflowRow).toBeGreaterThanOrEqual(0) + expect(logRow).toBeGreaterThan(workflowRow) + expect(bare.some((line) => line.includes('logs/'))).toBe(false) + + key(input, '', { name: 'down', sequence: '\u001b[B' }) + input.write('\t\r') + await expect(result).resolves.toMatchObject({ + kind: 'line', + value: '@Incident run ', + contexts: [ + { + kind: 'logs', + executionId: 'execution-1', + label: 'Incident run', + }, + ], + }) + terminal.close() + }) + + it('reopens @ suggestions after the trigger is removed and retyped', () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:workflow-1', + value: 'Release workflow', + displayText: 'Release workflow', + context: { + kind: 'workflow', + workflowId: 'workflow-1', + label: 'Release workflow', + }, + }, + ], + slash: [], + }) + void terminal.read('❯ ') + const probe = terminal as never as { buildPanel(rows: number): { lines: string[] } } + const hasReleaseSuggestion = () => + probe + .buildPanel(14) + .lines.map(plainTerminalText) + .some((line) => line.includes('Release workflow')) + + input.write('@') + expect(hasReleaseSuggestion()).toBe(true) + + key(input, '', { name: 'escape', sequence: '\u001b' }) + expect(hasReleaseSuggestion()).toBe(false) + key(input, '\u007f', { name: 'backspace', sequence: '\u007f' }) + input.write('@') + + expect(hasReleaseSuggestion()).toBe(true) + terminal.close() + }) + + it('renders suggestions above the status and bottom-pinned composer', async () => { + const columns = 80 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + const probe = terminal as never as { + buildPanel(rows: number): { lines: string[]; cursor?: { row: number } } + } + const closedPanel = probe.buildPanel(rows) + const closedCursorRow = rows - closedPanel.lines.length + (closedPanel.cursor?.row ?? 0) + + input.write('/') + const panel = probe.buildPanel(rows) + const lines = panel.lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const suggestion = lines.findIndex((line) => line.includes('/help')) + const thinking = lines.findIndex((line) => line.includes('Thinking…')) + const composer = lines.findIndex((line) => line.startsWith(' ❯')) + const openCursorRow = rows - panel.lines.length + (panel.cursor?.row ?? 0) + + expect(suggestion).toBeGreaterThanOrEqual(0) + expect(thinking).toBeGreaterThan(suggestion) + expect(thinking).toBe(composer - 2) + expect(lines[composer - 1]).toBe(' ') + expect(lines[composer + 1]).toBe(' ') + expect(composer + 1).toBe(lines.length - 2) + expect(panel.cursor?.row).toBe(composer) + expect(openCursorRow).toBe(closedCursorRow) + expect(lines.at(-1)).toContain('enter to steer · esc to interrupt') + + await screen.flush() + const renderedLines = visibleTerminalLines(screen.terminal, rows) + const renderedThinking = renderedLines.findIndex((line) => line?.includes('Thinking…')) + const renderedComposer = renderedLines.findIndex((line) => line?.startsWith(' ❯ /')) + expect(renderedThinking).toBeGreaterThanOrEqual(0) + expect(renderedComposer).toBe(renderedThinking + 2) + expectUserPanelRow(screen.terminal, renderedComposer - 1, columns) + expectUserPanelRow(screen.terminal, renderedComposer, columns) + expectUserPanelRow(screen.terminal, renderedComposer + 1, columns) + expect(screen.terminal.buffer.active.cursorY).toBe(renderedComposer) + + activity.stop() + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('keeps autocomplete inactive when the terminal is too short to show an option', async () => { + const { input, output } = terminalStreams(80, 4) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('/r\r') + + await expect(result).resolves.toEqual({ kind: 'line', value: '/r' }) + terminal.close() + }) + + it('filters a single-choice menu above a fixed bottom search composer', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const selected = terminal.select({ + prompt: 'Choose a chat', + options: [ + { id: 'new', label: 'New chat', description: 'start blank' }, + { id: 'release', label: 'Release investigation', description: 'pinned' }, + { id: 'deploy', label: 'Deployment failure', description: 'updated yesterday' }, + ], + }) + const probe = terminal as never as { + buildPanel(rows: number): { lines: string[]; cursor?: { row: number } } + } + const initial = probe.buildPanel(14) + const initialCursorRow = 14 - initial.lines.length + (initial.cursor?.row ?? 0) + + input.write('deploy') + const filtered = probe.buildPanel(14) + const lines = filtered.lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const filteredCursorRow = 14 - filtered.lines.length + (filtered.cursor?.row ?? 0) + + expect(lines.some((line) => line.includes('Deployment failure'))).toBe(true) + expect(lines.some((line) => line.includes('Release investigation'))).toBe(false) + const searchRow = lines.indexOf(' Search › deploy') + expect(lines.findIndex((line) => line.includes('Deployment failure'))).toBeLessThan(searchRow) + expect(lines[searchRow - 1]).toBe(' ') + expect(lines[searchRow + 1]).toBe(' ') + expect(lines.some((line) => line.startsWith('─'))).toBe(false) + expect(filteredCursorRow).toBe(initialCursorRow) + + input.write('\r') + await expect(selected).resolves.toEqual({ kind: 'selected', id: 'deploy' }) + terminal.close() + }) + + it('keeps chat options beyond the first hundred searchable', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const selected = terminal.select({ + prompt: 'Choose a chat', + options: Array.from({ length: 150 }, (_, index) => ({ + id: `chat-${index + 1}`, + label: index === 149 ? 'Needle investigation' : `Chat ${index + 1}`, + })), + }) + + input.write('needle\r') + + await expect(selected).resolves.toEqual({ kind: 'selected', id: 'chat-150' }) + terminal.close() + }) + + it('clears prior transcript content without rebuilding the terminal viewport', () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.userMessage('Old question') + terminal.write('Old answer\n') + + terminal.clearTranscript() + + expect((terminal as never as { transcript: string }).transcript).toBe('') + terminal.userMessage('New question') + expect((terminal as never as { transcript: string }).transcript).toContain('New question') + expect((terminal as never as { transcript: string }).transcript).not.toContain('Old question') + terminal.close() + }) + + it('opens / after whitespace and carries a selected skill identity', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [], + slash: [ + { + id: 'skill:skill-1', + value: 'review', + displayText: '/review', + tag: 'skill', + context: { kind: 'skill', skillId: 'skill-1', label: 'review' }, + }, + ], + }) + const result = terminal.read('❯ ') + + input.write('please /rev\tthis\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: 'please /review this', + contexts: [{ kind: 'skill', skillId: 'skill-1', label: 'review' }], + }) + terminal.close() + }) + + it('resets autocomplete selection to the first match when the token query changes', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: ['Apple', 'Apricot', 'Banana'].map((label, index) => ({ + id: `workflow:${index}`, + value: label, + displayText: label, + context: { + kind: 'workflow' as const, + workflowId: `workflow-${index}`, + label, + }, + })), + slash: [], + }) + const result = terminal.read('❯ ') + + input.write('@') + key(input, '', { name: 'down', sequence: '\u001b[B' }) + input.write('a\t\r') + + await expect(result).resolves.toMatchObject({ + kind: 'line', + value: '@Apple ', + contexts: [ + { + kind: 'workflow', + workflowId: 'workflow-0', + label: 'Apple', + }, + ], + }) + terminal.close() + }) + + it('preserves the highlighted autocomplete item when async candidates arrive', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const tables = ['Customers', 'Orders'].map((label, index) => ({ + id: `table:${index}`, + value: label, + displayText: label, + context: { + kind: 'table' as const, + tableId: `table-${index}`, + label, + }, + })) + terminal.setSuggestionCandidates({ resources: tables, slash: [] }) + const result = terminal.read('❯ ') + + input.write('@') + key(input, '', { name: 'down', sequence: '\u001b[B' }) + terminal.setSuggestionCandidates({ + resources: [ + { + id: 'workflow:0', + value: 'Billing', + displayText: 'Billing', + context: { + kind: 'workflow', + workflowId: 'workflow-0', + label: 'Billing', + }, + }, + ...tables, + ], + slash: [], + }) + input.write('\t\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Orders ', + contexts: [ + { + kind: 'table', + tableId: 'table-1', + label: 'Orders', + }, + ], + }) + terminal.close() + }) + + it('auto-resolves a manually typed slash tag with skill precedence', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + terminal.setSuggestionCandidates({ + resources: [], + slash: [ + { + id: 'skill:skill-1', + value: 'review', + displayText: '/review', + tag: 'skill', + context: { kind: 'skill', skillId: 'skill-1', label: 'review' }, + }, + { + id: 'mcp:mcp-1', + value: 'review', + displayText: '/review', + tag: 'mcp', + context: { kind: 'mcp', serverId: 'mcp-1', label: 'review' }, + }, + ], + }) + const result = terminal.read('❯ ') + + input.write('/REVIEW this\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '/REVIEW this', + contexts: [{ kind: 'skill', skillId: 'skill-1', label: 'review' }], + }) + terminal.close() + }) + + it('preserves selected context identity through a queued priority preload', async () => { + const { input, output } = terminalStreams(80, 14) + const terminal = new ReadlineChatTerminal(input, output) + const contexts = [{ kind: 'workflow' as const, workflowId: 'workflow-1', label: 'Release' }] + + expect(terminal.preload('@Release', { queued: true, contexts })).toBe(true) + const result = terminal.read('❯ ') + input.write('\r') + + await expect(result).resolves.toEqual({ + kind: 'line', + value: '@Release', + queued: true, + display: '@Release', + contexts, + }) + terminal.close() + }) + + it('leaves a caller-owned flowing input flowing after close', () => { + const { input, output } = terminalStreams(80, 14) + input.resume() + expect(input.readableFlowing).toBe(true) + + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.close() + + expect(input.isPaused()).toBe(false) + }) + + it('does not change caller-owned raw mode when closed before opening the viewport', () => { + const { input, output } = terminalStreams(80, 14) + input.isRaw = true + + const terminal = new ReadlineChatTerminal(input, output) + terminal.close() + + expect(input.setRawMode).not.toHaveBeenCalled() + }) + + it('commits sent prompts with the same balanced panel as the composer', async () => { + const columns = 80 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('first line\\\rsecond line\r') + await expect(result).resolves.toEqual({ kind: 'line', value: 'first line\nsecond line' }) + await screen.flush() + + const buffer = screen.terminal.buffer.active + const lines = visibleTerminalLines(screen.terminal, rows) + const firstRow = lines.indexOf(' ❯ first line') + const secondRow = lines.indexOf(' second line') + expect(firstRow).toBeGreaterThan(0) + expect(secondRow).toBe(firstRow + 1) + for (const row of [firstRow - 1, firstRow, secondRow, secondRow + 1]) { + expectUserPanelRow(screen.terminal, row, columns) + } + expect(buffer.getLine(firstRow)?.getCell(0)?.getChars()).toBe(' ') + expect(buffer.getLine(firstRow)?.getCell(1)?.getChars()).toBe('❯') + expect(buffer.getLine(firstRow)?.getCell(1)?.getFgColor()).toBe(0xa0a0a0) + expect( + buffer + .getLine(firstRow) + ?.getCell(columns - 3) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(secondRow + 2) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('wraps committed user-card words within the shaded content width', async () => { + const columns = 21 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.read('❯ ') + + input.write('abcdef 1234567890\r') + await expect(result).resolves.toEqual({ kind: 'line', value: 'abcdef 1234567890' }) + await screen.flush() + + const lines = visibleTerminalLines(screen.terminal, rows) + const firstRow = lines.indexOf(' ❯ abcdef') + expect(firstRow).toBeGreaterThan(0) + expect(lines[firstRow + 1]).toBe(' 1234567890') + expectUserPanelRow(screen.terminal, firstRow, columns) + expectUserPanelRow(screen.terminal, firstRow + 1, columns) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('renders padded user-turn cells while keeping the composer in the physical bottom rows', async () => { + const columns = 67 + const rows = 12 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + terminal.welcome({ chatTitle: 'New chat' }) + const submitted = terminal.read('❯ ') + + input.write('whats in my workspace\r') + await expect(submitted).resolves.toEqual({ + kind: 'line', + value: 'whats in my workspace', + }) + const activity = terminal.activity('Thinking…') + activity.clear() + terminal.write('Here is your workspace.') + activity.complete() + void terminal.read('❯ ') + await screen.flush() + + const buffer = screen.terminal.buffer.active + const lines = visibleTerminalLines(screen.terminal, rows) + expect(lines).toContain(' ❯ whats in my workspace') + expect(lines).toContain('● Here is your workspace.') + expect(lines).toContain('✻ Worked for 1s') + expect(lines[rows - 3]).toBe(' ❯') + expect(lines[rows - 2]).toBe('') + expect(lines[11]).toBe(' ? for shortcuts') + expectUserPanelRow(screen.terminal, rows - 4, columns) + expectUserPanelRow(screen.terminal, rows - 3, columns) + expectUserPanelRow(screen.terminal, rows - 2, columns) + + const userRowIndex = lines.indexOf(' ❯ whats in my workspace') + const assistantRowIndex = lines.indexOf('● Here is your workspace.') + expectUserPanelRow(screen.terminal, userRowIndex - 1, columns) + expectUserPanelRow(screen.terminal, userRowIndex, columns) + expectUserPanelRow(screen.terminal, userRowIndex + 1, columns) + expect( + buffer + .getLine(userRowIndex + 2) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + expect(buffer.getLine(assistantRowIndex)?.getCell(0)?.getChars()).toBe('●') + expect(buffer.getLine(assistantRowIndex)?.getCell(0)?.isBgDefault()).toBe(true) + expect(buffer.cursorY).toBe(rows - 3) + expect(buffer.baseY).toBe(0) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('expands user and composer panels across wide terminal viewports', async () => { + const columns = 240 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const submitted = terminal.read('❯ ') + + input.write('wide terminal\r') + await expect(submitted).resolves.toEqual({ kind: 'line', value: 'wide terminal' }) + void terminal.read('❯ ') + await screen.flush() + + const buffer = screen.terminal.buffer.active + expectUserPanelRow(screen.terminal, 0, columns) + expectUserPanelRow(screen.terminal, 1, columns) + expectUserPanelRow(screen.terminal, 2, columns) + expectUserPanelRow(screen.terminal, rows - 4, columns) + expectUserPanelRow(screen.terminal, rows - 3, columns) + expectUserPanelRow(screen.terminal, rows - 2, columns) + expect( + buffer + .getLine(0) + ?.getCell(columns - 3) + ?.getChars() + ).toBe(' ') + expect( + buffer + .getLine(rows - 1) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('buffers and removes leading whitespace so assistant text shares the prefix row', async () => { + const { input, output, chunks } = terminalStreams(30, 10) + const screen = mirrorToHeadless(output, 30, 10) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.userMessage('question') + const chunksBeforeWhitespace = chunks.length + + terminal.write('\u001b[1m') + terminal.write('\n ') + expect(chunks).toHaveLength(chunksBeforeWhitespace) + + terminal.write('answer\u001b[0m') + await screen.flush() + const answerFrame = chunks.at(-1) ?? '' + expect(answerFrame).toContain('● \u001b[1manswer\u001b[0m') + expect(answerFrame).not.toContain('● \u001b[0m') + const visibleLines = Array.from({ length: 10 }, (_, row) => + screen.terminal.buffer.active.getLine(row)?.translateToString(true).trimEnd() + ).filter(Boolean) + expect(visibleLines).toContain('● answer') + expect(visibleLines).not.toContain('●') + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('keeps explicit and soft-wrapped assistant rows in a hanging gutter', async () => { + const columns = 16 + const rows = 14 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.userMessage('question') + const activity = terminal.activity('Thinking…') + activity.clear() + + terminal.write('alpha beta gamma delta\n') + terminal.write('\u001b[1mHeading\u001b[0m\n') + terminal.write('\u001b[2m•\u001b[0m nested item') + + await screen.flush() + const visibleLines = Array.from({ length: rows }, (_, row) => + screen.terminal.buffer.active.getLine(row)?.translateToString(true).trimEnd() + ) + expect(visibleLines).toContain('● alpha beta') + expect(visibleLines).toContain(' gamma delta') + expect(visibleLines).toContain(' Heading') + expect(visibleLines).toContain(' • nested item') + expect(visibleLines).not.toContain('Heading') + expect(visibleLines).not.toContain('• nested item') + + activity.stop() + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('starts an assistant turn for attachment-only requests without a text prompt', () => { + const { input, output, chunks } = terminalStreams(30, 10) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + activity.clear() + terminal.write('I inspected the attachment.') + + expect(chunks.at(-1)).toContain('● I inspected the attachment.') + activity.stop() + terminal.close() + }) + + it('coordinates streaming transcript writes without moving the busy composer from the bottom', async () => { + const columns = 50 + const rows = 12 + const { input, output, chunks } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + const submitted = terminal.read('❯ ') + input.write('question\r') + await submitted + const activity = terminal.activity('Thinking…') + activity.clear() + + terminal.write('Hello ') + terminal.write('\u001b[1mworld\u001b[0m') + await screen.flush() + + const latestFrame = chunks.at(-1) ?? '' + const rendered = chunks.join('') + expect(latestFrame).toContain('Hello \u001b[1mworld\u001b[0m') + expect(rendered).toContain('esc to interrupt') + expect(latestFrame).not.toContain('\u001b[2J') + expect(latestFrame).not.toContain('\n') + expectUserPanelRow(screen.terminal, rows - 4, columns) + expectUserPanelRow(screen.terminal, rows - 3, columns) + expectUserPanelRow(screen.terminal, rows - 2, columns) + expect(visibleTerminalLines(screen.terminal, rows)[rows - 3]).toBe(' ❯') + expect(screen.terminal.buffer.active.cursorY).toBe(rows - 3) + + activity.stop() + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('keeps the busy composer editable and drains steering prompts in FIFO order', async () => { + const { input, output, chunks } = terminalStreams(60, 14) + const terminal = new ReadlineChatTerminal(input, output) + const initial = terminal.read('❯ ') + input.write('original request\r') + await initial + + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + + input.write('first steer') + await new Promise((resolve) => setImmediate(resolve)) + expect(plainTerminalText(chunks.at(-1) ?? '')).toContain(' ❯ first steer') + expect(chunks.at(-1)).toContain('\u001b[?25h') + + input.write('\rsecond steer\r') + await new Promise((resolve) => setImmediate(resolve)) + expect(interruptions).toEqual(['submit', 'submit']) + expect(chunks.at(-1)).toContain('2 queued · enter to steer · esc to interrupt') + + activity.stop() + await expect(terminal.read('❯ ')).resolves.toEqual({ + kind: 'line', + value: 'first steer', + queued: true, + display: 'first steer', + }) + await expect(terminal.read('❯ ')).resolves.toEqual({ + kind: 'line', + value: 'second steer', + queued: true, + display: 'second steer', + }) + terminal.close() + }) + + it('treats blank busy Enter as a no-op', async () => { + const { input, output, chunks } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + + key(input, '\r', { name: 'return', sequence: '\r' }) + + expect(interruptions).toEqual([]) + expect((terminal as never as { queued: unknown[] }).queued).toHaveLength(0) + expect(chunks.at(-1)).not.toContain('queued') + activity.stop() + terminal.close() + }) + + it('reports busy submissions without duplicating chat command or path semantics', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + + input.write('/help \r/private/tmp/report.txt\r') + await new Promise((resolve) => setImmediate(resolve)) + + expect(interruptions).toEqual(['submit', 'submit']) + activity.stop() + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: '/help ', queued: true }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: '/private/tmp/report.txt', + queued: true, + }) + terminal.close() + }) + + it('prioritizes an explicit preload without losing queued turns or the live draft', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('/private/tmp/report.txt\rinspect it\runfinished') + await new Promise((resolve) => setImmediate(resolve)) + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: '/private/tmp/report.txt', + queued: true, + }) + expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) + const confirmation = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(confirmation).resolves.toEqual({ + kind: 'line', + value: '/attach "/private/tmp/report.txt"', + }) + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: 'inspect it', + queued: true, + }) + const restoredDraft = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(restoredDraft).resolves.toEqual({ kind: 'line', value: 'unfinished' }) + terminal.close() + }) + + it('consumes a preload submitted while clipboard work is between reads', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('live draft') + activity.stop() + + expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) + const clipboard = terminal.read('❯ ') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + await expect(clipboard).resolves.toEqual({ + kind: 'clipboard', + value: '/attach "/private/tmp/report.txt"', + }) + + // Clipboard inspection is asynchronous in chat.ts. Enter can arrive before + // it asks the terminal for another input, and must consume this preload. + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + kind: 'line', + value: '/attach "/private/tmp/report.txt"', + queued: true, + }) + + const restoredDraft = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(restoredDraft).resolves.toEqual({ kind: 'line', value: 'live draft' }) + terminal.close() + }) + + it('preserves a large pasted draft while a priority preload is submitted', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const pasted = 'p'.repeat(900) + const activity = terminal.activity('Thinking…') + input.write('before ') + key(input, '', { name: 'paste-start', sequence: '\u001b[200~' }) + key(input, pasted, { sequence: pasted }) + key(input, '', { name: 'paste-end', sequence: '\u001b[201~' }) + activity.stop() + + expect(terminal.preload('/attach "/private/tmp/report.txt"')).toBe(true) + const confirmation = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(confirmation).resolves.toMatchObject({ + kind: 'line', + value: '/attach "/private/tmp/report.txt"', + }) + + const restoredDraft = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(restoredDraft).resolves.toMatchObject({ + kind: 'line', + value: `before ${pasted}`, + }) + terminal.close() + }) + + it('retains queued paste bodies across later input and a priority retry', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const pasted = 'q'.repeat(900) + const activity = terminal.activity('Thinking…') + key(input, '', { name: 'paste-start', sequence: '\u001b[200~' }) + key(input, pasted, { sequence: pasted }) + key(input, '', { name: 'paste-end', sequence: '\u001b[201~' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + activity.stop() + + const queued = await terminal.read('❯ ') + expect(queued).toMatchObject({ kind: 'line', value: pasted, queued: true }) + if (queued.kind !== 'line' || !queued.display) throw new Error('Expected queued pasted line') + + const laterActivity = terminal.activity('Thinking…') + input.write('later\r') + laterActivity.stop() + expect(terminal.preload(queued.display, { queued: true, pastes: queued.pastes })).toBe(true) + + const retry = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(retry).resolves.toMatchObject({ kind: 'line', value: pasted, queued: true }) + terminal.close() + }) + + it('keeps a deferred retry ahead of queued turns without duplicating its transcript row', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('retry me\r') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) + const laterActivity = terminal.activity('Thinking…') + input.write('later\r') + laterActivity.stop() + expect(terminal.preload('retry me', { queued: true })).toBe(true) + + const clipboard = terminal.read('❯ ') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + await expect(clipboard).resolves.toMatchObject({ kind: 'clipboard' }) + + // Enter can land before clipboard inspection asks for the next input. The + // retry remains the priority item even though another turn is queued. + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: 'retry me', + queued: true, + }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'later', queued: true }) + + const transcript = (terminal as never as { transcript: string }).transcript + expect(transcript.match(/retry me/gu)).toHaveLength(1) + terminal.close() + }) + + it('retries a normally submitted prompt without duplicating its transcript row', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const firstAttempt = terminal.read('❯ ') + input.write('retry me\r') + await expect(firstAttempt).resolves.toEqual({ kind: 'line', value: 'retry me' }) + + const activity = terminal.activity('Thinking…') + activity.stop() + expect(terminal.preload('retry me', { queued: true })).toBe(true) + const retry = terminal.read('❯ ') + input.write('\r') + + await expect(retry).resolves.toMatchObject({ + kind: 'line', + value: 'retry me', + queued: true, + }) + const transcript = (terminal as never as { transcript: string }).transcript + expect(transcript.match(/retry me/gu)).toHaveLength(1) + terminal.close() + }) + + it('keeps an unchanged committed retry deduplicated after queue recall', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('retry me\r') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) + expect(terminal.preload('retry me', { queued: true })).toBe(true) + key(input, '\r', { name: 'return', sequence: '\r' }) + key(input, '', { name: 'up', sequence: '\u001b[A' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'retry me', queued: true }) + const transcript = (terminal as never as { transcript: string }).transcript + expect(transcript.match(/retry me/gu)).toHaveLength(1) + terminal.close() + }) + + it('keeps clipboard draft edits terminal-owned while a turn is active', async () => { + const { input, output, chunks } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('draft') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + for (let index = 0; index < 5; index += 1) { + key(input, '\u007f', { name: 'backspace', sequence: '\u007f' }) + } + expect(chunks.at(-1)).not.toContain('queued') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'clipboard', value: 'draft' }) + const empty = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(empty).resolves.toEqual({ kind: 'line', value: '' }) + terminal.close() + }) + + it('dismisses busy suggestions before Escape interrupts generation', () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const interruptions: string[] = [] + terminal.onInterrupt((reason) => interruptions.push(reason)) + const activity = terminal.activity('Thinking…') + input.write('/he') + + key(input, '', { name: 'escape', sequence: '\u001b' }) + expect(interruptions).toEqual([]) + expect((terminal as never as { draft: string }).draft).toBe('/he') + + key(input, '', { name: 'escape', sequence: '\u001b' }) + expect(interruptions).toEqual(['manual']) + activity.stop() + terminal.close() + }) + + it('recalls the newest queued steering prompt with Up', async () => { + const { input, output, chunks } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('first\rsecond\r') + await new Promise((resolve) => setImmediate(resolve)) + key(input, '', { name: 'up', sequence: '\u001b[A' }) + + expect(plainTerminalText(chunks.at(-1) ?? '')).toContain(' ❯ second') + expect(chunks.at(-1)).toContain('1 queued · enter to steer · esc to interrupt') + activity.stop() + terminal.close() + }) + + it('reinserts a recalled prompt ahead of controls that arrived after it', async () => { + const { input, output } = terminalStreams(60, 12) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + input.write('first\rsecond\r') + await new Promise((resolve) => setImmediate(resolve)) + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + key(input, '', { name: 'up', sequence: '\u001b[A' }) + input.write(' edited\r') + activity.stop() + + await expect(terminal.read('❯ ')).resolves.toMatchObject({ value: 'first', queued: true }) + await expect(terminal.read('❯ ')).resolves.toMatchObject({ + value: 'second edited', + queued: true, + }) + await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'clipboard', value: '' }) + terminal.close() + }) + + it('preserves a mid-stream draft across a structured question', async () => { + const { input, output } = terminalStreams(60, 14) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('unfinished follow-up') + activity.stop() + + const answer = terminal.askQuestion({ + prompt: 'Which service?', + multi: false, + options: [{ id: 'api', label: 'API' }], + }) + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(answer).resolves.toEqual({ kind: 'answer', values: ['API'] }) + + const followUp = terminal.read('❯ ') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(followUp).resolves.toEqual({ kind: 'line', value: 'unfinished follow-up' }) + terminal.close() + }) + + it('paints only visible transcript rows without terminal scrolling during repeated redraws', () => { + const { input, output, chunks } = terminalStreams(16, 10) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.write(`${Array.from({ length: 100 }, (_, index) => `line-${index}`).join('\n')}\n`) + const transcriptFrame = chunks.at(-1) ?? '' + const transcriptPaints = [...transcriptFrame.matchAll(/\u001b\[(\d+);1H\u001b\[2K/gu)] + expect(transcriptPaints.length).toBeLessThanOrEqual(output.rows) + expect(transcriptFrame).not.toContain('line-0') + expect(transcriptFrame).toContain('line-99') + expect(transcriptFrame).not.toContain('\n') + expect(transcriptFrame).not.toContain('\r') + expect(transcriptFrame).not.toMatch(/\u001b\[\d+;\d+r/u) + + for (let redraw = 0; redraw < 10; redraw += 1) output.emit('resize') + for (const frame of chunks.slice(-10)) { + const paintedRows = [...frame.matchAll(/\u001b\[(\d+);1H\u001b\[2K/gu)] + expect(paintedRows).toHaveLength(0) + expect(frame).not.toMatch(/\u001b\[\d+;\d+r/u) + expect(frame).not.toContain('\n') + expect(frame).not.toContain('\r') + expect(frame).not.toContain('line-99') + expect(frame).not.toContain('\u001b[2J') + } + + terminal.close() + }) + + it('owns transcript scrollback while keeping the composer fixed and sticky', async () => { + const { input, output, chunks } = terminalStreams(32, 10) + const terminal = new ReadlineChatTerminal(input, output) + const submitted = terminal.read('❯ ') + + terminal.write(`${Array.from({ length: 20 }, (_, index) => `line-${index}`).join('\n')}\n`) + expect(chunks.at(-1)).toContain('line-19') + + key(input, '', { name: 'pageup', sequence: '\u001b[5~' }) + const historyFrame = chunks.at(-1) ?? '' + expect(historyFrame).toContain('line-11') + expect(historyFrame).not.toContain('line-19') + expect(historyFrame).toContain('\u001b[8;4H\u001b[?25h') + + terminal.write('line-20\nline-21\n') + const anchoredFrame = chunks.at(-1) ?? '' + expect(anchoredFrame).not.toContain('line-20') + expect(anchoredFrame).not.toContain('line-21') + expect(anchoredFrame).not.toMatch(/\u001b\[\d+;1H\u001b\[2K/u) + + key(input, '', { ctrl: true, name: 'home', sequence: '\u001b[1;5H' }) + expect(chunks.at(-1)).toContain('line-0') + key(input, '', { ctrl: true, name: 'end', sequence: '\u001b[1;5F' }) + expect(chunks.at(-1)).toContain('line-21') + + key(input, '', { name: 'pageup', sequence: '\u001b[5~' }) + output.rows = 12 + output.emit('resize') + const resizedFrame = chunks.at(-1) ?? '' + expect(resizedFrame).not.toContain('line-21') + expect(resizedFrame).not.toContain('\n') + expect(resizedFrame).not.toContain('\r') + + input.write('new question\r') + await expect(submitted).resolves.toEqual({ kind: 'line', value: 'new question' }) + const submittedFrame = chunks.at(-1) ?? '' + expect(submittedFrame).toContain('new question') + expect(submittedFrame).toContain('\u001b[10;1H\u001b[2K') + expect(plainTerminalText(submittedFrame)).toContain(' ❯ ') + expect(submittedFrame).not.toContain('\n') + expect(submittedFrame).not.toContain('\r') + terminal.close() + }) + + it('wraps ANSI-styled wide graphemes into bounded absolute rows', () => { + const { input, output, chunks } = terminalStreams(10, 8) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.write('\u001b[31m12345678界Z\u001b[0m') + + const latestFrame = chunks.at(-1) ?? '' + expect(latestFrame).toContain('\u001b[31m12345678\u001b[0m') + expect(latestFrame).toContain('\u001b[31m界Z\u001b[0m') + expect(latestFrame).not.toContain('\n') + expect(latestFrame).not.toMatch(/\u001b\[\d+;\d+r/u) + terminal.close() + }) + + it('reflows streamed prose at word boundaries instead of splitting ordinary words', () => { + const { input, output, chunks } = terminalStreams(21, 8) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.write('happy to build somet') + expect(chunks.at(-1)).toContain('happy to build somet') + + terminal.write('hing') + const reflowedFrame = chunks.at(-1) ?? '' + expect(reflowedFrame).toContain('\u001b[1;1H\u001b[2Khappy to build \u001b[0m') + expect(reflowedFrame).toContain('\u001b[2;1H\u001b[2Ksomething\u001b[0m') + expect(reflowedFrame).not.toContain('somet\u001b[0m') + expect(reflowedFrame).not.toContain('\u001b[2;1H\u001b[2Khing') + terminal.close() + }) + + it('reopens the user panel on word-wrapped rows without leaking into assistant output', async () => { + const columns = 21 + const rows = 8 + const { input, output } = terminalStreams(columns, rows) + const screen = mirrorToHeadless(output, columns, rows) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + + terminal.userMessage('happy to build something') + await screen.flush() + + const userLines = visibleTerminalLines(screen.terminal, rows) + const firstRow = userLines.indexOf(' ❯ happy to build') + const continuationRow = userLines.indexOf(' something') + expect(firstRow).toBeGreaterThanOrEqual(0) + expect(continuationRow).toBe(firstRow + 1) + expectUserPanelRow(screen.terminal, firstRow, columns) + expectUserPanelRow(screen.terminal, continuationRow, columns) + + terminal.write('assistant') + await screen.flush() + const assistantRow = visibleTerminalLines(screen.terminal, rows).indexOf('● assistant') + expect(assistantRow).toBeGreaterThanOrEqual(0) + expect(screen.terminal.buffer.active.getLine(assistantRow)?.getCell(0)?.isBgDefault()).toBe( + true + ) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('supports multiline input, grapheme deletion, and history recall', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + + const multiline = terminal.read('❯ ') + input.write('hello\\\rworld\r') + await expect(multiline).resolves.toEqual({ kind: 'line', value: 'hello\nworld' }) + + const edited = terminal.read('❯ ') + input.write('A😀B') + key(input, '', { name: 'left', sequence: '\u001b[D' }) + key(input, '', { name: 'backspace', sequence: '\u007f' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(edited).resolves.toEqual({ kind: 'line', value: 'AB' }) + + const recalled = terminal.read('❯ ') + key(input, '', { name: 'up', sequence: '\u001b[A' }) + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(recalled).resolves.toEqual({ kind: 'line', value: 'AB' }) + terminal.close() + }) + + it('preserves the live draft cursor when a streamed turn settles', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const initial = terminal.read('❯ ') + input.write('original\r') + await initial + + const activity = terminal.activity('Thinking…') + input.write('abcdef') + key(input, '', { name: 'left', sequence: '\u001b[D' }) + key(input, '', { name: 'left', sequence: '\u001b[D' }) + activity.stop() + + const followUp = terminal.read('❯ ') + input.write('X\r') + await expect(followUp).resolves.toEqual({ kind: 'line', value: 'abcdXef' }) + terminal.close() + }) + + it('returns eof after close even when deferred input remains queued', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + input.write('stale\r') + await new Promise((resolve) => setImmediate(resolve)) + + terminal.close() + + await expect(terminal.read('❯ ')).resolves.toEqual({ kind: 'eof' }) + activity.stop() + }) + + it('redraws the balanced composer across narrow terminal resizes', async () => { + const { input, output } = terminalStreams(12, 10) + const screen = mirrorToHeadless(output, 12, 10) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + await screen.flush() + + expectUserPanelRow(screen.terminal, 6, 12) + expectUserPanelRow(screen.terminal, 7, 12) + expectUserPanelRow(screen.terminal, 8, 12) + expect(screen.terminal.buffer.active.cursorY).toBe(7) + + screen.terminal.resize(40, 16) + output.columns = 40 + output.rows = 16 + output.emit('resize') + await screen.flush() + + expectUserPanelRow(screen.terminal, 12, 40) + expectUserPanelRow(screen.terminal, 13, 40) + expectUserPanelRow(screen.terminal, 14, 40) + expect(visibleTerminalLines(screen.terminal, 16)[13]).toBe(' ❯') + expect(screen.terminal.buffer.active.cursorY).toBe(13) + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('never paints beyond the physical terminal during extreme row resizes', () => { + const { input, output, chunks } = terminalStreams(20, 14) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.write('one\ntwo\nthree\nfour') + + for (const rows of [2, 1, 20]) { + output.rows = rows + output.emit('resize') + const frame = chunks.at(-1) ?? '' + const cursorPositions = [...frame.matchAll(/\u001b\[(\d+);(\d+)H/gu)] + expect(cursorPositions.length).toBeGreaterThan(0) + for (const position of cursorPositions) { + expect(Number(position[1])).toBeLessThanOrEqual(rows) + expect(Number(position[2])).toBeLessThanOrEqual(output.columns) + } + expect(frame).not.toContain('\n') + expect(frame).not.toContain('\r') + } + + terminal.close() + }) + + it('respects the physical column count and leaves a no-wrap safety column', () => { + const { input, output, chunks } = terminalStreams(14, 8) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + terminal.write('alpha beta 界界 gamma') + + for (const columns of [2, 1, 20]) { + output.columns = columns + output.emit('resize') + const frame = chunks.at(-1) ?? '' + const cursorPositions = [...frame.matchAll(/\u001b\[(\d+);(\d+)H/gu)] + expect(cursorPositions.length).toBeGreaterThan(0) + for (const position of cursorPositions) { + expect(Number(position[1])).toBeLessThanOrEqual(output.rows) + expect(Number(position[2])).toBeLessThanOrEqual(columns) + } + for (const payload of paintedPayloads(frame)) { + expect(payloadDisplayWidth(payload)).toBeLessThanOrEqual(Math.max(0, columns - 1)) + } + expect(frame).not.toContain('\n') + expect(frame).not.toContain('\r') + } + + terminal.close() + }) + + it('keeps the meaningful composer and question row focused at one terminal row', async () => { + const { input, output, chunks } = terminalStreams(40, 1) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + + const busyFrame = chunks.at(-1) ?? '' + expect(busyFrame).toContain('❯ ') + expect(busyFrame).not.toContain('esc to interrupt') + expect(paintedPayloads(busyFrame)).toHaveLength(1) + activity.stop() + + const answer = terminal.askQuestion({ + prompt: 'Which service?', + multi: false, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + }) + const questionFrame = chunks.at(-1) ?? '' + expect(questionFrame).toContain('❯ 1. API') + expect(questionFrame).not.toContain('navigate') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(answer).resolves.toEqual({ kind: 'answer', values: ['API'] }) + terminal.close() + }) + + it('clips the balanced composer around its input on tiny terminal heights', async () => { + const columns = 40 + const { input, output } = terminalStreams(columns, 6) + const screen = mirrorToHeadless(output, columns, 6) + const terminal = new ReadlineChatTerminal(input, output) + void terminal.read('❯ ') + const expectedCursorRows = new Map([ + [5, 2], + [4, 1], + [3, 1], + [2, 0], + [1, 0], + ]) + + for (const rows of [5, 4, 3, 2, 1]) { + screen.terminal.resize(columns, rows) + output.rows = rows + output.emit('resize') + await screen.flush() + + const cursorRow = expectedCursorRows.get(rows) + if (cursorRow === undefined) throw new Error(`Missing cursor expectation for ${rows} rows`) + expect(screen.terminal.buffer.active.cursorY).toBe(cursorRow) + expect(screen.terminal.buffer.active.cursorX).toBe(3) + expect(visibleTerminalLines(screen.terminal, rows)[cursorRow]).toBe(' ❯') + expectUserPanelRow(screen.terminal, cursorRow, columns) + if (cursorRow > 0) expectUserPanelRow(screen.terminal, cursorRow - 1, columns) + if (cursorRow + 1 < rows) expectUserPanelRow(screen.terminal, cursorRow + 1, columns) + if (rows >= 4) { + expect( + screen.terminal.buffer.active + .getLine(rows - 1) + ?.getCell(0) + ?.isBgDefault() + ).toBe(true) + } + } + + terminal.close() + await screen.flush() + screen.terminal.dispose() + }) + + it('returns Ctrl+V with the current draft and implements clear-aware Ctrl+C', async () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + + const clipboard = terminal.read('❯ ') + input.write('explain this') + key(input, '\u0016', { ctrl: true, name: 'v', sequence: '\u0016' }) + await expect(clipboard).resolves.toEqual({ kind: 'clipboard', value: 'explain this' }) + + const withDraft = terminal.read('❯ ') + input.write('discard me') + key(input, '\u0003', { ctrl: true, name: 'c', sequence: '\u0003' }) + await expect(withDraft).resolves.toEqual({ kind: 'interrupt', empty: false }) + + const empty = terminal.read('❯ ') + key(input, '\u0003', { ctrl: true, name: 'c', sequence: '\u0003' }) + await expect(empty).resolves.toEqual({ kind: 'interrupt', empty: true }) + terminal.close() + }) + + it('renders questions in the bottom panel with focus, custom answers, and cancellation', async () => { + const { input, output, chunks } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const question = { + prompt: 'Which service?', + multi: false, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + } + + const selected = terminal.askQuestion(question) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + expect(chunks.at(-1)).toContain('❯ 2. Worker') + key(input, '\r', { name: 'return', sequence: '\r' }) + await expect(selected).resolves.toEqual({ kind: 'answer', values: ['Worker'] }) + + const custom = terminal.askQuestion(question) + input.write('my service\r') + await expect(custom).resolves.toEqual({ kind: 'answer', values: ['my service'] }) + + const cancelled = terminal.askQuestion(question) + key(input, '', { name: 'escape', sequence: '\u001b' }) + await expect(cancelled).resolves.toEqual({ kind: 'cancel' }) + expect(chunks.join('')).not.toContain('Choose an option:') + expect(chunks.join('')).not.toContain('Selected:') + terminal.close() + }) + + it('keeps multi-select state in place and submits it explicitly', async () => { + const { input, output, chunks } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const result = terminal.askQuestion({ + prompt: 'Which services?', + multi: true, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + }) + + key(input, ' ', { name: 'space', sequence: ' ' }) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + key(input, ' ', { name: 'space', sequence: ' ' }) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + key(input, '', { name: 'down', sequence: '\u001b[B' }) + expect(chunks.at(-1)).toContain('❯ \u001b[2mSubmit answers') + key(input, '\r', { name: 'return', sequence: '\r' }) + + await expect(result).resolves.toEqual({ kind: 'answer', values: ['API', 'Worker'] }) + expect(chunks.join('')).toContain('[✓] API') + expect(chunks.join('')).toContain('[✓] Worker') + expect(chunks.join('')).not.toContain('Selected:') + terminal.close() + }) + + it('keeps completed tool rows in the transcript after transient activity clears', () => { + const { input, output, chunks } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.thinking('Inspecting\nworkflows…') + activity.event({ + kind: 'tool', + id: 'tool-1', + label: 'Read\nworkspace', + state: 'running', + }) + activity.event({ kind: 'tool', id: 'tool-1', label: 'Read workspace', state: 'complete' }) + activity.event({ + kind: 'subagent', + id: 'agent-1', + label: 'Research\u001b]0;owned\u0007 agent', + state: 'running', + }) + activity.event({ + kind: 'narration', + parentId: 'agent-1', + delta: 'Found the relevant workflow', + }) + activity.event({ + kind: 'subagent', + id: 'agent-1', + label: 'Research\u001b]0;owned\u0007 agent', + state: 'error', + }) + activity.clear() + activity.stop() + + const rendered = chunks.join('') + expect(rendered).toContain('\u001b[32m●\u001b[0m Read workspace') + expect(rendered).toContain('\u001b[31m●\u001b[0m Research agent') + expect(rendered).toContain(' \u001b[2mFound the relevant workflow\u001b[0m') + expect(rendered).not.toContain('Research agent \u001b[2mfailed') + expect(rendered).not.toContain(' \u001b[32m●\u001b[0m Read workspace') + expect(rendered).not.toContain(' \u001b[31m●\u001b[0m Research agent') + expect(rendered).not.toContain('owned') + expect(rendered).not.toContain('✗') + terminal.close() + }) + + it('renders nested lanes in wire order with verbatim adjacent narration and structural seams', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.event({ + kind: 'subagent', + id: 'agent-root', + label: 'Workflow Agent', + state: 'running', + }) + activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'First ' }) + activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'step\n\ncontinues' }) + activity.event({ + kind: 'tool', + id: 'tool-read', + parentId: 'agent-root', + label: 'Read file', + state: 'complete', + }) + activity.event({ kind: 'narration', parentId: 'agent-root', delta: 'After tool' }) + activity.event({ + kind: 'subagent', + id: 'agent-child', + parentId: 'agent-root', + label: 'Deploy Agent', + state: 'running', + }) + activity.event({ kind: 'narration', parentId: 'agent-child', delta: 'Shipping now' }) + + const probe = terminal as never as { activityEventsDisplay(): string } + expect(plainTerminalText(probe.activityEventsDisplay()).split('\n')).toEqual([ + '● Workflow Agent', + ' First step', + ' ', + ' continues', + ' ● Read file', + ' After tool', + ' ● Deploy Agent', + ' Shipping now', + ]) + + activity.stop() + terminal.close() + }) + + it('keeps parallel same-name subagents separate by id', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + for (const [id, narration] of [ + ['agent-a', 'First lane'], + ['agent-b', 'Second lane'], + ] as const) { + activity.event({ kind: 'subagent', id, label: 'Research Agent', state: 'running' }) + activity.event({ kind: 'narration', parentId: id, delta: narration }) + activity.event({ kind: 'subagent', id, label: 'Research Agent', state: 'complete' }) + } + + const probe = terminal as never as { activityEventsDisplay(): string } + expect(plainTerminalText(probe.activityEventsDisplay()).split('\n')).toEqual([ + '● Research Agent', + ' First lane', + '● Research Agent', + ' Second lane', + ]) + + activity.stop() + terminal.close() + }) + + it('commits only whole settled roots and prunes closed empty subagent groups', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.event({ + kind: 'subagent', + id: 'agent-root', + label: 'Build Agent', + state: 'complete', + }) + activity.event({ + kind: 'tool', + id: 'tool-child', + parentId: 'agent-root', + label: 'Editing workflow', + state: 'running', + }) + activity.event({ + kind: 'subagent', + id: 'agent-empty', + label: 'Empty Agent', + state: 'complete', + }) + + const probe = terminal as never as { + activityEventsDisplay(): string + transcript: string + } + activity.clear() + expect(plainTerminalText(probe.transcript)).toBe('') + expect(plainTerminalText(probe.activityEventsDisplay())).toContain('Build Agent') + expect(plainTerminalText(probe.activityEventsDisplay())).not.toContain('Empty Agent') + + activity.event({ + kind: 'tool', + id: 'tool-child', + parentId: 'agent-root', + label: 'Edited workflow', + state: 'complete', + }) + activity.clear() + expect(plainTerminalText(probe.transcript).trim().split('\n')).toEqual([ + '● Build Agent', + ' ● Edited workflow', + ]) + expect(probe.activityEventsDisplay()).toBe('') + + activity.stop() + terminal.close() + }) + + it('pins the UI thinking label while tool activity remains in the transcript tail', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + activity.thinking('Planning next step') + activity.event({ kind: 'tool', id: 'tool-1', label: 'Reading file', state: 'running' }) + + const probe = terminal as never as { + activityEventsDisplay(): string + activityStatusLine(): string + buildPanel(rows: number): { lines: string[] } + } + const events = probe + .activityEventsDisplay() + .split('\n') + .map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const status = probe.activityStatusLine().replace(/\u001b\[[0-9;:]*m/gu, '') + const panel = probe.buildPanel(24).lines.map((line) => line.replace(/\u001b\[[0-9;:]*m/gu, '')) + const statusRow = panel.findIndex((line) => line.includes('Thinking…')) + const composerRow = panel.findIndex((line) => line.startsWith(' ❯')) + + expect(events).toEqual(['● Reading file…']) + expect(status).toMatch(/^[·•●] Thinking…$/u) + expect(status).not.toContain('Planning next step') + expect(statusRow).toBeGreaterThanOrEqual(0) + expect(statusRow).toBe(composerRow - 2) + expect(panel[composerRow - 1]).toBe(' ') + expect(panel[composerRow + 1]).toBe(' ') + + activity.clear() + expect(probe.activityStatusLine().replace(/\u001b\[[0-9;:]*m/gu, '')).toMatch( + /^[·•●] Thinking…$/u + ) + activity.stop() + terminal.close() + }) + + it('commits one settled work duration without showing a live time counter', () => { + const { input, output } = terminalStreams() + const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + const probe = terminal as never as { + activityStatusLine(): string + transcript: string + } + + expect(probe.activityStatusLine()).not.toContain('Worked for') + expect(probe.activityStatusLine()).not.toContain('1m') + terminal.write('Done') + now.mockReturnValue(75_000) + activity.complete() + activity.complete() + + const transcript = probe.transcript.replace(/\u001b\[[0-9;:]*m/gu, '') + expect(transcript.match(/✻ Worked for 1m 5s/gu)).toHaveLength(1) + expect(probe.activityStatusLine()).toBe('') + + terminal.close() + now.mockRestore() + }) + + it('preserves every completed row when a turn exceeds the live activity window', () => { + const { input, output } = terminalStreams() + const terminal = new ReadlineChatTerminal(input, output) + const activity = terminal.activity('Thinking…') + const labels = Array.from({ length: 30 }, (_, index) => `Tool ${index}`) + + for (const [index, label] of labels.entries()) { + activity.event({ kind: 'tool', id: `tool-${index}`, label, state: 'complete' }) + } + activity.stop() + + const transcript = ( + terminal as never as { + transcript: string + } + ).transcript + .replace(/\u001b\[[0-9;:]*m/gu, '') + .trim() + .split('\n') + expect(transcript).toEqual(labels.map((label) => `● ${label}`)) + + terminal.close() + }) + + it('ignores stale activity handles and settles an empty successful turn once', () => { + const { input, output } = terminalStreams() + const now = vi.spyOn(Date, 'now').mockReturnValue(10_000) + const terminal = new ReadlineChatTerminal(input, output) + const stale = terminal.activity('Thinking…') + const current = terminal.activity('Thinking…') + const probe = terminal as never as { + activityEventsDisplay(): string + activityStatusLine(): string + transcript: string + } + + stale.update('Stale') + stale.event({ kind: 'tool', id: 'stale-tool', label: 'Stale tool', state: 'complete' }) + stale.complete() + expect(probe.activityStatusLine()).toContain('Thinking…') + expect(probe.activityEventsDisplay()).not.toContain('Stale tool') + + now.mockReturnValue(12_000) + current.complete() + current.complete() + expect( + probe.transcript.replace(/\u001b\[[0-9;:]*m/gu, '').match(/Worked for 2s/gu) + ).toHaveLength(1) + + terminal.close() + now.mockRestore() + }) + + it('queues rapid non-TTY lines and leaves non-interactive output free of screen controls', async () => { + const input = new PassThrough() + const output = new PassThrough() + const chunks: string[] = [] + output.on('data', (chunk) => chunks.push(String(chunk))) + const terminal = new ReadlineChatTerminal(input, output) + + input.write('first\nsecond\n') + await new Promise((resolve) => setImmediate(resolve)) + + await expect(terminal.read('> ')).resolves.toEqual({ + kind: 'line', + value: 'first', + queued: true, + display: 'first', + }) + await expect(terminal.read('> ')).resolves.toEqual({ + kind: 'line', + value: 'second', + queued: true, + display: 'second', + }) + terminal.write('plain output') + expect(chunks.join('')).toBe('plain output') + expect(chunks.join('')).not.toContain('\u001b[') + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.ts new file mode 100644 index 00000000000..e4afac55352 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.ts @@ -0,0 +1,2682 @@ +import { emitKeypressEvents, type Key } from 'node:readline' +import type { Readable, Writable } from 'node:stream' +import { safeOneLine, sanitize } from '../../output/render.js' +import { + artPad, + displayWidth, + firstGrapheme, + graphemes, + graphemeWidth, + lineEnd, + lineStart, + nextGraphemeIndex, + previousGraphemeIndex, + tailToWidth, + truncateDisplay, +} from '../../output/terminal-text.js' + +export type ChatTerminalInput = + | { + kind: 'line' + value: string + queued?: boolean + display?: string + /** Large-paste bodies retained only so a failed queued turn can be retried losslessly. */ + pastes?: ReadonlyMap<number, string> + /** Identity-bearing `@` and `/` tags present in this submitted line. */ + contexts?: ChatContext[] + } + | { kind: 'clipboard'; value: string } + | { kind: 'selection'; values: string[] } + | { kind: 'interrupt'; empty?: boolean } + | { kind: 'eof' } + +type ChatActivityState = 'running' | 'complete' | 'error' + +export type ChatActivityUpdate = + | { + kind: 'tool' | 'subagent' + id: string + label: string + state: ChatActivityState + /** Opaque public id of the subagent lane that owns this row. */ + parentId?: string + } + | { + kind: 'narration' + /** Opaque public id of the subagent lane that owns this text. */ + parentId: string + delta: string + } + +export interface ChatActivity { + update(message: string): void + thinking(delta: string): void + event(update: ChatActivityUpdate): void + clear(): void + complete(): void + stop(): void +} + +export interface ChatTerminalQuestion { + prompt: string + multi: boolean + options: Array<{ id: string; label: string }> +} + +export interface ChatTerminalSelect { + prompt: string + options: Array<{ id: string; label: string; description?: string }> +} + +export interface ChatTerminalWelcome { + chatTitle: string + profile?: string + workspaceName?: string +} + +export type ChatTerminalQuestionResult = + | { kind: 'answer'; values: string[] } + | { kind: 'cancel' } + | { kind: 'eof' } + +export type ChatTerminalSelectResult = + | { kind: 'selected'; id: string } + | { kind: 'cancel' } + | { kind: 'eof' } + +export type ChatTerminalInterruptReason = 'manual' | 'submit' +export type ChatTerminalInterruptListener = ( + reason: ChatTerminalInterruptReason, + input?: ChatTerminalInput +) => void + +export interface ChatTerminal { + welcome(context: ChatTerminalWelcome): void + /** Updates the active conversation title after resume or server-side title generation. */ + setChatTitle(title: string): void + /** Fills in the workspace name once the lookup resolves. */ + setWorkspaceName(name: string): void + /** Inserts an `[Image #N]` tag at the cursor for a just-attached image. */ + noteAttachment(): void + /** Supplies the home-composer `@` resource and `/` skill/MCP pools. */ + setSuggestionCandidates?(candidates: ChatSuggestionCandidates): void + /** Clears the visible conversation while preserving the active terminal session. */ + clearTranscript(): void + userMessage(message: string): void + read(prompt: string): Promise<ChatTerminalInput> + /** Whether deferred input, a control, or a priority preload is waiting to be consumed. */ + hasQueuedInput(): boolean + /** Temporarily stages text ahead of queued turns without discarding the live draft. */ + preload( + value: string, + options?: { + queued?: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } + ): boolean + status(message: string): void + /** Writes trusted, already-rendered assistant output into the coordinated transcript viewport. */ + write(content: string): void + activity(message: string): ChatActivity + askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> + /** Opens a searchable, single-choice menu above the bottom-pinned search composer. */ + select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> + onInterrupt(listener: ChatTerminalInterruptListener): () => void + close(): void +} + +interface TerminalInput extends Readable { + isTTY?: boolean + isRaw?: boolean + setRawMode?: (mode: boolean) => void +} + +interface TerminalOutput extends Writable { + isTTY?: boolean + columns?: number + rows?: number +} + +interface CursorPoint { + index: number + row: number + column: number +} + +interface DraftLayout { + rows: string[] + points: CursorPoint[] + cursor: CursorPoint +} + +interface DraftLayoutOptions { + continuationPrefix?: string + normalTextStyle?: string +} + +interface RenderPanel { + lines: string[] + focusRow?: number + centerFocus?: boolean + cursor?: { row: number; column: number } +} + +interface QuestionState { + question: ChatTerminalQuestion + active: number + selected: Set<number> + previousDraft: string + previousCursor: number + previousContexts: ChatContext[] + resolve: (result: ChatTerminalQuestionResult) => void +} + +interface SelectState { + menu: ChatTerminalSelect + active: number + previousDraft: string + previousCursor: number + previousContexts: ChatContext[] + resolve: (result: ChatTerminalSelectResult) => void +} + +interface QueuedTerminalInput { + input: ChatTerminalInput + /** Composer text that has not already been committed to the transcript. */ + display?: string +} + +interface PreloadState { + initialDraft: string + previousDraft: string + previousCursor: number + previousPastes: Map<number, string> + previousContexts: ChatContext[] + queued: boolean +} + +interface RecalledQueueState { + index: number + initialDraft: string + /** Undefined when the original queue row was already committed. */ + commitDisplay?: string +} + +type ChatActivityStatusUpdate = Exclude<ChatActivityUpdate, { kind: 'narration' }> + +type ActivityTreeChild = { kind: 'node'; id: string } | { kind: 'narration'; content: string } + +interface ActivityTreeNode extends ChatActivityStatusUpdate { + children: ActivityTreeChild[] +} + +import { + applySuggestion, + type ChatContext, + type ChatSuggestionCandidates, + type CompletionToken, + contextSpans, + extractCompletionToken, + formatMention, + presentContexts, + rankSuggestions, + resolveSlashContexts, + SLASH_COMMANDS, + type SuggestionItem, + suggestionWindow, +} from './chat-suggestions.js' + +const ESC = '\u001b' +const RESET = `${ESC}[0m` +const DIM = `${ESC}[2m` +const HIDE_CURSOR = `${ESC}[?25l` +const SHOW_CURSOR = `${ESC}[?25h` +const ENTER_ALTERNATE_SCREEN = `${ESC}[?1049h` +const EXIT_ALTERNATE_SCREEN = `${ESC}[?1049l` +const ENABLE_BRACKETED_PASTE = `${ESC}[?2004h` +const DISABLE_BRACKETED_PASTE = `${ESC}[?2004l` +const BEGIN_SYNCHRONIZED_OUTPUT = `${ESC}[?2026h` +const END_SYNCHRONIZED_OUTPUT = `${ESC}[?2026l` +const CLEAR_SCREEN = `${ESC}[2J` +const RESET_SCROLL_REGION = `${ESC}[r` +const BOLD = `${ESC}[1m` +const BRIGHT_WHITE = `${ESC}[97m` +/** Sim green — marks a mention that currently resolves to a candidate. */ +const MENTION_TEXT = `${ESC}[38;2;51;196;130m` +const USER_MESSAGE_BACKGROUND = `${ESC}[48;2;58;60;70m` +const USER_MESSAGE_TEXT = `${ESC}[38;2;242;242;242m` +const USER_MESSAGE_POINTER = `${ESC}[38;2;160;160;160m` +const USER_PANEL_OUTER_MARGIN = ' ' +const USER_TURN_PREFIX = `${USER_PANEL_OUTER_MARGIN}❯ ` +const ASSISTANT_TURN_PREFIX = '● ' +const DEFAULT_CHAT_TITLE = 'New chat' +const CONTINUATION_PREFIX = ' ' +const MAX_TRANSCRIPT_CHARACTERS = 256 * 1024 +const MAX_HISTORY_ENTRIES = 500 +const MAX_DRAFT_CHARACTERS = 10 * 1024 * 1024 +/** Above this, or across multiple lines, a paste collapses to a placeholder. */ +const PASTE_PLACEHOLDER_CHARACTERS = 800 +const PASTE_PLACEHOLDER_LINES = 3 +const PASTED_TEXT_REF = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g +const BLIMP_ART = [ + ' ⣀⣀⣀', + ' ⡇ ⢳⡀⣀⣀⣀⠤⢤⣤⣤⣤⠤⠤⠤⣀⣀⣀', + ' ⢻⣀⡴⠂⠉⢹⠤⠤⣜⠁ ⢘⡦⠤⠤⡞⠉⠉⠙⠻⡖⠢⢄⡀', + '⠤⠶⠶⠮⣤⣽⠤⠴⡋ ⢘⡦⠤⢤⡏ ⠹⣄⣀⣀⡴⠋⠉⢹⢺⡄', + ' ⠈⠓⠤⢄⣹⡤⠤⢜ ⠳⣄⣀⣀⡞ ⢙⣦⣖⠾⠋', + ' ⠈⠉⠉⣝⣀⣀⣚⣀⡔⠒⠛⠓⠊⠉⠉', +] as const + +/** Frames and cadence for the airship sliding in from the left on first paint. */ +const WELCOME_FLY_IN_FRAMES = 20 +const WELCOME_FLY_IN_INTERVAL_MS = 22 +/** Columns the detail box needs beside the art before it is worth drawing. */ +const WELCOME_MIN_BOX_COLUMNS = 26 +/** Blank columns between the detail box and the airship. */ +const WELCOME_GUTTER = 2 + +function formatActivityDuration(elapsedMs: number): string { + let seconds = Math.max(1, Math.round(Math.max(0, elapsedMs) / 1000)) + const hours = Math.floor(seconds / 3600) + seconds %= 3600 + const minutes = Math.floor(seconds / 60) + seconds %= 60 + return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', seconds ? `${seconds}s` : ''] + .filter(Boolean) + .join(' ') +} + +/** Shared row treatment for the editable composer and its committed user turn. */ +function userPanelRow(content = ''): string { + return `${USER_PANEL_OUTER_MARGIN}${USER_MESSAGE_BACKGROUND}${content}${RESET}` +} + +/** A fullscreen terminal chat with a durable transcript and a bottom-pinned composer. */ +export class ReadlineChatTerminal implements ChatTerminal { + private pending: ((input: ChatTerminalInput) => void) | null = null + private readonly queued: QueuedTerminalInput[] = [] + private recalledQueue: RecalledQueueState | null = null + private readonly interruptListeners = new Set<ChatTerminalInterruptListener>() + private readonly history: string[] = [] + private historyIndex = 0 + private historyDraft = '' + private preferredColumn: number | null = null + private prompt = '❯ ' + private draft = '' + private cursor = 0 + private preloadState: PreloadState | null = null + private composerVisible = false + private busy = false + private questionState: QuestionState | null = null + private selectState: SelectState | null = null + private welcomeVisible = false + private welcomeProfile: string | null = null + private welcomeChatTitle = DEFAULT_CHAT_TITLE + private welcomeWorkspaceName: string | null = null + private suggestionIndex = 0 + private suggestionQueryKey: string | null = null + private suggestionDismissed: string | null = null + private resourceCandidates: SuggestionItem[] = [] + private slashCandidates: SuggestionItem[] = [] + private selectedContexts: ChatContext[] = [] + private nextAttachmentNumber = 1 + private pasting = false + private pasteBuffer = '' + private pastedText = new Map<number, string>() + private nextPasteId = 1 + private transcriptEpoch = 0 + private wrapCache: { + width: number + epoch: number + consumed: number + rows: string[] + state: WrapState + } | null = null + private welcomeRevealFrame = WELCOME_FLY_IN_FRAMES + private welcomeTimer: ReturnType<typeof setInterval> | null = null + private transcript = '' + private assistantPrefixPending = false + private assistantPrefixBuffer = '' + private assistantTurnActive = false + private assistantContinuationPending = false + private transcriptScrollTopRow: number | null = null + private viewportActive = false + private renderedScreen: string[] | null = null + private renderedColumns = 0 + private renderedRows = 0 + private restoredRawMode = false + private readonly inputWasRaw: boolean + private readonly inputWasFlowing: boolean + private ended = false + private closed = false + private activityActive = false + private activityThinking = '' + private activityStartedAt = 0 + private activityGeneration = 0 + private readonly activityNodes = new Map<string, ActivityTreeNode>() + private readonly activityRoots: string[] = [] + private readonly committedActivityRoots = new Set<string>() + private activityFrame = 0 + private activityTimer: ReturnType<typeof setInterval> | null = null + + constructor( + private readonly input: Readable = process.stdin, + private readonly output: Writable = process.stdout + ) { + this.inputWasFlowing = input.readableFlowing === true + this.inputWasRaw = Boolean((input as TerminalInput).isRaw) + emitKeypressEvents(input) + input.on('keypress', this.handleKeypress) + input.once('end', this.handleInputEnd) + output.on('resize', this.handleResize) + } + + welcome(context: ChatTerminalWelcome): void { + if (!this.isInteractiveTTY() || this.closed) return + this.welcomeVisible = true + this.welcomeProfile = context.profile ? safeOneLine(context.profile).slice(0, 80) : null + this.welcomeChatTitle = safeOneLine(context.chatTitle).slice(0, 160) || DEFAULT_CHAT_TITLE + this.welcomeWorkspaceName = context.workspaceName + ? safeOneLine(context.workspaceName).slice(0, 80) + : null + this.startWelcomeFlyIn() + this.ensureViewport() + this.renderScreen() + } + + userMessage(message: string): void { + if (!message.trim()) return + this.commitUserLine(message) + this.renderScreen() + } + + clearTranscript(): void { + this.stopActivity() + this.transcript = '' + this.transcriptEpoch += 1 + this.wrapCache = null + this.assistantPrefixPending = false + this.assistantPrefixBuffer = '' + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.transcriptScrollTopRow = null + this.history.length = 0 + this.historyIndex = 0 + this.historyDraft = '' + this.renderScreen() + } + + read(prompt: string): Promise<ChatTerminalInput> { + if (this.closed) return Promise.resolve({ kind: 'eof' }) + const queued = this.preloadState ? undefined : this.queued.shift() + if (queued) { + if (this.recalledQueue && this.recalledQueue.index > 0) { + this.recalledQueue.index-- + } + const { input } = queued + if (input.kind === 'line') { + for (const [id, body] of input.pastes ?? []) this.pastedText.set(id, body) + if (queued.display?.trim()) this.commitUserLine(queued.display) + } + this.renderScreen() + return Promise.resolve(input) + } + if (this.ended) return Promise.resolve({ kind: 'eof' }) + if (this.pending || this.questionState || this.selectState) { + throw new Error('Chat terminal already has a pending read') + } + + this.prompt = sanitize(prompt) + .replace(/[\n\r\t]+/gu, ' ') + .slice(0, 80) + this.draft = sanitize(this.draft).slice(0, MAX_DRAFT_CHARACTERS) + this.cursor = Math.min(this.cursor, this.draft.length) + this.preferredColumn = null + this.historyIndex = this.history.length + this.historyDraft = this.draft + this.composerVisible = true + this.busy = false + this.ensureViewport() + + if (!this.isInteractiveTTY()) this.output.write(this.prompt) + this.renderScreen() + return new Promise((resolve) => { + this.pending = resolve + this.renderScreen() + }) + } + + hasQueuedInput(): boolean { + return this.preloadState !== null || this.queued.length > 0 + } + + preload( + value: string, + options: { + queued?: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } = {} + ): boolean { + if ( + this.closed || + this.ended || + this.pending || + this.busy || + this.questionState || + this.selectState || + this.preloadState + ) { + return false + } + const next = sanitize(value).slice(0, MAX_DRAFT_CHARACTERS) + if (!next) return false + + this.preloadState = { + initialDraft: next, + previousDraft: this.draft, + previousCursor: this.cursor, + previousPastes: this.pastesFor(this.draft), + previousContexts: this.selectedContexts, + queued: options.queued === true, + } + for (const [id, body] of options.pastes ?? []) this.pastedText.set(id, body) + this.draft = next + this.cursor = next.length + this.selectedContexts = [...(options.contexts ?? [])] + this.preferredColumn = null + this.composerVisible = true + this.renderScreen() + return true + } + + status(message: string): void { + const safe = sanitize(message) + if (!this.isInteractiveTTY()) { + this.output.write(safe) + if (!safe.endsWith('\n')) this.output.write('\n') + return + } + + this.ensureViewport() + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.appendTranscript(safe) + if (!safe.endsWith('\n')) this.appendTranscript('\n') + this.renderScreen() + } + + write(content: string): void { + if (!content) return + if (!this.isInteractiveTTY()) { + this.output.write(content) + return + } + + this.ensureViewport() + let rendered = content.replace(/\r/gu, '') + if (this.assistantPrefixPending) { + this.assistantPrefixBuffer += rendered + const prefixed = prefixAssistantTurn(this.assistantPrefixBuffer) + if (prefixed === null) return + rendered = prefixed + this.assistantPrefixPending = false + this.assistantPrefixBuffer = '' + this.assistantTurnActive = true + this.assistantContinuationPending = rendered.endsWith('\n') + } else if (this.assistantTurnActive) { + rendered = indentAssistantFragment(rendered, this.assistantContinuationPending) + this.assistantContinuationPending = rendered.endsWith('\n') + } + this.appendTranscript(rendered) + this.renderScreen() + } + + activity(message: string): ChatActivity { + this.stopActivity() + this.assistantPrefixPending = true + this.assistantPrefixBuffer = '' + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.activityActive = true + this.activityThinking = safeOneLine(message) || 'Thinking…' + this.activityStartedAt = Date.now() + const generation = ++this.activityGeneration + this.activityNodes.clear() + this.activityRoots.length = 0 + this.committedActivityRoots.clear() + this.activityFrame = 0 + this.busy = true + this.composerVisible = true + this.ensureViewport() + this.renderScreen() + + if (this.isInteractiveTTY()) { + this.activityTimer = setInterval(() => { + this.activityFrame += 1 + this.renderScreen() + }, 90) + this.activityTimer.unref() + } + + let stopped = false + const isCurrent = () => + !stopped && this.activityActive && generation === this.activityGeneration + const finish = (completed: boolean) => { + if (!isCurrent()) return + stopped = true + this.stopActivity(completed) + } + return { + update: (next) => { + if (!isCurrent()) return + this.activityThinking = safeOneLine(next) || this.activityThinking + this.renderScreen() + }, + thinking: (_delta) => { + if (!isCurrent()) return + // Match the web client: raw reasoning is not rendered; the stable + // turn-level label remains visible in the tail instead. + }, + event: (update) => { + if (!isCurrent()) return + this.recordActivityEvent(update) + this.renderScreen() + }, + clear: () => { + if (!isCurrent()) return + this.commitActivityEvents(false) + this.renderScreen() + }, + complete: () => finish(true), + stop: () => finish(false), + } + } + + askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> { + if (this.pending || this.questionState || this.selectState) { + throw new Error('Chat terminal already has a pending read') + } + if (this.ended || this.closed) return Promise.resolve({ kind: 'eof' }) + + const safeQuestion: ChatTerminalQuestion = { + prompt: safeOneLine(question.prompt).slice(0, 500), + multi: question.multi, + options: question.options.slice(0, 20).map((option) => ({ + id: safeOneLine(option.id).slice(0, 160), + label: safeOneLine(option.label).slice(0, 160), + })), + } + const previousDraft = this.draft + const previousCursor = this.cursor + const previousContexts = this.selectedContexts + this.draft = '' + this.cursor = 0 + this.selectedContexts = [] + this.preferredColumn = null + this.composerVisible = true + this.busy = false + this.ensureViewport() + + return new Promise((resolve) => { + this.questionState = { + question: safeQuestion, + active: 0, + selected: new Set(), + previousDraft, + previousCursor, + previousContexts, + resolve, + } + this.renderScreen() + }) + } + + select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> { + if (this.pending || this.questionState || this.selectState) { + throw new Error('Chat terminal already has a pending read') + } + if (this.ended || this.closed) return Promise.resolve({ kind: 'eof' }) + + const safeMenu: ChatTerminalSelect = { + prompt: safeOneLine(menu.prompt).slice(0, 500), + options: menu.options.map((option) => ({ + id: safeOneLine(option.id).slice(0, 160), + label: safeOneLine(option.label).slice(0, 255), + ...(option.description + ? { description: safeOneLine(option.description).slice(0, 255) } + : {}), + })), + } + const previousDraft = this.draft + const previousCursor = this.cursor + const previousContexts = this.selectedContexts + this.draft = '' + this.cursor = 0 + this.selectedContexts = [] + this.preferredColumn = null + this.composerVisible = true + this.busy = false + this.ensureViewport() + + return new Promise((resolve) => { + this.selectState = { + menu: safeMenu, + active: 0, + previousDraft, + previousCursor, + previousContexts, + resolve, + } + this.renderScreen() + }) + } + + onInterrupt(listener: ChatTerminalInterruptListener): () => void { + this.interruptListeners.add(listener) + return () => this.interruptListeners.delete(listener) + } + + close(): void { + if (this.closed) return + this.stopActivity() + this.stopWelcomeFlyIn() + this.closed = true + + const pending = this.pending + this.pending = null + pending?.({ kind: 'eof' }) + const question = this.questionState + this.questionState = null + question?.resolve({ kind: 'eof' }) + const select = this.selectState + this.selectState = null + select?.resolve({ kind: 'eof' }) + + this.input.removeListener('keypress', this.handleKeypress) + this.input.removeListener('end', this.handleInputEnd) + this.output.removeListener('resize', this.handleResize) + + if (this.viewportActive) { + this.output.write( + `${BEGIN_SYNCHRONIZED_OUTPUT}${RESET}${RESET_SCROLL_REGION}${DISABLE_BRACKETED_PASTE}${SHOW_CURSOR}${EXIT_ALTERNATE_SCREEN}${END_SYNCHRONIZED_OUTPUT}` + ) + this.viewportActive = false + } + this.restoreInputMode() + } + + private readonly handleResize = (): void => { + this.renderScreen() + } + + private readonly handleInputEnd = (): void => { + this.ended = true + const pending = this.pending + this.pending = null + pending?.({ kind: 'eof' }) + const question = this.questionState + this.questionState = null + question?.resolve({ kind: 'eof' }) + const select = this.selectState + this.selectState = null + select?.resolve({ kind: 'eof' }) + this.renderScreen() + } + + private readonly handleKeypress = (character: string, key: Key | undefined): void => { + if (this.closed) return + if (key?.name === 'paste-start') { + this.pasting = true + this.pasteBuffer = '' + return + } + if (key?.name === 'paste-end') { + const pasted = this.pasteBuffer + this.pasting = false + this.pasteBuffer = '' + this.commitPaste(pasted) + return + } + if (this.pasting) { + if (character) this.pasteBuffer += character + return + } + + if (this.selectState) { + this.handleSelectKey(character, key) + return + } + + if (this.handleTranscriptNavigationKey(key)) return + + if (key?.ctrl && key.name === 'v') { + this.resolveClipboard() + return + } + if (this.questionState) { + this.handleQuestionKey(character, key) + return + } + + this.handleEditorKey(character, key) + } + + private handleEditorKey(character: string, key: Key | undefined): void { + if (!this.isComposerEditable() && this.isInteractiveTTY()) return + if (key?.ctrl && key.name === 'c') { + if (!this.pending && this.busy) { + for (const listener of this.interruptListeners) listener('manual') + return + } + const wasEmpty = this.draft.length === 0 + this.draft = '' + this.cursor = 0 + this.preferredColumn = null + this.resolveInput({ kind: 'interrupt', empty: wasEmpty }) + return + } + if (key?.ctrl && key.name === 'd' && this.draft.length === 0) { + this.resolveInput({ kind: 'eof' }) + return + } + const open = this.openSuggestions() + if (open) { + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + this.moveSuggestion(open.items.length, -1) + return + } + if (key?.name === 'down' || (key?.ctrl && key.name === 'n')) { + this.moveSuggestion(open.items.length, 1) + return + } + if (key?.name === 'escape') { + this.suggestionDismissed = this.draft + this.renderScreen() + return + } + if (key?.name === 'tab' || isEnter(key)) { + const chosen = open.items[Math.min(this.suggestionIndex, open.items.length - 1)] + const submitExactSlash = + isEnter(key) && + chosen?.tag === 'command' && + open.token.trigger === '/' && + chosen.value === open.token.token + if (!submitExactSlash) { + this.acceptSuggestion(open) + return + } + } + } + if (key?.name === 'escape') { + if (!this.pending && this.busy) { + for (const listener of this.interruptListeners) listener('manual') + return + } + const wasEmpty = this.draft.length === 0 + this.draft = '' + this.cursor = 0 + this.resolveInput({ kind: 'interrupt', empty: wasEmpty }) + return + } + + if (isEnter(key)) { + const beforeCursor = this.draft.slice(0, this.cursor) + if (key?.shift || key?.meta || beforeCursor.endsWith('\\')) { + if (beforeCursor.endsWith('\\')) { + this.draft = `${beforeCursor.slice(0, -1)}\n${this.draft.slice(this.cursor)}` + this.cursor = beforeCursor.length + } else { + this.insertText('\n') + } + this.renderScreen() + return + } + this.submitDraft() + return + } + if (key?.name === 'backspace') { + this.deleteBackward() + return + } + if (key?.name === 'delete') { + this.deleteForward() + return + } + if (key?.name === 'left' || (key?.ctrl && key.name === 'b')) { + this.cursor = previousGraphemeIndex(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.name === 'right' || (key?.ctrl && key.name === 'f')) { + this.cursor = nextGraphemeIndex(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + if (this.draft.length === 0 && this.recallQueuedDraft()) return + this.moveVertically(-1) + return + } + if (key?.name === 'down' || (key?.ctrl && key.name === 'n')) { + this.moveVertically(1) + return + } + if (key?.name === 'home' || (key?.ctrl && key.name === 'a')) { + this.cursor = lineStart(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.name === 'end' || (key?.ctrl && key.name === 'e')) { + this.cursor = lineEnd(this.draft, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'u') { + this.draft = this.draft.slice(this.cursor) + this.cursor = 0 + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'k') { + this.draft = this.draft.slice(0, this.cursor) + this.preferredColumn = null + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'w') { + const before = this.draft.slice(0, this.cursor) + const start = before.search(/\S+\s*$/u) + if (start >= 0) { + this.draft = `${before.slice(0, start)}${this.draft.slice(this.cursor)}` + this.cursor = start + } + this.preferredColumn = null + this.renderScreen() + return + } + + const printable = printableText(character, key) + if (printable) { + this.insertText(printable) + this.renderScreen() + } + } + + private handleQuestionKey(character: string, key: Key | undefined): void { + const state = this.questionState + if (!state) return + const otherIndex = state.question.options.length + const submitIndex = state.question.multi ? otherIndex + 1 : otherIndex + const choiceCount = submitIndex + 1 + + if (key?.name === 'escape' || (key?.ctrl && key.name === 'c')) { + this.finishQuestion({ kind: 'cancel' }) + return + } + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + state.active = (state.active - 1 + choiceCount) % choiceCount + this.renderScreen() + return + } + if (key?.name === 'down' || key?.name === 'tab' || (key?.ctrl && key.name === 'n')) { + state.active = (state.active + 1) % choiceCount + this.renderScreen() + return + } + if (state.question.multi && key?.name === 'space' && state.active < otherIndex) { + this.toggleQuestionSelection(state.active) + return + } + if (/^[1-9]$/u.test(character) && this.draft.length === 0) { + const index = Number(character) - 1 + if (index < state.question.options.length) { + state.active = index + this.renderScreen() + return + } + } + if (isEnter(key)) { + if (state.active < otherIndex) { + if (state.question.multi) this.toggleQuestionSelection(state.active) + else { + const selected = state.question.options[state.active] + if (selected) this.finishQuestion({ kind: 'answer', values: [selected.label] }) + } + return + } + + const custom = safeOneLine(this.draft) + if (state.active === otherIndex && custom) { + const values = state.question.multi ? [...this.selectedQuestionLabels(), custom] : [custom] + this.finishQuestion({ kind: 'answer', values: [...new Set(values)] }) + return + } + if (state.question.multi && state.active === submitIndex) { + const values = this.selectedQuestionLabels() + if (custom) values.push(custom) + if (values.length > 0) { + this.finishQuestion({ kind: 'answer', values: [...new Set(values)] }) + } + } + return + } + + if (state.active === otherIndex) { + if (key?.name === 'backspace') { + this.deleteBackward() + return + } + if (key?.name === 'delete') { + this.deleteForward() + return + } + if (key?.name === 'left') { + this.cursor = previousGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + if (key?.name === 'right') { + this.cursor = nextGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + } + + const printable = printableText(character, key) + if (printable) { + state.active = otherIndex + this.insertText(printable) + this.renderScreen() + } + } + + private handleSelectKey(character: string, key: Key | undefined): void { + const state = this.selectState + if (!state) return + const options = this.filteredSelectOptions() + + if (key?.name === 'escape' || (key?.ctrl && key.name === 'c')) { + this.finishSelect({ kind: 'cancel' }) + return + } + if (key?.name === 'up' || (key?.ctrl && key.name === 'p')) { + if (options.length > 0) state.active = (state.active - 1 + options.length) % options.length + this.renderScreen() + return + } + if (key?.name === 'down' || key?.name === 'tab' || (key?.ctrl && key.name === 'n')) { + if (options.length > 0) state.active = (state.active + 1) % options.length + this.renderScreen() + return + } + if (isEnter(key)) { + if (this.selectOptionCapacity() <= 0) return + const selected = options[Math.min(state.active, options.length - 1)] + if (selected) this.finishSelect({ kind: 'selected', id: selected.id }) + return + } + if (key?.name === 'backspace') { + state.active = 0 + this.deleteBackward() + return + } + if (key?.name === 'delete') { + state.active = 0 + this.deleteForward() + return + } + if (key?.name === 'left' || (key?.ctrl && key.name === 'b')) { + this.cursor = previousGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + if (key?.name === 'right' || (key?.ctrl && key.name === 'f')) { + this.cursor = nextGraphemeIndex(this.draft, this.cursor) + this.renderScreen() + return + } + if (key?.name === 'home' || (key?.ctrl && key.name === 'a')) { + this.cursor = 0 + this.renderScreen() + return + } + if (key?.name === 'end' || (key?.ctrl && key.name === 'e')) { + this.cursor = this.draft.length + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'u') { + this.draft = this.draft.slice(this.cursor) + this.cursor = 0 + state.active = 0 + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'k') { + this.draft = this.draft.slice(0, this.cursor) + state.active = 0 + this.renderScreen() + return + } + if (key?.ctrl && key.name === 'w') { + const before = this.draft.slice(0, this.cursor) + const start = before.search(/\S+\s*$/u) + if (start >= 0) { + this.draft = `${before.slice(0, start)}${this.draft.slice(this.cursor)}` + this.cursor = start + } + state.active = 0 + this.renderScreen() + return + } + + const printable = printableText(character, key) + if (printable) { + this.insertText(printable) + state.active = 0 + this.renderScreen() + } + } + + /** + * Recomputed each render rather than tracked on every draft mutation, so the + * menu can never disagree with the text it is completing. + */ + private openSuggestions(): { + token: CompletionToken + items: SuggestionItem[] + pool: SuggestionItem[] + } | null { + if ( + !this.isComposerEditable() || + this.questionState || + this.selectState || + this.terminalRows() < 5 + ) { + this.suggestionIndex = 0 + this.suggestionQueryKey = null + return null + } + if (this.suggestionDismissed !== null) { + if (this.suggestionDismissed === this.draft) return null + this.suggestionDismissed = null + } + const token = extractCompletionToken(this.draft, this.cursor) + if (!token) { + this.suggestionIndex = 0 + this.suggestionQueryKey = null + return null + } + const queryKey = `${token.startPos}:${token.trigger}:${token.query}` + if (queryKey !== this.suggestionQueryKey) { + this.suggestionIndex = 0 + this.suggestionQueryKey = queryKey + } + const commandPosition = this.draft.slice(0, token.startPos).trim().length === 0 + const pool = + token.trigger === '/' + ? [...(commandPosition ? SLASH_COMMANDS : []), ...this.slashCandidates] + : this.resourceCandidates + if (!pool.length) return null + const items = rankSuggestions(token.query, pool) + return items.length ? { token, items, pool } : null + } + + setSuggestionCandidates(candidates: ChatSuggestionCandidates): void { + const open = this.openSuggestions() + const selectedId = open?.items[Math.min(this.suggestionIndex, open.items.length - 1)]?.id + this.resourceCandidates = candidates.resources + .map(sanitizeSuggestionItem) + .filter((item): item is SuggestionItem => item !== null) + this.slashCandidates = candidates.slash + .map(sanitizeSuggestionItem) + .filter((item): item is SuggestionItem => item !== null) + if (selectedId) { + const refreshed = this.openSuggestions() + const refreshedIndex = refreshed?.items.findIndex((item) => item.id === selectedId) ?? -1 + this.suggestionIndex = refreshedIndex >= 0 ? refreshedIndex : 0 + } + if (!this.closed && this.isComposerEditable()) this.renderScreen() + } + + private moveSuggestion(total: number, delta: number): void { + this.suggestionIndex = (this.suggestionIndex + delta + total) % total + this.renderScreen() + } + + private acceptSuggestion(open: { token: CompletionToken; items: SuggestionItem[] }): void { + const chosen = open.items[Math.min(this.suggestionIndex, open.items.length - 1)] + if (!chosen) return + const replacement = + open.token.trigger === '@' + ? formatMention(chosen.value) + : chosen.tag === 'command' + ? chosen.value + : `/${chosen.value}` + const next = applySuggestion(this.draft, open.token, replacement) + this.draft = next.draft + this.cursor = next.cursor + if ( + chosen.context && + !this.selectedContexts.some((context) => context.label === chosen.context?.label) + ) { + this.selectedContexts.push(chosen.context) + } + this.suggestionIndex = 0 + this.suggestionDismissed = this.draft + this.renderScreen() + } + + /** + * Re-derived every render rather than stored, so a mention the user + * half-deletes simply stops lighting up instead of leaving stale state. + */ + private liveMentionSpans(): Array<{ start: number; end: number }> { + const selected = presentContexts(this.draft, this.selectedContexts) + const occupied = new Set(selected.map((context) => context.label.toLowerCase())) + const typedSlash = resolveSlashContexts(this.draft, this.slashCandidates).filter( + (context) => !occupied.has(context.label.toLowerCase()) + ) + return [ + ...contextSpans(this.draft, [...selected, ...typedSlash]), + ...attachmentSpans(this.draft), + ].sort((left, right) => left.start - right.start) + } + + private suggestionRows(width: number, rows: number): string[] { + const open = this.openSuggestions() + if (!open) return [] + const maxVisible = Math.max(1, Math.min(5, rows - 6)) + const selected = Math.min(this.suggestionIndex, open.items.length - 1) + const { start, end } = suggestionWindow(open.items.length, selected, maxVisible) + /* Width comes from the whole pool, not the filtered slice, so the column + does not jump while the user narrows the list. */ + const labelWidth = Math.min( + Math.floor(width * 0.4), + Math.max(...open.pool.map((entry) => displayWidth(entry.displayText))) + 2 + ) + return open.items.slice(start, end).map((entry) => { + const active = entry.id === open.items[selected]?.id + const label = truncateDisplay(entry.displayText, Math.max(1, labelWidth - 2)) + const padding = ' '.repeat(Math.max(2, labelWidth - displayWidth(label))) + const line = truncateDisplay(` ${label}${padding}${entry.description ?? ''}`, width) + return active ? `${BRIGHT_WHITE}${line}${RESET}` : `${DIM}${line}${RESET}` + }) + } + + /** + * Turns one bracketed paste into a single edit. + * + * An empty paste is macOS Cmd+V of an image — the terminal sends the markers + * with nothing between them — so it routes to the same clipboard path as + * ctrl+v rather than being discarded. + */ + private commitPaste(text: string): void { + if (!this.isComposerEditable()) return + const normalized = text.replace(/\r\n?/gu, '\n') + if (!normalized) { + if (this.selectState) return + this.resolveClipboard() + return + } + if (this.selectState) { + this.insertText(normalized.replace(/\s+/gu, ' ')) + this.selectState.active = 0 + this.renderScreen() + return + } + const lines = normalized.split('\n').length - 1 + if (normalized.length > PASTE_PLACEHOLDER_CHARACTERS || lines >= PASTE_PLACEHOLDER_LINES) { + const id = this.nextPasteId++ + this.pastedText.set(id, normalized) + this.insertText(lines ? `[Pasted text #${id} +${lines} lines]` : `[Pasted text #${id}]`) + } else { + this.insertText(normalized) + } + this.renderScreen() + } + + /** Splices stashed paste bodies back in, and drops any the user deleted. */ + private pastesFor(value: string): Map<number, string> { + const pastes = new Map<number, string>() + for (const match of value.matchAll(PASTED_TEXT_REF)) { + const id = Number(match[1]) + const body = this.pastedText.get(id) + if (body !== undefined) pastes.set(id, body) + } + return pastes + } + + private expandPastes(value: string): string { + const referenced = new Set<number>() + const expanded = value.replace(PASTED_TEXT_REF, (match, id: string) => { + const body = this.pastedText.get(Number(id)) + if (body === undefined) return match + referenced.add(Number(id)) + return body + }) + for (const id of this.pastedText.keys()) if (!referenced.has(id)) this.pastedText.delete(id) + return expanded + } + + private submitDraft(): void { + /* The placeholder is what the user sees and recalls; only the wire value + carries the expanded body, so a large paste never floods the transcript. */ + const display = this.draft + const preload = this.preloadState + const deferred = !this.pending + if (deferred && !display.trim()) { + this.draft = '' + this.cursor = 0 + this.preferredColumn = null + this.recalledQueue = null + this.renderScreen() + return + } + const pastes = this.pastesFor(display) + const value = this.expandPastes(display) + const selected = presentContexts(value, this.selectedContexts) + const occupied = new Set(selected.map((context) => context.label.toLowerCase())) + const contexts = [ + ...selected, + ...resolveSlashContexts(value, this.slashCandidates).filter( + (context) => !occupied.has(context.label.toLowerCase()) + ), + ] + this.transcriptScrollTopRow = null + if (display.trim()) { + if (this.history.at(-1) !== display) this.history.push(display) + if (this.history.length > MAX_HISTORY_ENTRIES) this.history.shift() + // A queued retry was already committed when it first left the queue. + // Repaint it only if the user edited the staged retry. + const unchangedCommittedRecall = + this.recalledQueue?.commitDisplay === undefined && + display === this.recalledQueue?.initialDraft + if ( + !deferred && + !(preload?.queued && display === preload.initialDraft) && + !unchangedCommittedRecall + ) { + this.commitUserLine(display) + } + } + this.draft = '' + this.cursor = 0 + this.selectedContexts = [] + this.preferredColumn = null + this.historyIndex = this.history.length + const input: ChatTerminalInput = { + kind: 'line', + value, + ...(display !== value ? { display } : {}), + ...(pastes.size ? { pastes } : {}), + ...(contexts.length ? { contexts } : {}), + } + this.resolveInput(input, display) + if (deferred && this.busy && display.trim()) { + for (const listener of this.interruptListeners) listener('submit', input) + } + } + + private resolveClipboard(): void { + if (!this.isComposerEditable()) return + this.resolveInput({ kind: 'clipboard', value: this.draft }) + } + + private resolveInput(value: ChatTerminalInput, display?: string): void { + const pending = this.pending + this.pending = null + let resolved = value + const preload = value.kind === 'clipboard' ? null : this.preloadState + if (preload && value.kind !== 'clipboard') { + this.preloadState = null + if (value.kind === 'line' && preload.queued) { + resolved = { + ...value, + queued: true, + ...(display === undefined ? {} : { display }), + } + } + this.draft = preload.previousDraft + this.cursor = preload.previousCursor + this.selectedContexts = preload.previousContexts + for (const [id, body] of preload.previousPastes) this.pastedText.set(id, body) + } + const recalled = !preload && resolved.kind === 'line' ? this.recalledQueue : null + if (!preload && resolved.kind !== 'clipboard') this.recalledQueue = null + + if (pending) { + pending(resolved) + } else { + if (resolved.kind === 'line') { + resolved = { + ...resolved, + queued: true, + ...(display === undefined ? {} : { display }), + } + } + const entry = { + input: resolved, + ...(!( + (preload?.queued && display === preload.initialDraft) || + (recalled?.commitDisplay === undefined && display === recalled?.initialDraft) + ) + ? { display } + : {}), + } + if (preload) { + this.queued.unshift(entry) + if (this.recalledQueue) this.recalledQueue.index++ + } else if (recalled) { + this.queued.splice(Math.min(recalled.index, this.queued.length), 0, entry) + } else { + this.queued.push(entry) + } + } + this.renderScreen() + } + + private finishQuestion(result: ChatTerminalQuestionResult): void { + const state = this.questionState + if (!state) return + this.questionState = null + this.draft = state.previousDraft + this.cursor = state.previousCursor + this.selectedContexts = state.previousContexts + if (result.kind === 'answer') this.commitUserLine(result.values.join(', ')) + this.renderScreen() + state.resolve(result) + } + + private finishSelect(result: ChatTerminalSelectResult): void { + const state = this.selectState + if (!state) return + this.selectState = null + this.draft = state.previousDraft + this.cursor = state.previousCursor + this.selectedContexts = state.previousContexts + this.preferredColumn = null + this.renderScreen() + state.resolve(result) + } + + private filteredSelectOptions(): ChatTerminalSelect['options'] { + const state = this.selectState + if (!state) return [] + const query = safeOneLine(this.draft).trim().toLocaleLowerCase() + if (!query) return state.menu.options + return state.menu.options.filter((option) => + `${option.label}\n${option.description ?? ''}`.toLocaleLowerCase().includes(query) + ) + } + + private selectOptionCapacity(): number { + return Math.max(0, Math.min(8, this.terminalRows() - 5)) + } + + private selectedQuestionLabels(): string[] { + const state = this.questionState + if (!state) return [] + return [...state.selected] + .sort((left, right) => left - right) + .map((index) => state.question.options[index]?.label) + .filter((label): label is string => Boolean(label)) + } + + private toggleQuestionSelection(index: number): void { + const state = this.questionState + if (!state) return + if (state.selected.has(index)) state.selected.delete(index) + else state.selected.add(index) + this.renderScreen() + } + + private isComposerEditable(): boolean { + return Boolean(this.composerVisible && !this.questionState && !this.closed && !this.ended) + } + + /** Recalls the newest deferred line without disturbing earlier FIFO entries. */ + private recallQueuedDraft(): boolean { + for (let index = this.queued.length - 1; index >= 0; index -= 1) { + const queued = this.queued[index] + if (queued.input.kind !== 'line' || queued.input.display === undefined) continue + this.queued.splice(index, 1) + this.recalledQueue = { + index, + initialDraft: queued.input.display, + ...(queued.display === undefined ? {} : { commitDisplay: queued.display }), + } + for (const [id, body] of queued.input.pastes ?? []) this.pastedText.set(id, body) + this.selectedContexts = [...(queued.input.contexts ?? [])] + this.draft = queued.input.display + this.cursor = this.draft.length + this.preferredColumn = null + this.historyIndex = this.history.length + this.renderScreen() + return true + } + return false + } + + private insertText(value: string): void { + const safe = sanitize(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, '') + if (!safe) return + const room = MAX_DRAFT_CHARACTERS - this.draft.length + if (room <= 0) return + const inserted = safe.slice(0, room) + this.draft = `${this.draft.slice(0, this.cursor)}${inserted}${this.draft.slice(this.cursor)}` + this.cursor += inserted.length + this.preferredColumn = null + this.historyIndex = this.history.length + } + + private deleteBackward(): void { + if (this.cursor === 0) return + const previous = previousGraphemeIndex(this.draft, this.cursor) + this.draft = `${this.draft.slice(0, previous)}${this.draft.slice(this.cursor)}` + this.cursor = previous + this.preferredColumn = null + this.renderScreen() + } + + private deleteForward(): void { + if (this.cursor >= this.draft.length) return + const next = nextGraphemeIndex(this.draft, this.cursor) + this.draft = `${this.draft.slice(0, this.cursor)}${this.draft.slice(next)}` + this.preferredColumn = null + this.renderScreen() + } + + private moveVertically(direction: -1 | 1): void { + const layout = this.composerDraftLayout() + const targetRow = layout.cursor.row + direction + if (targetRow < 0 || targetRow >= layout.rows.length) { + this.navigateHistory(direction) + return + } + + const desiredColumn = this.preferredColumn ?? layout.cursor.column + this.preferredColumn = desiredColumn + const candidates = layout.points.filter((point) => point.row === targetRow) + const best = candidates.reduce<CursorPoint | null>((current, candidate) => { + if (!current) return candidate + return Math.abs(candidate.column - desiredColumn) < Math.abs(current.column - desiredColumn) + ? candidate + : current + }, null) + if (best) this.cursor = best.index + this.renderScreen() + } + + private navigateHistory(direction: -1 | 1): void { + if (this.history.length === 0) return + if (direction < 0) { + if (this.historyIndex === this.history.length) this.historyDraft = this.draft + if (this.historyIndex === 0) return + this.historyIndex -= 1 + this.draft = this.history[this.historyIndex] ?? '' + } else { + if (this.historyIndex >= this.history.length) return + this.historyIndex += 1 + this.draft = + this.historyIndex === this.history.length + ? this.historyDraft + : (this.history[this.historyIndex] ?? '') + } + this.cursor = this.draft.length + this.preferredColumn = null + this.renderScreen() + } + + private handleTranscriptNavigationKey(key: Key | undefined): boolean { + if (key?.name === 'pageup') { + this.scrollTranscript(-1) + return true + } + if (key?.name === 'pagedown') { + this.scrollTranscript(1) + return true + } + if (key?.ctrl && key.name === 'home') { + this.jumpTranscript('oldest') + return true + } + if (key?.ctrl && key.name === 'end') { + this.jumpTranscript('latest') + return true + } + return false + } + + private scrollTranscript(direction: -1 | 1): void { + const metrics = this.transcriptViewportMetrics() + if (metrics.capacity <= 0 || metrics.maxTop <= 0) { + this.transcriptScrollTopRow = null + return + } + + const page = Math.max(1, metrics.capacity - 1) + const currentTop = this.transcriptScrollTopRow ?? metrics.maxTop + const nextTop = Math.max(0, Math.min(metrics.maxTop, currentTop + direction * page)) + const nextScrollTop = nextTop >= metrics.maxTop ? null : nextTop + if (nextScrollTop === this.transcriptScrollTopRow) return + this.transcriptScrollTopRow = nextScrollTop + this.renderScreen() + } + + private jumpTranscript(destination: 'oldest' | 'latest'): void { + if (destination === 'latest') { + if (this.transcriptScrollTopRow === null) return + this.transcriptScrollTopRow = null + this.renderScreen() + return + } + + const metrics = this.transcriptViewportMetrics() + if (metrics.capacity <= 0 || metrics.maxTop <= 0 || this.transcriptScrollTopRow === 0) return + this.transcriptScrollTopRow = 0 + this.renderScreen() + } + + private transcriptViewportMetrics(): { capacity: number; maxTop: number } { + const rows = this.terminalRows() + const panel = this.buildPanel(rows) + const capacity = Math.max(0, rows - Math.min(rows, panel.lines.length)) + const totalRows = this.wrappedBody(this.panelWidth()).length + return { capacity, maxTop: Math.max(0, totalRows - capacity) } + } + + private ensureViewport(): void { + if (!this.isInteractiveTTY() || this.viewportActive || this.closed) return + const input = this.input as TerminalInput + if (input.setRawMode && !input.isRaw) input.setRawMode(true) + input.resume() + this.viewportActive = true + const rows = this.terminalRows() + const columns = this.terminalColumns() + this.output.write( + `${BEGIN_SYNCHRONIZED_OUTPUT}${ENTER_ALTERNATE_SCREEN}${ENABLE_BRACKETED_PASTE}${HIDE_CURSOR}${CLEAR_SCREEN}${ESC}[H${END_SYNCHRONIZED_OUTPUT}` + ) + this.renderedScreen = Array<string>(rows).fill('') + this.renderedColumns = columns + this.renderedRows = rows + } + + private restoreInputMode(): void { + if (this.restoredRawMode) return + this.restoredRawMode = true + const input = this.input as TerminalInput + if (input.setRawMode && input.isRaw !== this.inputWasRaw) input.setRawMode(this.inputWasRaw) + if (!this.inputWasFlowing) input.pause() + } + + private isInteractiveTTY(): boolean { + return Boolean((this.input as TerminalInput).isTTY && (this.output as TerminalOutput).isTTY) + } + + private terminalColumns(): number { + return Math.max(1, (this.output as TerminalOutput).columns ?? 80) + } + + private terminalRows(): number { + return Math.max(1, (this.output as TerminalOutput).rows ?? 24) + } + + private panelWidth(): number { + return Math.max(0, this.terminalColumns() - 1) + } + + private userPanelDraftLayout( + prompt: string, + highlights: Array<{ start: number; end: number }> = [] + ): DraftLayout { + return layoutDraft( + `${USER_MESSAGE_POINTER}${prompt}${USER_MESSAGE_TEXT}`, + this.draft, + Math.max(1, this.panelWidth() - 3), + this.cursor, + highlights, + { + continuationPrefix: CONTINUATION_PREFIX, + normalTextStyle: USER_MESSAGE_TEXT, + } + ) + } + + private composerDraftLayout(): DraftLayout { + return this.userPanelDraftLayout(this.prompt, this.liveMentionSpans()) + } + + private renderScreen(): void { + if (!this.viewportActive || this.closed) return + const rows = this.terminalRows() + const width = this.panelWidth() + const panel = this.buildPanel(rows) + const panelCapacity = Math.min(rows, panel.lines.length) + const panelFocusRow = panel.focusRow ?? panel.cursor?.row + const panelFirst = + panelFocusRow !== undefined + ? Math.max( + 0, + Math.min( + panel.centerFocus + ? panelFocusRow - Math.floor((panelCapacity - 1) / 2) + : panelFocusRow - panelCapacity + 1, + Math.max(0, panel.lines.length - panelCapacity) + ) + ) + : Math.max(0, panel.lines.length - panelCapacity) + const panelLines = panel.lines + .slice(panelFirst, panelFirst + panelCapacity) + .map((line) => layoutAnsiRows(line, width)[0] ?? '') + const panelTop = rows - panelLines.length + 1 + const transcriptCapacity = Math.max(0, panelTop - 1) + const allTranscriptRows = this.wrappedBody(width) + const maxTranscriptTop = Math.max(0, allTranscriptRows.length - transcriptCapacity) + if (this.transcriptScrollTopRow !== null) { + const clamped = Math.max(0, Math.min(this.transcriptScrollTopRow, maxTranscriptTop)) + this.transcriptScrollTopRow = clamped >= maxTranscriptTop ? null : clamped + } + const transcriptTop = this.transcriptScrollTopRow ?? maxTranscriptTop + const transcriptRows = transcriptCapacity + ? allTranscriptRows.slice(transcriptTop, transcriptTop + transcriptCapacity) + : [] + const screen = Array<string>(rows).fill('') + for (const [index, line] of transcriptRows.entries()) screen[index] = line + for (const [index, line] of panelLines.entries()) screen[panelTop + index - 1] = line + const columns = this.terminalColumns() + const fullRepaint = + !this.renderedScreen || this.renderedColumns !== columns || this.renderedRows !== rows + + let frame = `${BEGIN_SYNCHRONIZED_OUTPUT}${HIDE_CURSOR}${RESET}${RESET_SCROLL_REGION}` + if (fullRepaint) frame += CLEAR_SCREEN + for (const [index, line] of screen.entries()) { + if ((!fullRepaint && line === this.renderedScreen?.[index]) || (fullRepaint && !line)) + continue + frame += `${cursorTo(index + 1, 1)}${ESC}[2K${line}${RESET}` + } + + if (panel.cursor && this.isComposerEditable()) { + const clippedPanelCursorRow = Math.max(0, panel.cursor.row - panelFirst) + frame += `${cursorTo( + Math.min(rows, panelTop + clippedPanelCursorRow), + Math.min(columns, panel.cursor.column) + )}${SHOW_CURSOR}` + } else if (panel.cursor && this.questionState) { + const clippedPanelCursorRow = Math.max(0, panel.cursor.row - panelFirst) + frame += `${cursorTo( + Math.min(rows, panelTop + clippedPanelCursorRow), + Math.min(columns, panel.cursor.column) + )}${SHOW_CURSOR}` + } else { + frame += HIDE_CURSOR + } + frame += END_SYNCHRONIZED_OUTPUT + this.renderedScreen = screen + this.renderedColumns = columns + this.renderedRows = rows + this.output.write(frame) + } + + private buildPanel(rows: number): RenderPanel { + if (this.selectState) return this.buildSelectPanel(rows) + if (this.questionState) return this.buildQuestionPanel(rows) + if (!this.composerVisible) return { lines: [] } + + const layout = this.composerDraftLayout() + const topMargin = rows >= 13 ? [''] : [] + const maxInputRows = Math.max(1, Math.min(6, Math.floor(rows / 3))) + const firstVisible = Math.max( + 0, + Math.min(layout.cursor.row - maxInputRows + 1, layout.rows.length - maxInputRows) + ) + const visibleRows = layout.rows.slice(firstVisible, firstVisible + maxInputRows) + const queuedTurns = this.queued.filter( + ({ input }) => input.kind === 'line' && input.value.trim() + ).length + const queued = queuedTurns > 0 ? `${queuedTurns} queued · ` : '' + const footer = this.busy + ? ` ${queued}enter to steer · esc to interrupt` + : this.pending && !this.draft + ? ' ? for shortcuts' + : '' + const activityStatus = this.activityStatusLine() + const activityRows = activityStatus ? [activityStatus] : [] + const suggestionRows = this.suggestionRows(this.panelWidth(), rows) + /* Keep the suggestion menu visually separate from the activity line. The + composer's shaded top row already separates activity from input. */ + const suggestionGap = suggestionRows.length ? [''] : [] + const composerCursor = { + row: + topMargin.length + + suggestionRows.length + + suggestionGap.length + + activityRows.length + + 1 + + layout.cursor.row - + firstVisible, + column: Math.min(this.panelWidth() + 1, layout.cursor.column + 2), + } + return { + lines: [ + ...topMargin, + ...suggestionRows, + ...suggestionGap, + ...activityRows, + userPanelRow(), + ...visibleRows.map((line) => userPanelRow(line)), + userPanelRow(), + `${DIM}${footer}${RESET}`, + ], + focusRow: composerCursor.row, + centerFocus: true, + cursor: this.isComposerEditable() ? composerCursor : undefined, + } + } + + private buildSelectPanel(rows: number): RenderPanel { + const state = this.selectState + if (!state) return { lines: [] } + + const width = this.panelWidth() + const options = this.filteredSelectOptions() + const capacity = Math.max(0, Math.min(8, rows - 5)) + state.active = Math.max(0, Math.min(state.active, options.length - 1)) + const { start, end } = suggestionWindow(options.length, state.active, capacity) + const visible = options.slice(start, end) + const labelWidth = Math.min( + Math.floor(width * 0.55), + Math.max(0, ...visible.map((option) => displayWidth(option.label))) + 3 + ) + const optionRows = visible.map((option) => { + const active = option.id === options[state.active]?.id + const pointer = active ? '❯' : ' ' + const label = truncateDisplay(option.label, Math.max(1, labelWidth - 3)) + const padding = ' '.repeat(Math.max(2, labelWidth - displayWidth(label) - 1)) + const line = truncateDisplay( + `${pointer} ${label}${padding}${option.description ?? ''}`, + width + ) + return active ? `${BRIGHT_WHITE}${line}${RESET}` : `${DIM}${line}${RESET}` + }) + if (capacity > 0 && optionRows.length === 0) { + optionRows.push(`${DIM} No matching chats${RESET}`) + } + + const layout = this.userPanelDraftLayout('Search › ') + const searchRow = + layout.rows[layout.cursor.row] ?? `${USER_MESSAGE_POINTER}Search › ${USER_MESSAGE_TEXT}` + const header = rows >= 5 ? [`${BOLD}? ${state.menu.prompt}${RESET}`] : [] + const cursor = { + row: header.length + optionRows.length + 1, + column: Math.min(width + 1, layout.cursor.column + 2), + } + return { + lines: [ + ...header, + ...optionRows, + userPanelRow(), + userPanelRow(searchRow), + userPanelRow(), + `${DIM} ↑/↓ navigate · enter open · esc cancel${RESET}`, + ], + focusRow: cursor.row, + cursor, + } + } + + private buildQuestionPanel(rows: number): RenderPanel { + const state = this.questionState + if (!state) return { lines: [] } + const otherIndex = state.question.options.length + const choices: Array<{ line: string; cursorColumn?: number }> = state.question.options.map( + (option, index) => { + const active = state.active === index + const pointer = active ? '❯' : ' ' + const marker = state.question.multi + ? `[${state.selected.has(index) ? '✓' : ' '}]` + : `${index + 1}.` + return { + line: truncateDisplay(`${pointer} ${marker} ${option.label}`, this.panelWidth()), + } + } + ) + + const otherActive = state.active === otherIndex + const otherLead = `${otherActive ? '❯' : ' '} Other › ` + const otherRoom = Math.max(1, this.panelWidth() - displayWidth(otherLead)) + const otherValue = this.draft + ? tailToWidth(this.draft.replace(/\n/gu, ' '), otherRoom) + : `${DIM}Type something…${RESET}` + choices.push({ + line: `${otherLead}${otherValue}`, + cursorColumn: otherActive + ? Math.min( + this.panelWidth() + 1, + this.draft ? displayWidth(`${otherLead}${otherValue}`) + 1 : displayWidth(otherLead) + 1 + ) + : undefined, + }) + if (state.question.multi) { + choices.push({ + line: `${state.active === otherIndex + 1 ? '❯' : ' '} ${DIM}Submit answers${RESET}`, + }) + } + + const maxChoices = Math.max(1, Math.min(choices.length, Math.floor(rows / 2))) + const firstVisible = Math.max( + 0, + Math.min(state.active - maxChoices + 1, choices.length - maxChoices) + ) + const visibleChoices = choices.slice(firstVisible, firstVisible + maxChoices) + const footer = state.question.multi + ? '↑/↓ navigate · Space select · Enter submit · Esc cancel' + : '↑/↓ navigate · Enter select · Esc cancel' + const lines = [ + `${ESC}[1m${truncateDisplay(`? ${state.question.prompt}`, this.panelWidth())}${RESET}`, + ...visibleChoices.map((choice) => choice.line), + `${DIM}${truncateDisplay(footer, this.panelWidth())}${RESET}`, + ] + const activeChoice = choices[state.active] + const focusRow = 1 + state.active - firstVisible + return { + lines, + focusRow, + cursor: + activeChoice?.cursorColumn && state.active >= firstVisible + ? { row: focusRow, column: activeChoice.cursorColumn } + : undefined, + } + } + + private appendTranscript(value: string): void { + this.transcript += value + if (this.transcript.length <= MAX_TRANSCRIPT_CHARACTERS) return + + const preferredCut = this.transcript.length - MAX_TRANSCRIPT_CHARACTERS + const nextLine = this.transcript.indexOf('\n', preferredCut) + if (nextLine >= 0) { + this.transcriptEpoch += 1 + this.transcript = this.transcript.slice(nextLine + 1) + return + } + if (this.transcript.length > MAX_TRANSCRIPT_CHARACTERS * 2) { + this.transcriptEpoch += 1 + this.transcript = `…${sanitize(this.transcript.slice(-MAX_TRANSCRIPT_CHARACTERS))}` + } + } + + /** + * Wrapped rows for the whole viewport body, reusing the rows already computed + * for the immutable part of the transcript. + * + * Everything up to the transcript's last newline can never change, so it is + * wrapped once and kept; only the partial final line is re-wrapped per token. + * That turns an O(transcript) cost per streamed chunk into O(one line). + */ + private wrappedBody(width: number): string[] { + const welcome = this.welcomeVisible ? this.renderWelcome() : '' + const activity = this.activityEventsDisplay() + const text = this.transcript + const boundary = text.lastIndexOf('\n') + 1 + + let cache = this.wrapCache + if ( + !cache || + cache.width !== width || + cache.epoch !== this.transcriptEpoch || + cache.consumed > boundary + ) { + cache = { + width, + epoch: this.transcriptEpoch, + consumed: 0, + rows: [], + state: { sgr: '', userBackground: false }, + } + } + if (cache.consumed < boundary) { + const state: WrapState = { ...cache.state } + const added = layoutAnsiRows(text.slice(cache.consumed, boundary), width, state) + cache = { + width, + epoch: this.transcriptEpoch, + consumed: boundary, + rows: cache.rows.concat(added), + state, + } + } + this.wrapCache = cache + + /* The welcome block always ends with a reset and a blank line, so it cannot + leak style into the transcript and is wrapped independently. */ + const rows = welcome ? layoutAnsiRows(welcome, width) : [] + rows.push(...cache.rows) + const tail = text.slice(cache.consumed) + if (tail) rows.push(...layoutAnsiRows(tail, width, { ...cache.state })) + if (activity) rows.push(...layoutAnsiRows(activity, width)) + return rows + } + + private commitUserLine(value: string): void { + if (!this.isInteractiveTTY()) return + this.transcriptScrollTopRow = null + this.assistantPrefixPending = true + this.assistantPrefixBuffer = '' + this.assistantTurnActive = false + this.assistantContinuationPending = false + if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') + if (this.transcript && !this.transcript.endsWith('\n\n')) this.appendTranscript('\n') + + this.appendTranscript(`${userPanelRow()}\n`) + const lines = sanitize(value).replace(/\t/gu, CONTINUATION_PREFIX).split('\n') + for (const [index, line] of lines.entries()) { + const pointer = + index === 0 + ? `${USER_MESSAGE_POINTER}❯ ${USER_MESSAGE_TEXT}` + : `${USER_MESSAGE_TEXT}${CONTINUATION_PREFIX}` + this.appendTranscript(`${userPanelRow(`${pointer}${line}`)}\n`) + } + this.appendTranscript(`${userPanelRow()}\n`) + this.appendTranscript('\n') + } + + private renderWelcome(): string { + const width = this.panelWidth() + const chat = `chat ${this.welcomeChatTitle}` + const artWidth = Math.max(...BLIMP_ART.map(displayWidth)) + const progress = this.welcomeRevealFrame / WELCOME_FLY_IN_FRAMES + const eased = progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2 + const trailing = Math.max(0, artWidth - Math.round(artWidth * eased)) + const art = BLIMP_ART.map((line) => `${line}${artPad(line, artWidth)}`.slice(trailing)) + const lead = ' '.repeat(trailing) + + const boxColumns = width - artWidth - WELCOME_GUTTER + if (boxColumns >= WELCOME_MIN_BOX_COLUMNS) { + const rows = this.welcomeDetailBox(boxColumns) + const gutter = ' '.repeat(WELCOME_GUTTER) + const lines: string[] = [] + /* Centre the shorter column against the taller one. Top-aligning leaves + the airship and the box visibly out of register whenever they differ + in height, which they usually do. */ + const height = Math.max(art.length, rows.length) + const artTop = Math.round((height - art.length) / 2) + const boxTop = Math.round((height - rows.length) / 2) + for (let index = 0; index < height; index++) { + const line = art[index - artTop] + const column = + line === undefined ? ' '.repeat(artWidth) : `${BRIGHT_WHITE}${line}${RESET}${lead}` + lines.push(`${column}${gutter}${rows[index - boxTop] ?? ''}`.trimEnd()) + } + return `${lines.join('\n')}\n\n` + } + + if (width >= artWidth) { + const title = `${BOLD}${truncateDisplay('Sim Chat', width)}${RESET}` + const scope = `${DIM}${truncateDisplay(chat, width)}${RESET}` + const rendered = art + .map((line) => `${BRIGHT_WHITE}${truncateDisplay(line.trimEnd(), width)}${RESET}`) + .join('\n') + return `${rendered}\n${title}\n${scope}\n\n` + } + + return `${BOLD}${truncateDisplay('Sim Chat', width)}${RESET}\n${DIM}${truncateDisplay( + chat, + width + )}${RESET}\n\n` + } + + noteAttachment(): void { + const token = `[Image #${this.nextAttachmentNumber++}]` + const before = this.draft.slice(0, this.cursor) + const separator = !before || /\s$/u.test(before) ? '' : ' ' + this.insertText(`${separator}${token} `) + this.renderScreen() + } + + setWorkspaceName(name: string): void { + const next = safeOneLine(name).slice(0, 80) + if (!next || next === this.welcomeWorkspaceName) return + this.welcomeWorkspaceName = next + if (!this.closed) this.renderScreen() + } + + setChatTitle(title: string): void { + const next = safeOneLine(title).slice(0, 160) + if (!next || next === this.welcomeChatTitle) return + this.welcomeChatTitle = next + if (this.welcomeVisible && !this.closed) this.renderScreen() + } + + /** Rounded detail box drawn to the right of the art, with aligned labels. */ + private welcomeDetailBox(columns: number): string[] { + const details: Array<[string, string]> = [ + ['profile', this.welcomeProfile ?? 'default'], + ...(this.welcomeWorkspaceName + ? ([['workspace', this.welcomeWorkspaceName]] as Array<[string, string]>) + : []), + ['chat', this.welcomeChatTitle], + ] + const labelWidth = Math.max(...details.map(([label]) => label.length)) + 2 + const content = [ + { text: 'Sim Chat', style: BOLD }, + ...details.map(([label, value]) => ({ + text: `${`${label}:`.padEnd(labelWidth)}${value}`, + style: DIM, + })), + ] + const widest = Math.max(...content.map((entry) => displayWidth(entry.text))) + const inner = Math.max(1, Math.min(columns - 4, widest)) + const rule = '\u2500'.repeat(inner + 2) + const rows = [`${DIM}\u256d${rule}\u256e${RESET}`] + for (const { text, style } of content) { + const clipped = truncateDisplay(text, inner) + const padding = ' '.repeat(Math.max(0, inner - displayWidth(clipped))) + const painted = style ? `${style}${clipped}${RESET}` : clipped + rows.push(`${DIM}\u2502${RESET} ${painted}${padding} ${DIM}\u2502${RESET}`) + } + rows.push(`${DIM}\u2570${rule}\u256f${RESET}`) + return rows + } + + /** + * Slides the airship in from the left edge, repainting on a timer. Skipped for + * non-interactive output and under CI/test runners, where a partially drawn + * frame would make the header nondeterministic. + */ + private startWelcomeFlyIn(): void { + this.stopWelcomeFlyIn() + if (!this.isInteractiveTTY()) return + if (process.env.CI || process.env.VITEST) return + this.welcomeRevealFrame = 0 + this.welcomeTimer = setInterval(() => { + this.welcomeRevealFrame += 1 + if (this.welcomeRevealFrame >= WELCOME_FLY_IN_FRAMES) this.stopWelcomeFlyIn() + this.renderScreen() + }, WELCOME_FLY_IN_INTERVAL_MS) + this.welcomeTimer.unref() + } + + private stopWelcomeFlyIn(): void { + if (this.welcomeTimer) clearInterval(this.welcomeTimer) + this.welcomeTimer = null + this.welcomeRevealFrame = WELCOME_FLY_IN_FRAMES + } + + private activityEventsDisplay(): string { + if (!this.activityActive) return '' + const lines: string[] = [] + for (const id of this.activityRoots) { + if (this.committedActivityRoots.has(id)) continue + const node = this.activityNodes.get(id) + if (node) lines.push(...this.activityNodeLines(node, true)) + } + return lines.join('\n') + } + + private activityStatusLine(): string { + if (!this.activityActive) return '' + const pulseFrames = ['·', '•', '●', '•'] + const pulse = pulseFrames[this.activityFrame % pulseFrames.length] + const label = tailToWidth( + safeOneLine(this.activityThinking) || 'Thinking…', + Math.max(1, this.panelWidth() - 2) + ) + return `${DIM}${ESC}[3m${pulse} ${label}${RESET}` + } + + private activityEventLine(event: ChatActivityStatusUpdate, live: boolean, depth = 0): string { + const icon = + event.state === 'complete' + ? `${ESC}[32m●${RESET}` + : event.state === 'error' + ? `${ESC}[31m●${RESET}` + : `${DIM}●${RESET}` + const indent = CONTINUATION_PREFIX.repeat(depth) + const label = live + ? truncateDisplay(event.label, Math.max(1, this.panelWidth() - displayWidth(indent) - 8)) + : event.label + // A subagent's public label is its stable lane header. Its dot carries the + // state, while tool labels may use the familiar live/error suffixes. + const suffix = event.kind === 'tool' && live && event.state === 'running' ? '…' : '' + const failed = event.kind === 'tool' && event.state === 'error' ? ` ${DIM}failed${RESET}` : '' + return `${indent}${icon} ${label}${suffix}${failed}` + } + + private recordActivityEvent(update: ChatActivityUpdate): void { + if (update.kind === 'narration') { + const parentId = safeOneLine(update.parentId).slice(0, 160) + const parent = this.activityNodes.get(parentId) + if (!parent || parent.kind !== 'subagent') return + const delta = update.delta.replace(/\r/gu, '') + if (!delta) return + const last = parent.children[parent.children.length - 1] + if (last?.kind === 'narration') last.content += delta + else parent.children.push({ kind: 'narration', content: delta }) + return + } + + const id = safeOneLine(update.id).slice(0, 160) + const label = safeOneLine(update.label).slice(0, 160) + if (!id || !label) return + const parentId = update.parentId ? safeOneLine(update.parentId).slice(0, 160) : undefined + const safeParentId = parentId && parentId !== id ? parentId : undefined + const existing = this.activityNodes.get(id) + const node: ActivityTreeNode = { + kind: update.kind, + id, + label, + state: update.state, + ...(safeParentId ? { parentId: safeParentId } : {}), + children: existing?.children ?? [], + } + this.activityNodes.set(id, node) + if (!existing || existing.parentId !== node.parentId) this.attachActivityNode(node) + + if (node.kind === 'subagent') { + for (const child of this.activityNodes.values()) { + if (child.parentId === node.id) this.attachActivityNode(child) + } + } + } + + private commitActivityEvents(includeRunning: boolean): void { + if (!this.isInteractiveTTY()) return + for (const id of this.activityRoots) { + if (this.committedActivityRoots.has(id)) continue + const node = this.activityNodes.get(id) + if (!node || (!includeRunning && !this.activityNodeSettled(node))) continue + this.commitActivityRoot(node) + } + } + + private commitActivityRoot(node: ActivityTreeNode): void { + if (!this.isInteractiveTTY() || this.committedActivityRoots.has(node.id)) return + this.committedActivityRoots.add(node.id) + const lines = this.activityNodeLines(node, false) + if (lines.length === 0) return + if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') + this.appendTranscript(`${lines.join('\n')}\n`) + } + + private attachActivityNode(node: ActivityTreeNode): void { + const rootIndex = this.activityRoots.indexOf(node.id) + if (rootIndex >= 0) this.activityRoots.splice(rootIndex, 1) + for (const candidate of this.activityNodes.values()) { + if (candidate.kind !== 'subagent') continue + candidate.children = candidate.children.filter( + (child) => child.kind !== 'node' || child.id !== node.id + ) + } + + if (node.parentId) { + const parent = this.activityNodes.get(node.parentId) + if (parent?.kind === 'subagent') parent.children.push({ kind: 'node', id: node.id }) + return + } + this.activityRoots.push(node.id) + } + + private activityNodeSettled(node: ActivityTreeNode, seen = new Set<string>()): boolean { + if (node.state === 'running' || seen.has(node.id)) return false + seen.add(node.id) + for (const child of node.children) { + if (child.kind !== 'node') continue + const nested = this.activityNodes.get(child.id) + if (nested && !this.activityNodeSettled(nested, seen)) return false + } + return true + } + + private activityNodeLines( + node: ActivityTreeNode, + live: boolean, + depth = 0, + seen = new Set<string>() + ): string[] { + if (seen.has(node.id)) return [] + seen.add(node.id) + + const children: string[] = [] + for (const child of node.children) { + if (child.kind === 'node') { + const nested = this.activityNodes.get(child.id) + if (nested) children.push(...this.activityNodeLines(nested, live, depth + 1, seen)) + continue + } + if (!child.content.trim()) continue + const indent = CONTINUATION_PREFIX.repeat(depth + 1) + // Only trim to decide whether the lane has visible work. The original + // text (including leading/trailing blank lines) is the ordered stream. + for (const line of child.content.split('\n')) { + children.push(`${indent}${DIM}${line}${RESET}`) + } + } + + // Match the web lane projection: a closed lane with no visible work leaves + // no orphan header, while an open empty lane still explains what is running. + if (node.kind === 'subagent' && node.state !== 'running' && children.length === 0) return [] + return [this.activityEventLine(node, live, depth), ...children] + } + + private stopActivity(completed = false): void { + if (this.activityTimer) clearInterval(this.activityTimer) + this.activityTimer = null + if (this.activityActive) { + this.commitActivityEvents(true) + if (completed) { + if (this.transcript && !this.transcript.endsWith('\n')) this.appendTranscript('\n') + if (this.transcript && !this.transcript.endsWith('\n\n')) this.appendTranscript('\n') + this.appendTranscript( + `${DIM}✻ Worked for ${formatActivityDuration(Date.now() - this.activityStartedAt)}${RESET}\n` + ) + } + } + this.activityActive = false + this.activityThinking = '' + this.activityStartedAt = 0 + this.activityNodes.clear() + this.activityRoots.length = 0 + this.committedActivityRoots.clear() + this.assistantTurnActive = false + this.assistantContinuationPending = false + this.busy = false + this.renderScreen() + } +} + +function prefixAssistantTurn(value: string): string | null { + let offset = 0 + let leadingSgr = '' + while (offset < value.length) { + if (value[offset] === ESC) { + const sgr = value.slice(offset).match(/^\u001b\[[0-9;:]*m/u)?.[0] + if (sgr) { + leadingSgr += sgr + offset += sgr.length + continue + } + } + + const part = firstGrapheme(value.slice(offset)) + if (!part) break + if (!/^\p{White_Space}+$/u.test(part)) { + return `${ASSISTANT_TURN_PREFIX}${indentAssistantFragment( + `${leadingSgr}${value.slice(offset)}`, + false + )}` + } + offset += part.length + } + return null +} + +/** Materializes the assistant gutter on explicit line breaks across streamed chunks. */ +function indentAssistantFragment(value: string, continuationPending: boolean): string { + const prefixed = continuationPending ? `${CONTINUATION_PREFIX}${value}` : value + return prefixed.replace(/\n(?=.)/gu, `\n${CONTINUATION_PREFIX}`) +} + +interface WrapState { + sgr: string + userBackground: boolean +} + +/** + * `carry` resumes the state a previous call ended in, and receives the state + * this call ends in — the two things that survive a row break. Threading them + * explicitly is what makes it safe to wrap a transcript in pieces. + */ +function layoutAnsiRows(value: string, width: number, carry?: WrapState): string[] { + if (!value || width <= 0) return [] + + type LayoutToken = + | { kind: 'sgr'; value: string } + | { kind: 'grapheme'; value: string; width: number } + + const rows: string[] = [] + /* Resumed styling must reopen on the first row, exactly as finishRow() + reopens it on every subsequent row. */ + let row = carry?.userBackground ? `${USER_PANEL_OUTER_MARGIN}${carry.sgr}` : (carry?.sgr ?? '') + let column = carry?.userBackground ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 + let activeSgr = carry?.sgr ?? '' + let userBackgroundActive = carry?.userBackground ?? false + let hangingIndent = 0 + let logicalLinePrefix = '' + let logicalLinePrefixRejected = false + let pendingWord: LayoutToken[] = [] + let pendingWordWidth = 0 + + const contentWidth = (): number => (userBackgroundActive && width > 2 ? width - 2 : width) + + const fillUserMessageRow = (): void => { + if (!userBackgroundActive) return + const target = width > 1 ? width - 1 : width + if (column >= target) return + row += ' '.repeat(target - column) + column = target + } + + const finishRow = (continueLogicalLine = true): void => { + fillUserMessageRow() + rows.push(row) + const outerMargin = userBackgroundActive ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 + const continuationIndent = continueLogicalLine + ? Math.min(hangingIndent, Math.max(0, contentWidth() - 1)) + : 0 + if (continuationIndent > 0) { + const indent = ' '.repeat(Math.max(0, continuationIndent - outerMargin)) + row = userBackgroundActive + ? `${USER_PANEL_OUTER_MARGIN}${activeSgr}${indent}` + : `${indent}${activeSgr}` + } else { + row = userBackgroundActive ? `${USER_PANEL_OUTER_MARGIN}${activeSgr}` : activeSgr + } + column = Math.max(continuationIndent, outerMargin) + if (!continueLogicalLine) { + hangingIndent = 0 + logicalLinePrefix = '' + logicalLinePrefixRejected = false + } + } + + const observeLogicalLinePrefix = (segment: string, segmentWidth: number): void => { + if (logicalLinePrefixRejected) return + if (segmentWidth !== 1) { + logicalLinePrefixRejected = true + return + } + + // Activity trees can be nested more deeply than the assistant's two-column + // gutter. Preserve every explicit leading space on soft wraps so a nested + // tool or narration row never jumps back toward its parent. + if (segment === ' ' && /^ *$/u.test(logicalLinePrefix)) { + logicalLinePrefix += segment + hangingIndent = displayWidth(logicalLinePrefix) + return + } + + const candidate = `${logicalLinePrefix}${segment}` + const knownPrefix = [ASSISTANT_TURN_PREFIX, USER_TURN_PREFIX].find((prefix) => + prefix.startsWith(candidate) + ) + if (knownPrefix) { + logicalLinePrefix = candidate + if (candidate === knownPrefix) hangingIndent = displayWidth(knownPrefix) + return + } + logicalLinePrefixRejected = true + } + + const appendVisible = (segment: string): void => { + if (segment === '\t') { + const spaces = Math.max(1, 8 - (column % 8)) + for (let index = 0; index < spaces; index += 1) appendVisible(' ') + return + } + if (/[\u0000-\u001f\u007f-\u009f]/u.test(segment)) return + + const segmentWidth = graphemeWidth(segment) + observeLogicalLinePrefix(segment, segmentWidth) + const availableWidth = contentWidth() + if (segmentWidth > availableWidth) { + if (column > 0) finishRow() + row += '…' + column = 1 + return + } + if (column > 0 && column + segmentWidth > availableWidth) finishRow() + row += segment + column += segmentWidth + } + + const appendToken = (token: LayoutToken): void => { + if (token.kind === 'sgr') { + if (userBackgroundActive && token.value === RESET) fillUserMessageRow() + row += token.value + activeSgr = updateActiveSgr(activeSgr, token.value) + if (token.value === USER_MESSAGE_BACKGROUND) userBackgroundActive = true + else if (token.value === RESET) userBackgroundActive = false + return + } + appendVisible(token.value) + } + + const flushWord = (): void => { + if (pendingWord.length === 0) return + + const availableWidth = contentWidth() + const outerMargin = userBackgroundActive ? displayWidth(USER_PANEL_OUTER_MARGIN) : 0 + const continuationIndent = Math.min(hangingIndent, Math.max(0, availableWidth - 1)) + const freshColumn = Math.max(continuationIndent, outerMargin) + + /** + * Matches Ink's default wrap behavior: ordinary words move intact when they fit on a fresh + * row, while overlong tokens hard-wrap through the remaining space. Styling tokens flush with + * their word so absolute continuation rows can safely reopen the active SGR state. + */ + if ( + pendingWordWidth > 0 && + freshColumn + pendingWordWidth <= availableWidth && + column > freshColumn && + column + pendingWordWidth > availableWidth + ) { + finishRow() + } + + for (const token of pendingWord) appendToken(token) + pendingWord = [] + pendingWordWidth = 0 + } + + const bufferWordToken = (token: LayoutToken): void => { + pendingWord.push(token) + if (token.kind === 'grapheme') pendingWordWidth += token.width + } + + let offset = 0 + while (offset < value.length) { + if (value[offset] === ESC) { + const match = value.slice(offset).match(/^\u001b\[[0-9;:]*m/u) + if (match) { + const sequence = match[0] + bufferWordToken({ kind: 'sgr', value: sequence }) + offset += sequence.length + continue + } + offset += 1 + continue + } + if (value[offset] === '\n') { + flushWord() + finishRow(false) + offset += 1 + continue + } + + const nextControl = [value.indexOf(ESC, offset), value.indexOf('\n', offset)] + .filter((index) => index >= 0) + .reduce((closest, index) => Math.min(closest, index), value.length) + const text = value.slice(offset, nextControl) + for (const part of graphemes(text)) { + const breakableWhitespace = + part.segment !== '\u00a0' && + part.segment !== '\u202f' && + /^\p{White_Space}+$/u.test(part.segment) + if (breakableWhitespace) { + flushWord() + appendVisible(part.segment) + } else { + bufferWordToken({ + kind: 'grapheme', + value: part.segment, + width: graphemeWidth(part.segment), + }) + } + } + offset = nextControl + } + + flushWord() + fillUserMessageRow() + rows.push(row) + if (value.endsWith('\n')) rows.pop() + if (carry) { + carry.sgr = activeSgr + carry.userBackground = userBackgroundActive + } + return rows +} + +function updateActiveSgr(active: string, sequence: string): string { + const rawParameters = sequence.slice(2, -1) + const parameters = rawParameters ? rawParameters.split(';') : ['0'] + let lastReset = -1 + for (let index = 0; index < parameters.length; index += 1) { + const parameter = parameters[index] ?? '' + const code = Number(parameter.split(':', 1)[0]) + if (code === 0) lastReset = index + if ((code === 38 || code === 48 || code === 58) && !parameter.includes(':')) { + const mode = Number(parameters[index + 1]) + if (mode === 2) index += 4 + else if (mode === 5) index += 2 + } + } + if (lastReset < 0) return `${active}${sequence}` + + const remaining = parameters.slice(lastReset + 1) + return remaining.length > 0 ? `${ESC}[${remaining.join(';')}m` : '' +} + +function cursorTo(row: number, column: number): string { + return `${ESC}[${Math.max(1, row)};${Math.max(1, column)}H` +} + +/** + * Spans of `[Image #N]` tags, so a pasted attachment reads as a tag rather than + * loose text. Derived per render like context spans, so deleting the tag stops + * the highlight with no bookkeeping. + */ +const ATTACHMENT_TOKEN = /\[Image #\d+\]/gu + +function attachmentSpans(text: string): Array<{ start: number; end: number }> { + return [...text.matchAll(ATTACHMENT_TOKEN)].map((match) => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + })) +} + +function isEnter(key: Key | undefined): boolean { + return key?.name === 'return' || key?.name === 'enter' +} + +function printableText(character: string, key: Key | undefined): string { + if (!character || key?.ctrl || key?.meta) return '' + if (key?.name === 'return' || key?.name === 'enter' || key?.name === 'tab') return '' + return sanitize(character).replace(/[\u0000-\u001f\u007f]/gu, '') +} + +/** Normalizes server-provided menu text before it can enter the terminal draft or renderer. */ +function sanitizeSuggestionItem(item: SuggestionItem): SuggestionItem | null { + const value = safeOneLine(item.value).slice(0, 255) + const displayText = safeOneLine(item.displayText).slice(0, 255) + if (!value || !displayText) return null + + const description = item.description ? safeOneLine(item.description).slice(0, 500) : undefined + const sanitized = { + ...item, + value, + displayText, + ...(description ? { description } : {}), + } + if (!item.context) return sanitized + + const contextLabel = safeOneLine(item.context.label).slice(0, 255) + if (!contextLabel) return null + return { ...sanitized, context: { ...item.context, label: contextLabel } } +} + +function layoutDraft( + prompt: string, + draft: string, + width: number, + cursor: number, + highlights: Array<{ start: number; end: number }> = [], + options: DraftLayoutOptions = {} +): DraftLayout { + const continuationPrefix = options.continuationPrefix ?? CONTINUATION_PREFIX + const normalTextStyle = options.normalTextStyle ?? RESET + const rows = [prompt] + const points: CursorPoint[] = [{ index: 0, row: 0, column: displayWidth(prompt) }] + let row = 0 + let column = displayWidth(prompt) + let styled = false + + const setPoint = (index: number): void => { + const previous = points.at(-1) + if (previous?.index === index) { + previous.row = row + previous.column = column + } else { + points.push({ index, row, column }) + } + } + + for (const part of graphemes(draft)) { + setPoint(part.index) + const end = part.index + part.segment.length + if (part.segment === '\n') { + if (styled) rows[row] += normalTextStyle + row += 1 + column = displayWidth(continuationPrefix) + rows.push( + styled + ? `${continuationPrefix}${MENTION_TEXT}` + : `${continuationPrefix}${options.normalTextStyle ?? ''}` + ) + setPoint(end) + continue + } + + const segmentWidth = displayWidth(part.segment) + if (column + segmentWidth > width && column > displayWidth(continuationPrefix)) { + if (styled) rows[row] += normalTextStyle + row += 1 + column = displayWidth(continuationPrefix) + rows.push( + styled + ? `${continuationPrefix}${MENTION_TEXT}` + : `${continuationPrefix}${options.normalTextStyle ?? ''}` + ) + setPoint(part.index) + } + /* ANSI has zero display width, so styling here cannot disturb the wrap or + cursor arithmetic above. Runs are coalesced rather than wrapping every + grapheme, and closed/reopened around a row break so no style leaks. */ + const lit = highlights.some((span) => part.index >= span.start && part.index < span.end) + if (lit && !styled) { + rows[row] += MENTION_TEXT + styled = true + } else if (!lit && styled) { + rows[row] += normalTextStyle + styled = false + } + rows[row] += part.segment + column += segmentWidth + setPoint(end) + } + + if (styled) rows[row] += normalTextStyle + + const fallback = points.at(-1) ?? { index: 0, row: 0, column: displayWidth(prompt) } + const cursorPoint = points.find((point) => point.index === cursor) ?? fallback + return { rows, points, cursor: cursorPoint } +} diff --git a/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts b/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts new file mode 100644 index 00000000000..92de4c1af5d --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-wrap.test.ts @@ -0,0 +1,81 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { ReadlineChatTerminal } from './chat-terminal.js' + +const ESC = String.fromCharCode(27) + +function harness(columns = 60) { + const input = new PassThrough() as PassThrough & { isTTY: boolean; setRawMode?: () => void } + const output = new PassThrough() as PassThrough & { + isTTY: boolean + columns: number + rows: number + } + input.isTTY = true + input.setRawMode = () => {} + output.isTTY = true + output.columns = columns + output.rows = 30 + output.on('data', () => {}) + const terminal = new ReadlineChatTerminal(input as never, output as never) + return { + terminal, + probe: terminal as never as { + wrappedBody(width: number): string[] + panelWidth(): number + wrapCache: unknown + }, + } +} + +/** + * The cached path must be byte-identical to wrapping the concatenated body in + * one pass — otherwise a streamed frame would differ from a repainted one. + */ +describe('incremental transcript wrapping', () => { + const cases: Array<[string, string[]]> = [ + ['plain lines', ['hello world\n', 'second line\n']], + ['partial final line', ['complete\n', 'partial without newline']], + ['long wrapping line', [`${'word '.repeat(40)}\n`]], + ['styled text', [`${ESC}[1mbold${ESC}[0m plain\n`, `${ESC}[31mred\n`, `still red${ESC}[0m\n`]], + ['blank lines', ['a\n', '\n', '\n', 'b\n']], + ['token by token', ['no newline yet', ' more', ' and more', '\n', 'next\n']], + ['unicode', ['héllo wörld ☃\n', '日本語のテキスト\n']], + ] + + for (const [name, chunks] of cases) { + it(`matches a single-pass wrap: ${name}`, () => { + const { terminal, probe } = harness() + const oneShot = harness() + for (const chunk of chunks) { + terminal.write(chunk) + oneShot.terminal.write(chunk) + oneShot.probe.wrapCache = null + const width = probe.panelWidth() + expect(probe.wrappedBody(width)).toEqual(oneShot.probe.wrappedBody(width)) + } + terminal.close() + oneShot.terminal.close() + }) + } + + it('rebuilds when the width changes', () => { + const { terminal, probe } = harness() + terminal.write(`${'alpha beta '.repeat(20)}\n`) + const narrow = probe.wrappedBody(40) + const wide = probe.wrappedBody(100) + expect(narrow).not.toEqual(wide) + expect(probe.wrappedBody(40)).toEqual(narrow) + terminal.close() + }) + + it('stays correct after the transcript is trimmed from the front', () => { + const { terminal, probe } = harness() + for (let i = 0; i < 400; i++) terminal.write(`line ${i} ${'x'.repeat(200)}\n`) + const width = probe.panelWidth() + const cached = probe.wrappedBody(width) + probe.wrapCache = null + expect(cached).toEqual(probe.wrappedBody(width)) + terminal.close() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts new file mode 100644 index 00000000000..b01fc1d2db3 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -0,0 +1,2646 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../../http/client.js' +import { + type ChatDependencies, + chatCommand, + composeChatPrompt, + readChatResponse, + readChatTurn, +} from './chat.js' +import type { ChatAttachment } from './chat-attachments.js' +import type { ChatContext, ChatSuggestionCandidates } from './chat-suggestions.js' +import type { + ChatActivity, + ChatActivityUpdate, + ChatTerminal, + ChatTerminalInput, + ChatTerminalInterruptListener, + ChatTerminalInterruptReason, + ChatTerminalQuestion, + ChatTerminalQuestionResult, + ChatTerminalSelect, + ChatTerminalSelectResult, + ChatTerminalWelcome, +} from './chat-terminal.js' + +const mocks = vi.hoisted(() => ({ + request: vi.fn(), + requestRaw: vi.fn(), + requireWorkspace: vi.fn(() => 'ws_local'), +})) + +vi.mock('../../context.js', () => ({ + clientFrom: () => ({ client: mocks, profile: { endpoint: 'https://sim.example' } }), +})) + +beforeEach(() => { + mocks.request.mockReset().mockResolvedValue({ data: [], nextCursor: null }) + mocks.requestRaw.mockReset() + mocks.requireWorkspace.mockClear() +}) + +function sse(chunks: string[]): Response { + const body = new ReadableStream<Uint8Array>({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)) + controller.close() + }, + }) + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) +} + +function completed(content: string, token = 'continuation-1', deltas: string[] = []): Response { + return sse([ + `event: session\ndata: ${JSON.stringify({ + type: 'session', + continuationToken: token, + requestId: 'req_1', + })}\n\n`, + ...deltas.map((delta) => `event: text\ndata: ${JSON.stringify({ type: 'text', delta })}\n\n`), + `event: complete\ndata: ${JSON.stringify({ + type: 'complete', + data: { content, continuationToken: token }, + })}\n\n`, + 'data: [DONE]\n\n', + ]) +} + +function openSse(chunk: string): { response: Response; cancel: ReturnType<typeof vi.fn> } { + const cancel = vi.fn() + const body = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode(chunk)) + }, + cancel, + }) + return { + response: new Response(body, { headers: { 'content-type': 'text/event-stream' } }), + cancel, + } +} + +function program( + readInput: () => Promise<string>, + writeOutput = vi.fn(), + overrides: Partial<ChatDependencies> = {} +): Command { + const root = new Command('sim').exitOverride() + root.option('-P, --profile <name>') + root.addCommand( + chatCommand({ + readInput, + writeOutput, + isInteractive: () => false, + ...overrides, + }) + ) + return root +} + +class FakeTerminal implements ChatTerminal { + readonly welcomes: string[] = [] + readonly workspaceNames: string[] = [] + attachmentNotes = 0 + readonly chatTitles: string[] = [] + readonly userMessages: string[] = [] + readonly statuses: string[] = [] + readonly thinking: string[] = [] + readonly activities: ChatActivityUpdate[] = [] + readonly questions: ChatTerminalQuestion[] = [] + readonly selections: ChatTerminalSelect[] = [] + readonly reads: Array<{ prompt: string; initialValue: string }> = [] + readonly preloads: Array<{ + value: string + queued: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + }> = [] + readonly writes: string[] = [] + readonly suggestionUpdates: ChatSuggestionCandidates[] = [] + suggestionCandidates: ChatSuggestionCandidates | null = null + clearedTranscripts = 0 + readonly listeners = new Set<ChatTerminalInterruptListener>() + closed = false + private stagedPreload = '' + + constructor( + readonly inputs: ChatTerminalInput[], + readonly questionResults: ChatTerminalQuestionResult[] = [], + readonly selectionResults: ChatTerminalSelectResult[] = [] + ) {} + + welcome({ chatTitle }: ChatTerminalWelcome): void { + this.welcomes.push(chatTitle) + } + + setChatTitle(title: string): void { + this.chatTitles.push(title) + } + + setWorkspaceName(name: string): void { + this.workspaceNames.push(name) + } + + noteAttachment(): void { + this.attachmentNotes += 1 + } + + userMessage(message: string): void { + this.userMessages.push(message) + } + + clearTranscript(): void { + this.clearedTranscripts += 1 + } + + read(prompt: string): Promise<ChatTerminalInput> { + this.reads.push({ prompt, initialValue: this.stagedPreload }) + this.stagedPreload = '' + return Promise.resolve(this.inputs.shift() ?? { kind: 'eof' }) + } + + hasQueuedInput(): boolean { + return ( + Boolean(this.stagedPreload) || + this.inputs.some((input) => input.kind === 'line' && input.queued === true) + ) + } + + preload( + value: string, + options: { + queued?: boolean + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } = {} + ): boolean { + this.preloads.push({ + value, + queued: options.queued === true, + ...(options.pastes ? { pastes: options.pastes } : {}), + ...(options.contexts ? { contexts: options.contexts } : {}), + }) + this.stagedPreload = value + return true + } + + setSuggestionCandidates(candidates: ChatSuggestionCandidates): void { + this.suggestionCandidates = candidates + this.suggestionUpdates.push(candidates) + } + + status(message: string): void { + this.statuses.push(message) + } + + write(content: string): void { + this.writes.push(content) + } + + activity(_message: string): ChatActivity { + return { + update: () => {}, + thinking: (delta) => this.thinking.push(delta), + event: (update) => this.activities.push(update), + clear: () => {}, + complete: () => {}, + stop: () => {}, + } + } + + askQuestion(question: ChatTerminalQuestion): Promise<ChatTerminalQuestionResult> { + this.questions.push(question) + return Promise.resolve(this.questionResults.shift() ?? { kind: 'cancel' }) + } + + select(menu: ChatTerminalSelect): Promise<ChatTerminalSelectResult> { + this.selections.push(menu) + return Promise.resolve(this.selectionResults.shift() ?? { kind: 'cancel' }) + } + + onInterrupt(listener: ChatTerminalInterruptListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + interrupt(reason: ChatTerminalInterruptReason = 'manual', input?: ChatTerminalInput): void { + const submitted = + input ?? + (reason === 'submit' ? this.inputs.find((entry) => entry.kind === 'line') : undefined) + for (const listener of this.listeners) listener(reason, submitted) + } + + close(): void { + this.closed = true + } +} + +describe('chat print mode', () => { + it('posts to the selected workspace and prints only the completed answer', async () => { + const wire = [ + ': keepalive\n\n', + `event: session\ndata: ${JSON.stringify({ + type: 'session', + continuationToken: 'opaque-token', + requestId: 'req_1', + })}\n\n`, + `event: text\ndata: ${JSON.stringify({ type: 'text', delta: 'Hello ' })}\n\n`, + `event: text\ndata: ${JSON.stringify({ type: 'text', delta: 'world' })}\n\n`, + `event: complete\ndata: ${JSON.stringify({ + type: 'complete', + data: { content: 'Hello world', continuationToken: 'opaque-token' }, + })}\n\n`, + 'data: [DONE]\n\n', + ].join('') + mocks.requestRaw.mockResolvedValue( + sse([wire.slice(0, 41), wire.slice(41, 137), wire.slice(137)]) + ) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + 'What', + 'is', + 'here?', + ]) + + expect(mocks.requireWorkspace).toHaveBeenCalledWith(undefined, { auth: 'optional' }) + expect(mocks.requestRaw).toHaveBeenCalledWith('/api/v2/chat', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body: { workspaceId: 'ws_local', prompt: 'What is here?' }, + signal: expect.any(AbortSignal), + auth: 'optional', + }) + expect(writeOutput).toHaveBeenCalledOnce() + expect(writeOutput).toHaveBeenCalledWith('Hello world') + }) + + it('keeps the profile shorthand distinct from chat -p', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => '').parseAsync(['node', 'sim', '-P', 'dev', 'chat', '-p', 'question']) + + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'question', + }) + }) + + it('opts into query-only chat only when --read-only is passed', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => '').parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--read-only', + 'question', + ]) + + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'question', + readOnly: true, + }) + }) + + it('combines positional and piped input in Claude Code order', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => 'piped context\n').parseAsync([ + 'node', + 'sim', + 'chat', + '--print', + 'Explain', + 'this', + ]) + + expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('Explain this\npiped context\n') + }) + + it('accepts piped input without a positional prompt', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + + await program(async () => 'question from stdin\n').parseAsync(['node', 'sim', 'chat', '-p']) + + expect(mocks.requestRaw.mock.calls[0][1].body.prompt).toBe('question from stdin\n') + }) + + it('accepts attachment-only turns and never sends local paths', async () => { + const attachment: ChatAttachment = { + name: 'notes.md', + mediaType: 'text/markdown', + data: 'IyBub3Rlcw==', + } + const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) + mocks.requestRaw.mockResolvedValue(completed('Inspected')) + + await program(async () => '', vi.fn(), { loadAttachments }).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + '--file', + '/private/local/notes.md', + ]) + + expect(loadAttachments).toHaveBeenCalledWith(['/private/local/notes.md']) + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: '', + attachments: [attachment], + }) + expect(JSON.stringify(mocks.requestRaw.mock.calls[0][1].body)).not.toContain('/private/local') + }) + + it('requires a prompt, attachment, or stdin', async () => { + await expect(program(async () => '').parseAsync(['node', 'sim', 'chat', '-p'])).rejects.toThrow( + /Provide a prompt, attach a file, or pipe input/ + ) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('caps the combined prompt by UTF-8 bytes', async () => { + const justOverTenMebibytes = 'é'.repeat(5 * 1024 * 1024 + 1) + + const result = program(async () => justOverTenMebibytes).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + ]) + + await expect(result).rejects.toMatchObject({ + message: 'Chat input exceeds the 10 MiB limit.', + status: 0, + }) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('fails clearly instead of blocking when bare chat has no interactive terminal', async () => { + await expect( + program(async () => '').parseAsync(['node', 'sim', 'chat', 'question']) + ).rejects.toThrow(/Use sim chat -p/) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('never constructs a terminal prompt in -p mode', async () => { + mocks.requestRaw.mockResolvedValue(completed('answer')) + const createTerminal = vi.fn(() => { + throw new Error('must not prompt') + }) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal, + }).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(createTerminal).not.toHaveBeenCalled() + }) + + it('sanitizes final plain text and strips suggested follow-up options', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + completed( + `Safe${terminalEscape}]0;owned\u0007 text<options>{"1":{"title":"Next${terminalEscape}[2A","description":"Continue"}}</options>` + ) + ) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(writeOutput).toHaveBeenCalledWith('Safe text') + expect(writeOutput.mock.calls[0][0]).not.toContain(terminalEscape) + }) + + it('trims whitespace owned by hidden options in print mode', async () => { + const options = '<options>{"1":{"title":"Next","description":"Continue"}}</options>' + mocks.requestRaw.mockResolvedValue(completed(`Answer\n\n${options}\n\n`)) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(writeOutput).toHaveBeenCalledOnce() + expect(writeOutput).toHaveBeenCalledWith('Answer') + }) + + it('renders a path-only file resource as its plain title without another API request', async () => { + mocks.requestRaw.mockResolvedValue( + completed( + '<workspace_resource>{"type":"file","path":"files/Reports/Q4%20Report.csv","title":"Q4 report"}</workspace_resource>' + ) + ) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + 'find file', + ]) + + expect(mocks.request).not.toHaveBeenCalled() + expect(writeOutput).toHaveBeenCalledWith('Q4 report') + }) + + it('omits a trailing standalone workspace link in print mode', async () => { + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' + mocks.requestRaw.mockResolvedValue(completed(`Summary.\n\n${resource}`)) + const writeOutput = vi.fn() + + await program(async () => '', writeOutput).parseAsync([ + 'node', + 'sim', + 'chat', + '-p', + 'inspect forceful-arm', + ]) + + expect(writeOutput).toHaveBeenCalledWith('Summary.') + }) + + it('does not print a partial answer when the stream fails', async () => { + mocks.requestRaw.mockResolvedValue( + sse([ + 'event: text\ndata: {"type":"text","delta":"partial"}\n\n', + 'event: error\ndata: {"type":"error","error":{"code":"FAILED","message":"No answer"}}\n\n', + ]) + ) + const writeOutput = vi.fn() + + await expect( + program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + ).rejects.toThrow('No answer') + expect(writeOutput).not.toHaveBeenCalled() + }) + + it('keeps thinking and activity events silent in print mode', async () => { + mocks.requestRaw.mockResolvedValue( + sse([ + 'event: thinking\ndata: {"type":"thinking","delta":"Checking the workspace"}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"subagent","id":"agent-1","label":"Build Agent","state":"running"}}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"narration","parentId":"agent-1","delta":"Inspecting files"}}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read workflows","state":"running"}}\n\n', + 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token-1"}}\n\n', + ]) + ) + const writeOutput = vi.fn() + await program(async () => '', writeOutput).parseAsync(['node', 'sim', 'chat', '-p', 'question']) + + expect(writeOutput).toHaveBeenCalledWith('Answer') + }) +}) + +describe('interactive chat', () => { + it('renders Markdown in the fullscreen TUI when TERM is dumb', async () => { + const originalTerm = process.env.TERM + const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + process.env.TERM = 'dumb' + + try { + const content = '**Workflows (3)**\n- cobalt_cloud' + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'list workspace']) + + const esc = String.fromCharCode(27) + const rendered = terminal.writes.join('') + expect(rendered).toContain(`${esc}[1mWorkflows (3)`) + expect(rendered).toContain(`${esc}[2m•${esc}[0m`) + expect(rendered).not.toContain('**') + expect(rendered).toContain('cobalt_cloud') + } finally { + if (originalIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTY) + } else { + Reflect.deleteProperty(process.stdout, 'isTTY') + } + if (originalTerm === undefined) Reflect.deleteProperty(process.env, 'TERM') + else process.env.TERM = originalTerm + } + }) + + it('aborts every background suggestion request when the terminal session closes', async () => { + const signals: AbortSignal[] = [] + mocks.request.mockImplementation( + (_path: string, options: { signal?: AbortSignal } = {}) => + new Promise((_resolve, reject) => { + if (!options.signal) return + signals.push(options.signal) + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }) + }) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(signals).toHaveLength(7) + expect(signals.every((signal) => signal === signals[0])).toBe(true) + expect(signals[0]?.aborted).toBe(true) + expect(terminal.closed).toBe(true) + }) + + it('loads workspace resources under @ and skills plus enabled MCP servers under /', async () => { + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/workflows') { + return Promise.resolve({ data: [{ id: 'wf-1', name: 'Release' }], nextCursor: null }) + } + if (path === '/api/v2/tables') { + return Promise.resolve({ data: [{ id: 'table-1', name: 'Leads' }], nextCursor: null }) + } + if (path === '/api/v2/files') { + return Promise.resolve({ data: [{ id: 'file-1', name: 'Brief.md' }], nextCursor: null }) + } + if (path === '/api/v2/knowledge') { + return Promise.resolve({ data: [{ id: 'kb-1', name: 'Handbook' }], nextCursor: null }) + } + if (path === '/api/v2/logs') { + return Promise.resolve({ + data: Array.from({ length: 55 }, (_, index) => ({ + id: `log-row-${index + 1}`, + executionId: `execution-${index + 1}`, + workflowId: 'wf-1', + startedAt: '2026-08-07T12:00:00.000Z', + })), + nextCursor: 'more-logs', + }) + } + if (path === '/api/v2/skills') { + return Promise.resolve({ + data: [{ id: 'skill-1', name: 'review', description: 'Review the work' }], + nextCursor: null, + }) + } + if (path === '/api/v2/mcp-servers') { + return Promise.resolve({ + data: [ + { id: 'mcp-1', name: 'Docs', enabled: true }, + { id: 'mcp-2', name: 'Disabled', enabled: false }, + ], + nextCursor: null, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + await vi.waitFor(() => { + expect(terminal.suggestionCandidates?.resources).toHaveLength(54) + expect(terminal.suggestionCandidates?.slash).toHaveLength(2) + }) + + const resources = terminal.suggestionCandidates?.resources ?? [] + expect(resources.slice(0, 4).map((item) => item.context?.kind)).toEqual([ + 'workflow', + 'table', + 'file', + 'knowledge', + ]) + expect(resources.slice(4)).toHaveLength(50) + expect(resources.slice(4).every((item) => item.context?.kind === 'logs')).toBe(true) + expect(resources.at(-1)?.context).toMatchObject({ + kind: 'logs', + executionId: 'execution-50', + label: expect.stringContaining('Release'), + }) + expect(terminal.suggestionCandidates?.slash.map((item) => item.context?.kind)).toEqual([ + 'skill', + 'mcp', + ]) + expect(terminal.suggestionCandidates?.slash.map((item) => item.displayText)).toEqual([ + '/review', + '/Docs', + ]) + expect( + terminal.suggestionUpdates.some( + (update) => + update.resources.length + update.slash.length > 0 && update.resources.length < 54 + ) + ).toBe(true) + const logRequests = mocks.request.mock.calls.filter(([path]) => path === '/api/v2/logs') + expect(logRequests).toHaveLength(1) + expect(logRequests[0]?.[1]).toMatchObject({ + query: { + workspaceId: 'ws_local', + details: 'basic', + order: 'desc', + limit: 50, + }, + }) + }) + + it('publishes skills but does not fetch or suggest MCP servers in read-only chat', async () => { + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/skills') { + return Promise.resolve({ + data: [{ id: 'skill-1', name: 'review', description: 'Review the work' }], + nextCursor: null, + }) + } + if (path === '/api/v2/mcp-servers') { + return Promise.resolve({ + data: [{ id: 'mcp-1', name: 'Docs', enabled: true }], + nextCursor: null, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', '--read-only']) + await vi.waitFor(() => expect(terminal.suggestionCandidates?.slash).toHaveLength(1)) + + expect(terminal.suggestionCandidates?.slash[0]?.context?.kind).toBe('skill') + expect(mocks.request.mock.calls.some(([path]) => path === '/api/v2/mcp-servers')).toBe(false) + }) + + it('publishes each suggestion family without waiting for a slower list', async () => { + let resolveWorkflows: + | ((page: { data: Array<{ id: string; name: string }>; nextCursor: null }) => void) + | undefined + const workflows = new Promise<{ data: Array<{ id: string; name: string }>; nextCursor: null }>( + (resolve) => { + resolveWorkflows = resolve + } + ) + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/workflows') return workflows + if (path === '/api/v2/files') { + return Promise.resolve({ data: [{ id: 'file-1', name: 'Ready.md' }], nextCursor: null }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + await vi.waitFor(() => + expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).toContain( + 'Ready.md' + ) + ) + expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).not.toContain( + 'Later workflow' + ) + + resolveWorkflows?.({ data: [{ id: 'workflow-1', name: 'Later workflow' }], nextCursor: null }) + await vi.waitFor(() => + expect(terminal.suggestionCandidates?.resources.map((item) => item.displayText)).toContain( + 'Later workflow' + ) + ) + }) + + it('sends selected resource and slash identities beside the prompt', async () => { + const contexts: ChatContext[] = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + { kind: 'skill', skillId: 'skill-1', label: 'review' }, + { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' }, + ] + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'Use @Release with /review and /Docs', + contexts, + }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw.mockResolvedValueOnce(completed('Done', 'token-1')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ + prompt: 'Use @Release with /review and /Docs', + contexts, + }) + }) + + it('lists every chat page and refreshes an active choice before sending', async () => { + let detailRequests = 0 + mocks.request.mockImplementation((path: string, options?: { query?: unknown }) => { + if (path === '/api/v2/chats') { + const cursor = (options?.query as { cursor?: string | null } | undefined)?.cursor + if (cursor === 'older-chats') { + return Promise.resolve({ + data: [ + { + id: 'chat-older', + title: 'Older investigation', + updatedAt: '2026-07-01T12:00:00.000Z', + pinned: false, + active: false, + }, + ], + nextCursor: null, + }) + } + return Promise.resolve({ + data: [ + { + id: 'chat-2', + title: 'Release investigation', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: true, + active: true, + }, + ], + nextCursor: 'older-chats', + }) + } + if (path === '/api/v2/chats/chat-2') { + detailRequests += 1 + const active = detailRequests === 1 + return Promise.resolve({ + data: { + id: 'chat-2', + title: 'Release investigation', + messages: [ + { + id: 'message-1', + role: 'user', + content: 'What failed?', + timestamp: '2026-08-07T11:59:00.000Z', + }, + { + id: 'message-2', + role: 'assistant', + content: active + ? 'The **release** is still running.' + : 'The **release** finished.\n\n<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + continuationToken: active ? 'resume-token' : 'refreshed-token', + active, + }, + }) + } + return Promise.resolve({ data: [], nextCursor: null, options }) + }) + mocks.requestRaw.mockResolvedValueOnce(completed('Continuing', 'next-token')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/chats' }, + { kind: 'line', value: 'Continue here' }, + { kind: 'line', value: '/exit' }, + ], + [], + [{ kind: 'selected', id: 'chat-2' }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.selections).toHaveLength(1) + expect(terminal.selections[0]?.options).toEqual([ + { + id: 'sim-cli:new-chat', + label: 'New chat', + description: 'start a blank conversation', + }, + expect.objectContaining({ + id: 'chat-2', + label: 'Release investigation', + description: expect.stringContaining('pinned'), + }), + expect.objectContaining({ + id: 'chat-older', + label: 'Older investigation', + }), + ]) + expect(detailRequests).toBe(2) + expect(terminal.clearedTranscripts).toBe(2) + expect(terminal.statuses).toContain( + 'Opened Release investigation. This chat is currently active elsewhere.' + ) + expect(terminal.statuses).toContain('Resumed Release investigation.') + expect(terminal.chatTitles).toContain('Release investigation') + expect(terminal.userMessages).toContain('What failed?') + expect(terminal.userMessages).toContain('Continue here') + expect(terminal.writes.join('')).toContain('The **release** finished.\n') + expect(terminal.writes.join('')).not.toContain('forceful-arm') + expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ + workspaceId: 'ws_local', + prompt: 'Continue here', + continuationToken: 'refreshed-token', + }) + const listRequests = mocks.request.mock.calls.filter(([path]) => path === '/api/v2/chats') + expect(listRequests).toHaveLength(2) + expect(listRequests[0]?.[1]).toMatchObject({ + query: { workspaceId: 'ws_local', limit: 100, cursor: null }, + }) + expect(listRequests[1]?.[1]).toMatchObject({ + query: { workspaceId: 'ws_local', limit: 100, cursor: 'older-chats' }, + }) + }) + + it('refreshes a resumed chat before retrying after a send races with remote activity', async () => { + let detailRequests = 0 + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats') { + return Promise.resolve({ + data: [ + { + id: 'chat-race', + title: 'Race investigation', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: false, + active: false, + }, + ], + nextCursor: null, + }) + } + if (path === '/api/v2/chats/chat-race') { + detailRequests += 1 + return Promise.resolve({ + data: { + id: 'chat-race', + title: 'Race investigation', + messages: [ + { + id: `message-${detailRequests}`, + role: 'assistant', + content: detailRequests === 1 ? 'Ready.' : 'The remote response finished.', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + continuationToken: detailRequests === 1 ? 'initial-token' : 'refreshed-token', + active: false, + }, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'next-token')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/chats' }, + { kind: 'line', value: 'Retry this turn' }, + { kind: 'line', value: 'Retry this turn' }, + { kind: 'line', value: '/exit' }, + ], + [], + [{ kind: 'selected', id: 'chat-race' }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(detailRequests).toBe(2) + expect(terminal.clearedTranscripts).toBe(2) + expect(terminal.preloads).toContainEqual({ value: 'Retry this turn', queued: true }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + expect(terminal.writes.join('')).toContain('The remote response finished.\n') + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(mocks.requestRaw.mock.calls[1][1].body).toMatchObject({ + workspaceId: 'ws_local', + prompt: 'Retry this turn', + continuationToken: 'refreshed-token', + }) + }) + + it('repaints and restores the exact turn while a resumed chat remains active elsewhere', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const pasted = 'p'.repeat(900) + const display = 'Retry @Release [Pasted text #1]' + const prompt = `Retry @Release ${pasted}` + const pastes = new Map([[1, pasted]]) + const contexts: ChatContext[] = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + ] + let detailRequests = 0 + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats') { + return Promise.resolve({ + data: [ + { + id: 'chat-active', + title: 'Active investigation', + updatedAt: '2026-08-07T12:00:00.000Z', + pinned: false, + active: true, + }, + ], + nextCursor: null, + }) + } + if (path === '/api/v2/chats/chat-active') { + detailRequests += 1 + const active = detailRequests < 3 + return Promise.resolve({ + data: { + id: 'chat-active', + title: 'Active investigation', + messages: [ + { + id: 'message-1', + role: 'assistant', + content: active ? 'Still working.' : 'Finished now.', + timestamp: '2026-08-07T12:00:00.000Z', + }, + ], + continuationToken: `resume-token-${detailRequests}`, + active, + }, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + mocks.requestRaw.mockResolvedValueOnce(completed('Retried', 'next-token')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/attach "/private/tmp/notes.txt"' }, + { kind: 'line', value: '/chats' }, + { kind: 'line', value: prompt, display, pastes, contexts }, + { kind: 'line', value: prompt, display, pastes, contexts }, + { kind: 'line', value: '/exit' }, + ], + [], + [{ kind: 'selected', id: 'chat-active' }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async (paths) => (paths.length ? [attachment] : []), + pastedAttachmentPaths: async () => null, + }).parseAsync(['node', 'sim', 'chat']) + + expect(detailRequests).toBe(3) + expect(terminal.statuses).toContain( + 'Refreshed Active investigation. This chat remains active elsewhere.' + ) + expect(terminal.preloads).toContainEqual({ + value: display, + queued: true, + pastes, + contexts, + }) + expect(terminal.userMessages).toContain(display) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + expect(mocks.requestRaw.mock.calls[0][1].body).toMatchObject({ + workspaceId: 'ws_local', + prompt, + continuationToken: 'resume-token-3', + attachments: [attachment], + contexts, + }) + }) + + it('visibly resets the transcript and continuation identity with /clear', async () => { + mocks.requestRaw + .mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"First","continuationToken":"token-1"}}\n\n', + ]) + ) + .mockResolvedValueOnce(completed('Second', 'token-2')) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/clear' }, + { kind: 'line', value: 'Fresh question' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Original question']) + + expect(terminal.clearedTranscripts).toBe(1) + expect(terminal.statuses).toContain('Started a new conversation.') + expect(terminal.chatTitles).toContain('New chat') + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Fresh question', + }) + }) + + it('updates the welcome header when the server generates a chat title', async () => { + mocks.requestRaw.mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', + 'event: session\ndata: {"type":"session","title":"Release investigation"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Investigate the release']) + + expect(terminal.welcomes).toEqual(['New chat']) + expect(terminal.chatTitles).toContain('Release investigation') + }) + + it('renames the active synced chat and updates the terminal header', async () => { + mocks.requestRaw.mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"token-1"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', + ]) + ) + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats/chat-1') { + return Promise.resolve({ + data: { id: 'chat-1', title: 'Incident investigation' }, + }) + } + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename Incident investigation' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Investigate the incident']) + + const renameRequest = mocks.request.mock.calls.find( + ([path, options]) => path === '/api/v2/chats/chat-1' && options?.method === 'PATCH' + ) + expect(renameRequest?.[1]).toEqual({ + method: 'PATCH', + body: { workspaceId: 'ws_local', title: 'Incident investigation' }, + auth: 'optional', + }) + expect(terminal.chatTitles).toContain('Incident investigation') + expect(terminal.statuses).toContain('Renamed chat to Incident investigation.') + }) + + it('requires a synced chat before renaming', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename Draft title' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.statuses).toContain('Send a message before renaming this chat.') + expect(mocks.request.mock.calls.some(([, options]) => options?.method === 'PATCH')).toBe(false) + }) + + it('validates rename titles locally', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename' }, + { kind: 'line', value: `/rename ${'x'.repeat(201)}` }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.statuses).toContain('Usage: /rename <title>') + expect(terminal.statuses).toContain('Error: Chat title cannot exceed 200 characters.') + expect(mocks.request.mock.calls.some(([, options]) => options?.method === 'PATCH')).toBe(false) + }) + + it('keeps the current title when rename fails', async () => { + mocks.requestRaw.mockResolvedValueOnce( + sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","title":"Current title","continuationToken":"token-1"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token-1"}}\n\n', + ]) + ) + mocks.request.mockImplementation((path: string) => { + if (path === '/api/v2/chats/chat-1') return Promise.reject(new Error('Rename failed')) + return Promise.resolve({ data: [], nextCursor: null }) + }) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/rename New title' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'Start']) + + expect(terminal.chatTitles).toEqual(['Current title']) + expect(terminal.statuses).toContain('Error: Rename failed') + }) + + it('sends only MCP contexts explicitly tagged on each turn', async () => { + const mcp: ChatContext = { kind: 'mcp', serverId: 'mcp-1', label: 'Docs' } + const terminal = new FakeTerminal([ + { kind: 'line', value: '/Docs search', contexts: [mcp] }, + { kind: 'line', value: 'Search again' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('First', 'token-1')) + .mockResolvedValueOnce(completed('Second', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(mocks.requestRaw.mock.calls[0][1].body.contexts).toEqual([mcp]) + expect(mocks.requestRaw.mock.calls[1][1].body.contexts).toBeUndefined() + }) + + it('quietly clears on Ctrl+C and exits on a second empty Ctrl+C', async () => { + const terminal = new FakeTerminal([ + { kind: 'interrupt', empty: true }, + { kind: 'interrupt', empty: true }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.statuses).toEqual([]) + expect(terminal.welcomes).toEqual(['New chat']) + expect(mocks.requestRaw).not.toHaveBeenCalled() + expect(terminal.closed).toBe(true) + }) + + it('strips suggested follow-ups and keeps the next composer message free-form', async () => { + const options = + '<options>{"1":{"title":"First","description":"A"},"2":{"title":"Second","description":"B"}}</options>' + mocks.requestRaw + .mockResolvedValueOnce( + completed(options, 'token-1', [options.slice(0, 31), options.slice(31)]) + ) + .mockResolvedValueOnce(completed('Done', 'token-2', ['Do', 'ne'])) + const terminal = new FakeTerminal([ + { kind: 'line', value: 'A different request' }, + { kind: 'line', value: '/exit' }, + ]) + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'A different request', + continuationToken: 'token-1', + }) + expect(terminal.writes.join('')).toBe('Done\n') + expect(terminal.statuses.join('\n')).not.toContain('Suggested follow-ups') + expect(terminal.statuses.join('\n')).not.toContain('First') + expect(terminal.reads[0]).toEqual({ prompt: '❯ ', initialValue: '' }) + expect(terminal.userMessages).toEqual(['start']) + expect(terminal.closed).toBe(true) + }) + + it.each([ + ['plain trailing whitespace', 'Answer\n\n'], + [ + 'whitespace before hidden options', + 'Answer\n\n<options>{"1":{"title":"Next","description":"Continue"}}</options>\n\n', + ], + ])('hands %s to the next composer with exactly one newline', async (_name, content) => { + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.writes.join('')).toBe('Answer\n') + expect(terminal.reads).toEqual([{ prompt: '❯ ', initialValue: '' }]) + }) + + it('renders tagged resource bullets as plain names without links or undefined prefixes', async () => { + const content = [ + 'Workflows\n', + '- <workspace_resource>{"type":"workflow","id":"wf-1","title":"default-agent"}</workspace_resource>\n', + '- <workspace_resource>{"type":"workflow","id":"wf-2","title":"forceful-arm"}</workspace_resource>', + ].join('') + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [content])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => true, + }).parseAsync(['node', 'sim', 'chat', 'list resources']) + + const rendered = terminal.writes.join('') + expect(rendered).toContain('default-agent') + expect(rendered).toContain('forceful-arm') + expect(rendered).not.toContain('undefined') + expect(rendered).not.toContain('https://') + expect(rendered).not.toContain(`${String.fromCharCode(27)}]8;;`) + }) + + it('omits a trailing standalone workspace link that has no terminal action', async () => { + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' + const content = [ + 'Three blocks, mostly a stub:\n\n', + '- Start — manual trigger.\n', + '- Router 1 — always routes hi.\n', + '- Agent 1 — replies to hi.\n\n', + resource, + ].join('') + mocks.requestRaw.mockResolvedValue( + completed(content, 'token-1', [ + content.slice(0, content.indexOf('<workspace_resource>') + 12), + content.slice(content.indexOf('<workspace_resource>') + 12, -8), + content.slice(-8), + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'inspect forceful-arm']) + + const rendered = terminal.writes.join('') + expect(rendered).toContain('Three blocks, mostly a stub:') + expect(rendered).toContain('- Agent 1 — replies to hi.') + expect(rendered).not.toContain('forceful-arm') + expect(rendered.endsWith('\n')).toBe(true) + }) + + it('restores a deferred workspace link when a later chunk continues the answer', async () => { + const resource = + '<workspace_resource>{"type":"workflow","id":"wf-forceful","title":"forceful-arm"}</workspace_resource>' + const first = `Summary.\n\n${resource}` + const content = `${first}\nThen continue.` + mocks.requestRaw.mockResolvedValue(completed(content, 'token-1', [first, '\nThen continue.'])) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'inspect forceful-arm']) + + expect(terminal.writes.join('')).toBe('Summary.\n\nforceful-arm\nThen continue.\n') + }) + + it('uses the dedicated question panel and sends its answer with the continuation token', async () => { + const question = + '<question>{"type":"single_select","prompt":"Which service should I inspect?","options":[{"id":"api","label":"API"},{"id":"worker","label":"Worker"}]}</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [{ kind: 'line', value: '/exit' }], + [{ kind: 'answer', values: ['Worker'] }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.questions).toEqual([ + { + prompt: 'Which service should I inspect?', + multi: false, + options: [ + { id: 'api', label: 'API' }, + { id: 'worker', label: 'Worker' }, + ], + }, + ]) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Which service should I inspect? — Worker', + continuationToken: 'token-1', + }) + }) + + it('runs queued local commands before presenting a retained structured question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [ + { kind: 'line', value: '/help', queued: true, display: '/help' }, + { kind: 'line', value: '/exit' }, + ], + [{ kind: 'answer', values: ['Yes'] }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.statuses.join('\n')).toContain('Commands:') + expect(terminal.questions).toHaveLength(1) + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ + 'start', + 'Proceed? — Yes', + ]) + }) + + it('waits for queued path confirmation before answering a retained question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' + const attachment: ChatAttachment = { + name: 'report.txt', + mediaType: 'text/plain', + data: 'cmVwb3J0', + } + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [ + { + kind: 'line', + value: '/private/tmp/report.txt', + queued: true, + display: '/private/tmp/report.txt', + }, + { kind: 'line', value: '/attach "/private/tmp/report.txt"' }, + { kind: 'line', value: '/exit' }, + ], + [{ kind: 'answer', values: ['Yes'] }] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths: async (value) => + value === '/private/tmp/report.txt' ? ['/private/tmp/report.txt'] : null, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.preloads).toContainEqual({ + value: '/attach "/private/tmp/report.txt"', + queued: false, + }) + expect(terminal.questions).toHaveLength(1) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Proceed? — Yes', + continuationToken: 'token-1', + attachments: [attachment], + }) + }) + + it('honors a queued exit before opening a structured question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' + mocks.requestRaw.mockResolvedValueOnce(completed(question, 'token-1')) + const terminal = new FakeTerminal([ + { kind: 'line', value: '/exit', queued: true, display: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.questions).toEqual([]) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + }) + + it('submits question arrays and multi-selects in the Mothership answer format', async () => { + const questions = + '<question>[{"type":"single_select","prompt":"Environment?","options":[{"id":"dev","label":"Dev"},{"id":"prod","label":"Prod"}]},{"type":"multi_select","prompt":"Services?","options":[{"id":"api","label":"API"},{"id":"worker","label":"Worker"}]}]</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(questions, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [{ kind: 'line', value: '/exit' }], + [ + { kind: 'answer', values: ['Prod'] }, + { kind: 'answer', values: ['API', 'custom service'] }, + ] + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.statuses).toEqual(['Question 1 of 2', 'Question 2 of 2']) + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe( + 'Environment? — Prod\nServices? — API, custom service' + ) + }) + + it('never interprets a model-authored question answer as a local slash command', async () => { + const question = + '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"bad","label":"/attach /secret"}]}</question>' + mocks.requestRaw + .mockResolvedValueOnce(completed(question, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal( + [{ kind: 'line', value: '/exit' }], + [{ kind: 'answer', values: ['/attach /secret'] }] + ) + const loadAttachments = vi.fn(async () => []) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(loadAttachments).toHaveBeenCalledOnce() + expect(loadAttachments).toHaveBeenCalledWith([]) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Proceed? — /attach /secret', + continuationToken: 'token-1', + }) + }) + + it('submits arbitrary composer text unchanged after stripped options', async () => { + const options = '<options>{"1":{"title":"Inspect logs","description":"Find errors"}}</options>' + mocks.requestRaw + .mockResolvedValueOnce(completed(options, 'token-1')) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal([ + { kind: 'line', value: 'Ask a completely different question' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(terminal.reads[0].prompt).toBe('❯ ') + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe( + 'Ask a completely different question' + ) + }) + + it('requires an explicit Enter on a preloaded /attach command for pasted paths', async () => { + const attachment: ChatAttachment = { + name: 'report.txt', + mediaType: 'text/plain', + data: 'cmVwb3J0', + } + const absolutePath = '/private/tmp/report.txt' + const terminal = new FakeTerminal([ + { kind: 'line', value: absolutePath }, + { kind: 'line', value: `/attach "${absolutePath}"` }, + { kind: 'line', value: 'Inspect this file' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw.mockResolvedValue(completed('Done')) + const pastedAttachmentPaths = vi.fn(async (value: string) => + value === absolutePath ? [absolutePath] : null + ) + const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths, + loadAttachments, + }).parseAsync(['node', 'sim', 'chat']) + + expect(loadAttachments).toHaveBeenCalledWith([absolutePath]) + expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) + expect(terminal.statuses).toContain( + 'File path detected. Press Enter to attach it, or edit the command.' + ) + expect(terminal.statuses.some((status) => status.startsWith('Unknown command:'))).toBe(false) + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Inspect this file', + attachments: [attachment], + }) + }) + + it('does not read or upload a detected path when confirmation is cancelled', async () => { + const absolutePath = '/private/tmp/private.txt' + const terminal = new FakeTerminal([ + { kind: 'line', value: absolutePath }, + { kind: 'line', value: '/exit' }, + ]) + const pastedAttachmentPaths = vi.fn(async (value: string) => + value === absolutePath ? [absolutePath] : null + ) + const loadAttachments = vi.fn(async () => []) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths, + loadAttachments, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) + expect(loadAttachments).toHaveBeenCalledTimes(1) + expect(loadAttachments).toHaveBeenCalledWith([]) + expect(mocks.requestRaw).not.toHaveBeenCalled() + }) + + it('preserves draft text when Ctrl+V attaches a clipboard image', async () => { + const attachment: ChatAttachment = { + name: 'clipboard.png', + mediaType: 'image/png', + data: 'iVBORw0KGgo=', + } + const terminal = new FakeTerminal([ + { kind: 'clipboard', value: 'explain this' }, + { kind: 'line', value: 'explain this' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw.mockResolvedValue(completed('Done')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + clipboardImage: async () => attachment, + pastedAttachmentPaths: async () => null, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.reads[1].initialValue).toBe('') + expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'explain this', + attachments: [attachment], + }) + }) + + it('aborts an active HTTP turn on Ctrl+C and returns to the prompt', async () => { + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + let requestSignal: AbortSignal | undefined + mocks.requestRaw.mockImplementation( + (_path: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + requestSignal = options.signal + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }) + queueMicrotask(() => terminal.interrupt()) + }) + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'long request']) + + expect(requestSignal?.aborted).toBe(true) + expect(terminal.statuses).toContain('Generation cancelled.') + expect(terminal.reads.at(-1)?.prompt).toBe('❯ ') + }) + + it('steers an active turn with the early continuation token and no attachment replay', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'change direction', + queued: true, + display: 'change direction', + }, + { kind: 'line', value: '/exit' }, + ]) + const order: string[] = [] + const interrupt = vi.spyOn(terminal, 'interrupt') + let firstRequestSignal: AbortSignal | undefined + + mocks.requestRaw + .mockImplementationOnce((_path: string, options: { signal: AbortSignal }) => { + firstRequestSignal = options.signal + return Promise.resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + order.push('session') + controller.enqueue( + new TextEncoder().encode( + 'event: session\ndata: {"type":"session","continuationToken":"token-before-complete"}\n\n' + ) + ) + options.signal.addEventListener( + 'abort', + () => { + order.push('abort') + controller.error(new Error('aborted')) + }, + { once: true } + ) + setImmediate(() => { + order.push('submit') + terminal.interrupt('submit') + }) + }, + }) + ) + ) + }) + .mockImplementationOnce(async () => { + order.push('follow-up') + return completed('Redirected', 'token-2') + }) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(interrupt).toHaveBeenCalledTimes(1) + expect(interrupt).toHaveBeenCalledWith('submit') + expect(firstRequestSignal?.aborted).toBe(true) + expect(order).toEqual(['session', 'submit', 'abort', 'follow-up']) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'change direction', + continuationToken: 'token-before-complete', + }) + expect(terminal.statuses).not.toContain('Generation cancelled.') + expect(terminal.preloads).toEqual([]) + }) + + it('leaves the active turn running for a queued path recognized by normal chat input', async () => { + const pathInput = { + kind: 'line' as const, + value: 'report.txt', + queued: true, + display: 'report.txt', + } + const terminal = new FakeTerminal([pathInput, { kind: 'line', value: '/exit' }]) + let requestSignal: AbortSignal | undefined + const pastedAttachmentPaths = vi.fn(async (value: string) => + value === 'report.txt' ? ['report.txt'] : null + ) + mocks.requestRaw.mockImplementationOnce( + async (_path: string, options: { signal: AbortSignal }) => { + requestSignal = options.signal + terminal.interrupt('submit', pathInput) + await new Promise((resolve) => setImmediate(resolve)) + return completed('Finished normally', 'token-1') + } + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + pastedAttachmentPaths, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(requestSignal?.aborted).toBe(false) + expect(terminal.preloads).toContainEqual({ + value: '/attach "report.txt"', + queued: false, + }) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + }) + + it('queues /chats without interrupting the active stream', async () => { + const chatsInput = { + kind: 'line' as const, + value: '/chats', + queued: true, + display: '/chats', + } + const terminal = new FakeTerminal( + [chatsInput, { kind: 'line', value: '/exit' }], + [], + [{ kind: 'cancel' }] + ) + let requestSignal: AbortSignal | undefined + mocks.requestRaw.mockImplementationOnce( + async (_path: string, options: { signal: AbortSignal }) => { + requestSignal = options.signal + terminal.interrupt('submit', chatsInput) + await new Promise((resolve) => setImmediate(resolve)) + return completed('Finished normally', 'token-1') + } + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(requestSignal?.aborted).toBe(false) + expect(terminal.selections).toHaveLength(1) + expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + const listRequest = mocks.request.mock.calls.find(([path]) => path === '/api/v2/chats') + expect(listRequest?.[1]).toMatchObject({ + query: { workspaceId: 'ws_local', limit: 100, cursor: null }, + }) + expect(listRequest?.[1]?.query).not.toHaveProperty('search') + }) + + it('waits for the first session token before interrupting a fast queued steer', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'change direction', + queued: true, + display: 'change direction', + }, + { kind: 'line', value: '/exit' }, + ]) + let abortedBeforeSession = false + + mocks.requestRaw + .mockImplementationOnce( + (_path: string, options: { signal: AbortSignal }) => + new Promise((resolve) => { + queueMicrotask(() => { + terminal.interrupt('submit') + abortedBeforeSession = options.signal.aborted + resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"session","continuationToken":"first-token"}\n\n' + ) + ) + options.signal.addEventListener( + 'abort', + () => controller.error(new Error('aborted')), + { once: true } + ) + }, + }) + ) + ) + }) + }) + ) + .mockResolvedValueOnce(completed('Redirected', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(abortedBeforeSession).toBe(false) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'change direction', + continuationToken: 'first-token', + }) + expect(terminal.statuses).not.toContain('Generation cancelled.') + }) + + it('keeps the original attachments when setup fails before a session is accepted', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const followUp = { + kind: 'line' as const, + value: 'retry with context', + queued: true, + display: 'retry with context', + } + const terminal = new FakeTerminal([followUp, { kind: 'line', value: '/exit' }]) + mocks.requestRaw + .mockImplementationOnce(() => + Promise.resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + terminal.interrupt('submit', followUp) + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"error","error":{"code":"INTERNAL_ERROR","message":"Chat request failed"}}\n\n' + ) + ) + controller.close() + }, + }) + ) + ) + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'retry with context', + attachments: [attachment], + }) + expect(terminal.statuses).toContain('Error: Chat request failed (INTERNAL_ERROR)') + }) + + it('does not replay attachments after an accepted turn fails', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { kind: 'line', value: 'continue without replaying it' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce( + sse([ + 'data: {"type":"session","continuationToken":"token-1"}\n\n', + 'data: {"type":"error","error":{"code":"INTERNAL_ERROR","message":"Chat request failed"}}\n\n', + ]) + ) + .mockResolvedValueOnce(completed('Continued', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw.mock.calls[0][1].body.attachments).toEqual([attachment]) + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'continue without replaying it', + continuationToken: 'token-1', + }) + expect(terminal.preloads).toEqual([]) + }) + + it('drains already-submitted turns before presenting an earlier turn question', async () => { + const question = + '<question>{"type":"single_select","prompt":"Pause for this?","options":[{"id":"yes","label":"Yes"}]}</question>' + const firstQueued = { + kind: 'line' as const, + value: 'first queued', + queued: true, + display: 'first queued', + } + const terminal = new FakeTerminal([ + firstQueued, + { kind: 'line', value: 'second queued', queued: true, display: 'second queued' }, + { kind: 'line', value: '/exit' }, + ]) + + mocks.requestRaw + .mockImplementationOnce((_path: string, options: { signal: AbortSignal }) => + Promise.resolve( + new Response( + new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"session","continuationToken":"token-1"}\n\n' + ) + ) + options.signal.addEventListener( + 'abort', + () => controller.error(new Error('aborted')), + { once: true } + ) + setImmediate(() => terminal.interrupt('submit', firstQueued)) + }, + }) + ) + ) + ) + .mockResolvedValueOnce(completed(question, 'token-2')) + .mockResolvedValueOnce(completed('Done', 'token-3')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ + 'original', + 'first queued', + 'second queued', + ]) + expect(terminal.questions).toEqual([]) + }) + + it('does not move queued prompts into another conversation', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: 'first' }, + { kind: 'line', value: '/clear', queued: true, display: '/clear' }, + { kind: 'line', value: 'second', queued: true, display: 'second' }, + { kind: 'line', value: '/chats', queued: true, display: '/chats' }, + { kind: 'line', value: 'third', queued: true, display: 'third' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('First', 'token-1')) + .mockResolvedValueOnce(completed('Second', 'token-2')) + .mockResolvedValueOnce(completed('Third', 'token-3')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body)).toEqual([ + { workspaceId: 'ws_local', prompt: 'first' }, + { workspaceId: 'ws_local', prompt: 'second', continuationToken: 'token-1' }, + { workspaceId: 'ws_local', prompt: 'third', continuationToken: 'token-2' }, + ]) + expect(terminal.statuses).toEqual([ + 'Finish queued prompts before changing conversations.', + 'Finish queued prompts before changing conversations.', + ]) + expect(mocks.request.mock.calls.some(([path]) => path === '/api/v2/chats')).toBe(false) + }) + + it('restores a queued head ahead of later input when the handoff lease is still busy', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry me', queued: true, display: 'retry me' }, + { kind: 'line', value: 'retry me' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.preloads).toContainEqual({ value: 'retry me', queued: true }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe('retry me') + }) + + it('restores a normally submitted prompt after a pre-session conflict', async () => { + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry me' }, + { kind: 'line', value: 'retry me' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.preloads).toContainEqual({ value: 'retry me', queued: true }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + expect(mocks.requestRaw).toHaveBeenCalledTimes(2) + }) + + it('automatically retries one queued continuation conflict', async () => { + const contexts: ChatContext[] = [ + { kind: 'workflow', workflowId: 'workflow-1', label: 'Release' }, + ] + const terminal = new FakeTerminal([ + { + kind: 'line', + value: 'retry @Release', + queued: true, + display: 'retry @Release', + contexts, + }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('Original', 'token-1')) + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(mocks.requestRaw.mock.calls.map(([, options]) => options.body.prompt)).toEqual([ + 'original', + 'retry @Release', + 'retry @Release', + ]) + expect(mocks.requestRaw.mock.calls[2][1].body).toMatchObject({ + continuationToken: 'token-1', + contexts, + }) + expect(terminal.preloads).toEqual([]) + expect(terminal.statuses).toContain('Previous response is still settling. Retrying…') + expect(terminal.statuses).not.toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + }) + + it('bounds queued continuation conflict retries and restores the exact tagged input', async () => { + const contexts: ChatContext[] = [{ kind: 'skill', skillId: 'skill-1', label: 'review' }] + const terminal = new FakeTerminal([ + { + kind: 'line', + value: '/review this', + queued: true, + display: '/review this', + contexts, + }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockResolvedValueOnce(completed('Original', 'token-1')) + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'original']) + + expect(mocks.requestRaw).toHaveBeenCalledTimes(3) + expect(terminal.preloads).toContainEqual({ + value: '/review this', + queued: true, + contexts, + }) + expect(terminal.statuses).toContain( + 'Previous response is still settling. Press Enter to retry.' + ) + }) + + it('carries queued large-paste bodies into a conflict retry', async () => { + const pasted = 'p'.repeat(900) + const pastes = new Map([[1, pasted]]) + const terminal = new FakeTerminal([ + { + kind: 'line', + value: pasted, + queued: true, + display: '[Pasted text #1]', + pastes, + }, + { kind: 'line', value: pasted, queued: true, display: '[Pasted text #1]', pastes }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce( + new SimApiError('A response is already in progress for this chat', 409, 'CONFLICT') + ) + .mockResolvedValueOnce(completed('Retried', 'token-2')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat']) + + expect(terminal.preloads[0]).toMatchObject({ + value: '[Pasted text #1]', + queued: true, + pastes, + }) + expect(mocks.requestRaw.mock.calls[1][1].body.prompt).toBe(pasted) + }) + + it('restores pending attachments after Ctrl+C so a retry can send them', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockImplementationOnce( + (_path: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }) + queueMicrotask(() => terminal.interrupt()) + }) + ) + .mockResolvedValueOnce(completed('Retried')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'retry', + attachments: [attachment], + }) + }) + + it('reports a failed turn and restores its attachments for the next prompt', async () => { + const attachment: ChatAttachment = { + name: 'notes.txt', + mediaType: 'text/plain', + data: 'bm90ZXM=', + } + const terminal = new FakeTerminal([ + { kind: 'line', value: 'retry' }, + { kind: 'line', value: '/exit' }, + ]) + mocks.requestRaw + .mockRejectedValueOnce(new SimApiError('Temporarily\nunavailable', 503, 'UNAVAILABLE')) + .mockResolvedValueOnce(completed('Retried')) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + loadAttachments: async () => [attachment], + }).parseAsync(['node', 'sim', 'chat', '--file', '/local/notes.txt', 'inspect']) + + expect(terminal.statuses).toContain('Error: Temporarily unavailable (UNAVAILABLE)') + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'retry', + attachments: [attachment], + }) + }) + + it('parses an authoritative completion suffix omitted from text deltas', async () => { + const options = '<options>{"1":{"title":"Continue","description":"Go"}}</options>' + mocks.requestRaw + .mockResolvedValueOnce(completed(`Hello${options}`, 'token-1', ['Hello'])) + .mockResolvedValueOnce(completed('Done', 'token-2')) + const terminal = new FakeTerminal([ + { kind: 'line', value: 'Continue' }, + { kind: 'line', value: '/exit' }, + ]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'start']) + + expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ + workspaceId: 'ws_local', + prompt: 'Continue', + continuationToken: 'token-1', + }) + }) + + it('sanitizes streamed plain deltas before stdout', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + completed(`Safe${terminalEscape}]0;owned\u0007 answer`, 'token', [ + `Safe${terminalEscape}]0;`, + 'owned\u0007 answer', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'question']) + + expect(terminal.writes.join('')).toBe('Safeowned answer\n') + expect(terminal.writes.join('')).not.toContain(terminalEscape) + }) + + it('forwards sanitized thinking and ordered activity transitions to the terminal', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + sse([ + `event: thinking\ndata: ${JSON.stringify({ + type: 'thinking', + delta: `Inspect${terminalEscape}]0;owned\u0007 workspace`, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research\nagent', + state: 'running', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Research agent', + state: 'complete', + }, + })}\n\n`, + 'event: text\ndata: {"type":"text","delta":"Done"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"token"}}\n\n', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + }).parseAsync(['node', 'sim', 'chat', 'question']) + + expect(terminal.thinking).toEqual(['Inspect workspace']) + expect(terminal.activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Research agent', + state: 'running', + }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Research agent', + state: 'complete', + }, + ]) + }) + + it('forwards nested subagent narration and tool seams without mixing them into the answer', async () => { + const terminalEscape = String.fromCharCode(27) + mocks.requestRaw.mockResolvedValue( + sse([ + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'running', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'narration', + parentId: 'agent-1', + delta: `Inspect${terminalEscape}]0;owned\u0007ing `, + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'workspace' }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { kind: 'narration', parentId: 'agent-1', delta: 'After tool' }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'complete', + }, + })}\n\n`, + 'event: text\ndata: {"type":"text","delta":"Final answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Final answer","continuationToken":"token"}}\n\n', + ]) + ) + const terminal = new FakeTerminal([{ kind: 'line', value: '/exit' }]) + + await program(async () => '', vi.fn(), { + isInteractive: () => true, + createTerminal: () => terminal, + formatMarkdown: () => false, + }).parseAsync(['node', 'sim', 'chat', 'question']) + + expect(terminal.activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'running', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'Inspecting ' }, + { kind: 'narration', parentId: 'agent-1', delta: 'workspace' }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + { kind: 'narration', parentId: 'agent-1', delta: 'After tool' }, + { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'complete', + }, + ]) + expect(terminal.writes.join('')).toBe('Final answer\n') + expect(terminal.writes.join('')).not.toContain('Inspecting') + }) +}) + +describe('chat SSE reader', () => { + it('delivers thinking, activity, and text callbacks in wire order', async () => { + const callbacks: string[] = [] + const response = sse([ + 'event: thinking\ndata: {"type":"thinking","delta":"Planning"}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read\\nworkflow","state":"running"}}\n\n', + 'event: activity\ndata: {"type":"activity","data":{"kind":"tool","id":"tool-1","label":"Read workflow","state":"complete"}}\n\n', + 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token"}}\n\n', + ]) + + const result = await readChatTurn(response, { + onThinking: (delta) => { + callbacks.push(`thinking:${delta}`) + }, + onActivity: (activity) => { + callbacks.push( + activity.kind === 'narration' + ? `${activity.kind}:${activity.parentId}:${activity.delta}` + : `${activity.kind}:${activity.label}:${activity.state}` + ) + }, + onDelta: (delta) => { + callbacks.push(`text:${delta}`) + }, + }) + + expect(callbacks).toEqual([ + 'thinking:Planning', + 'tool:Read workflow:running', + 'tool:Read workflow:complete', + 'text:Answer', + ]) + expect(result.content).toBe('Answer') + }) + + it('parses parented narration and nested tools without adding scoped text to content', async () => { + const terminalEscape = String.fromCharCode(27) + const activities: ChatActivityUpdate[] = [] + const response = sse([ + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'subagent', + id: 'agent-1', + label: 'Build\nAgent', + state: 'running', + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'narration', + parentId: 'agent-1', + delta: `Line one\n\nLine${terminalEscape}]0;owned\u0007 two`, + }, + })}\n\n`, + `event: activity\ndata: ${JSON.stringify({ + type: 'activity', + data: { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + })}\n\n`, + 'event: text\ndata: {"type":"text","delta":"Answer"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Answer","continuationToken":"token"}}\n\n', + ]) + + const result = await readChatTurn(response, { + onActivity: (activity) => { + activities.push(activity) + }, + }) + + expect(activities).toEqual([ + { + kind: 'subagent', + id: 'agent-1', + label: 'Build Agent', + state: 'running', + }, + { + kind: 'narration', + parentId: 'agent-1', + delta: 'Line one\n\nLine two', + }, + { + kind: 'tool', + id: 'tool-1', + parentId: 'agent-1', + label: 'Read file', + state: 'complete', + }, + ]) + expect(result).toEqual({ + content: 'Answer', + streamedContent: 'Answer', + continuationToken: 'token', + }) + }) + + it('falls back to text deltas and uses the completion continuation token', async () => { + const response = sse([ + 'event: session\ndata: {"type":"session","continuationToken":"session-token"}\n\n', + 'event: text\r\ndata: {"type":"text","delta":"one"}\r\n\r\n', + 'event: text\ndata: {"type":"text","delta":" two"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"continuationToken":"complete-token"}}\n\n', + 'data: [DONE]\n\n', + ]) + + await expect(readChatTurn(response)).resolves.toEqual({ + content: 'one two', + streamedContent: 'one two', + continuationToken: 'complete-token', + }) + }) + + it('exposes the session continuation token before completion', async () => { + const tokens: string[] = [] + const response = sse([ + 'event: session\ndata: {"type":"session","continuationToken":"session-token"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', + ]) + + await readChatTurn(response, { + onContinuationToken: (token) => { + tokens.push(token) + }, + }) + + expect(tokens).toEqual(['session-token']) + }) + + it('exposes the shared chat id from the session event', async () => { + const chatIds: string[] = [] + const response = sse([ + 'event: session\ndata: {"type":"session","chatId":"chat-1","continuationToken":"session-token"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', + ]) + + await readChatTurn(response, { + onChatId: (chatId) => { + chatIds.push(chatId) + }, + }) + + expect(chatIds).toEqual(['chat-1']) + }) + + it('exposes a sanitized generated title from session events', async () => { + const titles: string[] = [] + const response = sse([ + 'event: session\ndata: {"type":"session","title":"Release\\u001b]0;owned\\u0007 investigation"}\n\n', + 'event: complete\ndata: {"type":"complete","data":{"content":"Done","continuationToken":"complete-token"}}\n\n', + ]) + + await readChatTurn(response, { + onTitle: (title) => { + titles.push(title) + }, + }) + + expect(titles).toEqual(['Release investigation']) + }) + + it('turns a streamed error into a sanitized structured CLI error', async () => { + const terminalEscape = String.fromCharCode(27) + const response = sse([ + `event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { + code: `CHAT${terminalEscape}[2A_FAILED`, + message: `Model${terminalEscape}]0;x\u0007 unavailable`, + }, + })}\n\n`, + ]) + + const result = readChatResponse(response) + await expect(result).rejects.toBeInstanceOf(SimApiError) + await expect(result).rejects.toMatchObject({ + message: 'Model unavailable', + code: 'CHAT_FAILED', + }) + }) + + it('rejects malformed and incomplete streams', async () => { + await expect(readChatResponse(sse(['data: not-json\n\n']))).rejects.toThrow( + /malformed streaming data/ + ) + await expect( + readChatResponse(sse(['data: {"type":"text","delta":"partial"}\n\ndata: [DONE]\n\n'])) + ).rejects.toThrow(/ended before completing/) + }) + + it.each([ + [ + 'an error event', + 'event: error\ndata: {"type":"error","error":{"code":"FAILED","message":"No answer"}}\n\n', + ], + ['malformed data', 'data: not-json\n\n'], + ])('cancels the response body after %s', async (_name, wire) => { + const { response, cancel } = openSse(wire) + + await expect(readChatResponse(response)).rejects.toBeInstanceOf(SimApiError) + expect(cancel).toHaveBeenCalledOnce() + }) +}) + +describe('composeChatPrompt', () => { + it('does not add a separator when only one source is present', () => { + expect(composeChatPrompt(['hello'], '')).toBe('hello') + expect(composeChatPrompt([], 'hello\n')).toBe('hello\n') + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts new file mode 100644 index 00000000000..81c1ccfb275 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -0,0 +1,1564 @@ +import { Command } from 'commander' +import { clientFrom } from '../../context.js' +import type { + ChatBody, + GetChatResponse, + GetWorkspaceResponse, + ListChatsResponse, + ListFilesResponse, + ListKnowledgeBasesResponse, + ListLogsResponse, + ListMcpServersResponse, + ListSkillsResponse, + ListTablesResponse, + ListWorkflowsResponse, + RenameChatBody, + RenameChatResponse, +} from '../../generated/v2-api.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' +import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../../http/client.js' +import { safeOneLine, sanitize } from '../../output/render.js' +import { + type ChatAttachment, + combineChatAttachments, + existingAttachmentPaths, + loadChatAttachments, + parseAttachmentPaths, + readClipboardImage, +} from './chat-attachments.js' +import { ChatMarkdownStream } from './chat-markdown.js' +import { + type ChatQuestion, + ChatStructuredParser, + type ChatStructuredSegment, + parseChatStructured, + type RenderPart, + renderChatStructured, +} from './chat-structured.js' +import type { ChatContext, ChatSuggestionCandidates, SuggestionItem } from './chat-suggestions.js' +import { + type ChatActivityUpdate, + type ChatTerminal, + type ChatTerminalInput, + type ChatTerminalSelectResult, + ReadlineChatTerminal, +} from './chat-terminal.js' + +export interface ChatDependencies { + readInput: (maxBytes: number) => Promise<string> + writeOutput: (content: string) => void + isInteractive: () => boolean + createTerminal: () => ChatTerminal + loadAttachments: (paths: string[]) => Promise<ChatAttachment[]> + clipboardImage: () => Promise<ChatAttachment | null> + pastedAttachmentPaths: (input: string) => Promise<string[] | null> + formatMarkdown: () => boolean +} + +interface ChatEvent { + type?: unknown + delta?: unknown + data?: unknown + error?: unknown + continuationToken?: unknown + chatId?: unknown + title?: unknown +} + +type ChatSummary = ListChatsResponse['data'][number] +type ChatHistoryMessage = GetChatResponse['data']['messages'][number] + +export interface ChatTurn { + content: string + streamedContent: string + continuationToken: string | null +} + +export interface ReadChatTurnOptions { + onDelta?: (delta: string) => void | Promise<void> + onThinking?: (delta: string) => void | Promise<void> + onActivity?: (activity: ChatActivityUpdate) => void | Promise<void> + /** The opaque token arrives after turn acceptance and before assistant output. */ + onContinuationToken?: (token: string) => void | Promise<void> + /** The shared chat identity arrives with the session event when available. */ + onChatId?: (chatId: string) => void | Promise<void> + /** The persisted chat title may arrive with either session acceptance or title generation. */ + onTitle?: (title: string) => void | Promise<void> +} + +type ChatRequest = ChatBody + +const MAX_CHAT_PROMPT_BYTES = 10 * 1024 * 1024 +const MAX_LOG_SUGGESTIONS = 50 + +function inputTooLarge(): SimApiError { + return new SimApiError('Chat input exceeds the 10 MiB limit.', 0) +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +/** Reads stdin only when the command is part of a pipe or redirection. */ +async function readPipedInput(maxBytes: number): Promise<string> { + if (process.stdin.isTTY) return '' + + process.stdin.setEncoding('utf8') + let input = '' + let inputBytes = 0 + for await (const chunk of process.stdin) { + inputBytes += utf8Bytes(chunk) + if (inputBytes > maxBytes) throw inputTooLarge() + input += chunk + } + return input +} + +/** Writes one completed answer, preserving its contents and adding a shell-friendly newline. */ +function writeCompletedAnswer(content: string): void { + if (!content) return + process.stdout.write(content) + if (!content.endsWith('\n')) process.stdout.write('\n') +} + +/** + * Matches Claude Code's print-mode input ordering: command-line prompt first, + * then piped context separated by one newline. + */ +export function composeChatPrompt(promptParts: string[], pipedInput: string): string { + return [promptParts.join(' '), pipedInput].filter(Boolean).join('\n') +} + +async function* linesOf(body: ReadableStream<Uint8Array>): AsyncGenerator<string> { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffered = '' + let reachedEnd = false + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + reachedEnd = true + break + } + buffered += decoder.decode(value, { stream: true }) + + let newline = buffered.indexOf('\n') + while (newline !== -1) { + const raw = buffered.slice(0, newline) + buffered = buffered.slice(newline + 1) + yield raw.endsWith('\r') ? raw.slice(0, -1) : raw + newline = buffered.indexOf('\n') + } + } + + buffered += decoder.decode() + if (buffered) yield buffered.endsWith('\r') ? buffered.slice(0, -1) : buffered + } finally { + if (!reachedEnd) await reader.cancel().catch(() => {}) + reader.releaseLock() + } +} + +function dataFromEvent(lines: string[]): string | null { + const data: string[] = [] + for (const line of lines) { + if (!line || line.startsWith(':')) continue + const separator = line.indexOf(':') + const field = separator === -1 ? line : line.slice(0, separator) + if (field !== 'data') continue + + const raw = separator === -1 ? '' : line.slice(separator + 1) + data.push(raw.startsWith(' ') ? raw.slice(1) : raw) + } + return data.length > 0 ? data.join('\n') : null +} + +function streamError(event: ChatEvent): SimApiError { + const detail = event.error + if (!detail || typeof detail !== 'object') { + return new SimApiError('Sim Chat failed.', 0) + } + + const error = detail as { code?: unknown; message?: unknown } + return new SimApiError( + typeof error.message === 'string' ? sanitize(error.message) : 'Sim Chat failed.', + 0, + typeof error.code === 'string' ? sanitize(error.code) : null + ) +} + +function tokenFrom(value: unknown): string | null { + if (!value || typeof value !== 'object') return null + const token = (value as { continuationToken?: unknown }).continuationToken + return typeof token === 'string' && token ? token : null +} + +function activityFrom(value: unknown): ChatActivityUpdate | null { + if (!value || typeof value !== 'object') return null + const data = value as Record<string, unknown> + if (data.kind === 'narration') { + if (typeof data.parentId !== 'string' || typeof data.delta !== 'string') return null + const parentId = safeOneLine(data.parentId).slice(0, 160) + const delta = sanitize(data.delta) + return parentId && delta ? { kind: 'narration', parentId, delta } : null + } + if (data.kind !== 'tool' && data.kind !== 'subagent') return null + if (data.state !== 'running' && data.state !== 'complete' && data.state !== 'error') return null + if (typeof data.id !== 'string' || typeof data.label !== 'string') return null + + const id = safeOneLine(data.id).slice(0, 160) + const label = safeOneLine(data.label).slice(0, 160) + const parentId = typeof data.parentId === 'string' ? safeOneLine(data.parentId).slice(0, 160) : '' + return id && label + ? { + kind: data.kind, + id, + label, + state: data.state, + ...(parentId && parentId !== id ? { parentId } : {}), + } + : null +} + +/** Reads one public chat turn, optionally forwarding raw text deltas to a safe renderer. */ +export async function readChatTurn( + response: Response, + options: ReadChatTurnOptions = {} +): Promise<ChatTurn> { + if (!response.body) throw new SimApiError('Sim Chat returned an empty response.', 0) + + let deltas = '' + let completedContent: string | null = null + let continuationToken: string | null = null + let sawComplete = false + let eventLines: string[] = [] + + const consume = async (): Promise<void> => { + const raw = dataFromEvent(eventLines) + eventLines = [] + if (raw === null || raw === '[DONE]') return + + let parsed: ChatEvent + try { + parsed = JSON.parse(raw) as ChatEvent + } catch { + throw new SimApiError('Sim Chat returned malformed streaming data.', 0) + } + + if (parsed.type === 'session') { + if (typeof parsed.chatId === 'string' && parsed.chatId) { + await options.onChatId?.(parsed.chatId) + } + if (typeof parsed.title === 'string') { + const title = safeOneLine(parsed.title).slice(0, 160) + if (title) await options.onTitle?.(title) + } + const token = tokenFrom(parsed) + if (token) { + continuationToken = token + await options.onContinuationToken?.(token) + } + return + } + if (parsed.type === 'text' && typeof parsed.delta === 'string') { + deltas += parsed.delta + await options.onDelta?.(parsed.delta) + return + } + if (parsed.type === 'thinking' && typeof parsed.delta === 'string') { + await options.onThinking?.(sanitize(parsed.delta)) + return + } + if (parsed.type === 'activity') { + const activity = activityFrom(parsed.data) + if (activity) await options.onActivity?.(activity) + return + } + if (parsed.type === 'error') throw streamError(parsed) + if (parsed.type !== 'complete') return + + sawComplete = true + if (parsed.data && typeof parsed.data === 'object') { + const content = (parsed.data as { content?: unknown }).content + if (typeof content === 'string') completedContent = content + continuationToken = tokenFrom(parsed.data) ?? continuationToken + } + } + + try { + for await (const line of linesOf(response.body)) { + if (line === '') await consume() + else eventLines.push(line) + } + if (eventLines.length > 0) await consume() + } catch (error) { + if (error instanceof SimApiError) throw error + const message = error instanceof Error ? error.message : String(error) + throw new SimApiError(`Sim Chat stream failed: ${sanitize(message)}`, 0) + } + + if (!sawComplete) throw new SimApiError('Sim Chat ended before completing.', 0) + return { + content: completedContent ?? deltas, + streamedContent: deltas, + continuationToken, + } +} + +/** Buffers the public chat SSE protocol and returns only the final assistant answer. */ +export async function readChatResponse(response: Response): Promise<string> { + return (await readChatTurn(response)).content +} + +function requestChat(client: SimClient, body: ChatRequest, signal: AbortSignal): Promise<Response> { + return client.requestRaw(V2_OPERATIONS.chat.path, { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body, + signal, + auth: 'optional', + }) +} + +function renderContext(interactive: boolean) { + return { printMode: !interactive } +} + +async function runOneShot( + client: SimClient, + workspaceId: string, + prompt: string, + attachments: ChatAttachment[], + readOnly: boolean, + dependencies: ChatDependencies +): Promise<void> { + const controller = new AbortController() + const cancel = () => controller.abort() + process.once('SIGINT', cancel) + + try { + const response = await requestChat( + client, + { + workspaceId, + prompt, + ...(readOnly ? { readOnly: true } : {}), + ...(attachments.length ? { attachments } : {}), + }, + controller.signal + ) + const result = await readChatTurn(response) + const segments = withoutTrailingStandaloneResource(parseChatStructured(result.content)) + const rendered = renderChatStructured(segments, renderContext(false)) + // Print mode deliberately has no ANSI/OSC of its own, so a final defense at + // the stdout boundary is safe and preserves shell composability. + dependencies.writeOutput(sanitize(rendered.text)) + } catch (error) { + if (controller.signal.aborted) throw new SimApiError('Sim Chat cancelled.', 0) + throw error + } finally { + process.removeListener('SIGINT', cancel) + } +} + +type UserTurnResult = + | { + kind: 'turn' + prompt: string + attachments: ChatAttachment[] + queued: boolean + display?: string + pastes?: ReadonlyMap<number, string> + contexts?: ChatContext[] + } + | { kind: 'clear'; attachments: ChatAttachment[] } + | { kind: 'chats'; attachments: ChatAttachment[] } + | { kind: 'rename'; title: string; attachments: ChatAttachment[] } + | { kind: 'idle'; attachments: ChatAttachment[] } + | { kind: 'exit' } + +function explainInteractiveCommands(terminal: ChatTerminal): void { + terminal.status( + [ + 'Commands:', + ' /attach <paths> attach local files to the next turn', + ' ctrl+v attach an image from the clipboard (or cmd+v on macOS)', + ' /clear start a new conversation', + ' /chats view and switch chats', + ' /rename <title> rename the active chat', + ' /help show this help', + ' /exit leave Sim Chat (alias: /quit)', + ].join('\n') + ) +} + +function attachmentStatus(attachments: ChatAttachment[]): string { + const names = attachments.map((attachment) => attachment.name).join(', ') + return `Attached for the next turn (${attachments.length}/${5}): ${names}` +} + +function attachmentCommand(paths: string[]): string | null { + if (paths.some((path) => /[\u0000-\u001f\u007f]/u.test(path))) return null + const quoted = paths.map((path) => `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) + return `/attach ${quoted.join(' ')}` +} + +async function addPaths( + current: ChatAttachment[], + paths: string[], + terminal: ChatTerminal, + dependencies: ChatDependencies +): Promise<ChatAttachment[]> { + try { + const additions = await dependencies.loadAttachments(paths) + const combined = combineChatAttachments(current, additions) + terminal.status(attachmentStatus(combined)) + return combined + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + terminal.status(`Error: ${message}`) + return current + } +} + +async function addClipboardImage( + current: ChatAttachment[], + terminal: ChatTerminal, + dependencies: ChatDependencies +): Promise<ChatAttachment[]> { + const image = await dependencies.clipboardImage() + /* Paste feedback is the `[Image #N]` tag in the composer, not a transcript + line: the tag says what was attached and disappears when it is deleted. */ + if (!image) return current + try { + const combined = combineChatAttachments(current, [image]) + terminal.noteAttachment() + return combined + } catch { + return current + } +} + +async function readUserTurn( + terminal: ChatTerminal, + initialAttachments: ChatAttachment[], + dependencies: ChatDependencies, + queuedOnly = false +): Promise<UserTurnResult> { + let attachments = initialAttachments + let lastEmptyInterrupt = 0 + + while (true) { + if (queuedOnly && !terminal.hasQueuedInput()) return { kind: 'idle', attachments } + const input = await terminal.read('❯ ') + if (input.kind === 'eof') return { kind: 'exit' } + if (input.kind === 'interrupt') { + const now = Date.now() + if (input.empty && now - lastEmptyInterrupt < 1_200) return { kind: 'exit' } + lastEmptyInterrupt = input.empty ? now : 0 + continue + } + if (input.kind === 'clipboard') { + attachments = await addClipboardImage(attachments, terminal, dependencies) + continue + } + if (input.kind === 'selection') continue + + const trimmed = input.value.trim() + if (trimmed === '/exit' || trimmed === '/quit') return { kind: 'exit' } + if (trimmed === '/help') { + explainInteractiveCommands(terminal) + continue + } + if (trimmed === '/clear') return { kind: 'clear', attachments } + if (trimmed === '/chats') { + return { kind: 'chats', attachments } + } + if (trimmed.startsWith('/chats ')) { + terminal.status('Usage: /chats (search inside the chat list).') + continue + } + if (trimmed === '/rename' || trimmed.startsWith('/rename ')) { + const title = safeOneLine(trimmed.slice('/rename'.length).trim()) + if (!title) { + terminal.status('Usage: /rename <title>') + continue + } + if (title.length > 200) { + terminal.status('Error: Chat title cannot exceed 200 characters.') + continue + } + return { kind: 'rename', title, attachments } + } + if (trimmed === '/attach' || trimmed.startsWith('/attach ')) { + const rawPaths = trimmed.slice('/attach'.length).trim() + if (!rawPaths) { + terminal.status('Usage: /attach <path> [more paths]') + continue + } + try { + attachments = await addPaths( + attachments, + parseAttachmentPaths(rawPaths), + terminal, + dependencies + ) + } catch (error) { + terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) + } + continue + } + if (trimmed) { + const pastedPaths = await dependencies.pastedAttachmentPaths(input.value) + if (pastedPaths) { + // A dragged path is still just user input. Preload an explicit command + // so the next Enter is the user's confirmation before any bytes are read. + const command = attachmentCommand(pastedPaths) + if (!command) { + terminal.status('The detected path cannot be safely preloaded. Use /attach manually.') + continue + } + if (!terminal.preload(command)) { + terminal.status( + 'File path detected, but newer composer input took priority. Use /attach to add it.' + ) + continue + } + terminal.status('File path detected. Press Enter to attach it, or edit the command.') + continue + } + } + if (trimmed.startsWith('/')) { + const taggedSlash = input.contexts?.some( + (context) => context.kind === 'skill' || context.kind === 'mcp' + ) + if (!taggedSlash) { + terminal.status(`Unknown command: ${trimmed.split(/\s/, 1)[0]}. Use /help.`) + continue + } + } + if (!trimmed && attachments.length === 0) continue + if (utf8Bytes(input.value) > MAX_CHAT_PROMPT_BYTES) { + terminal.status('Error: Chat input exceeds the 10 MiB limit.') + continue + } + return { + kind: 'turn', + prompt: input.value, + attachments, + queued: input.queued === true, + ...(input.display === undefined ? {} : { display: input.display }), + ...(input.pastes === undefined ? {} : { pastes: input.pastes }), + ...(input.contexts?.length ? { contexts: input.contexts } : {}), + } + } +} + +type QuestionAnswers = { kind: 'answer'; value: string } | { kind: 'cancel' } | { kind: 'exit' } + +async function answerQuestions( + terminal: ChatTerminal, + questions: ChatQuestion[] +): Promise<QuestionAnswers> { + const answers: string[] = [] + for (const [index, question] of questions.entries()) { + if (questions.length > 1) terminal.status(`Question ${index + 1} of ${questions.length}`) + const result = await terminal.askQuestion({ + prompt: question.prompt, + multi: question.type === 'multi_select', + options: question.options, + }) + if (result.kind === 'eof') return { kind: 'exit' } + if (result.kind === 'cancel') return { kind: 'cancel' } + answers.push(`${safeOneLine(question.prompt)} — ${result.values.map(safeOneLine).join(', ')}`) + } + return { kind: 'answer', value: answers.join('\n') } +} + +async function isChatTurnInput( + input: Extract<ChatTerminalInput, { kind: 'line' }>, + dependencies: Pick<ChatDependencies, 'pastedAttachmentPaths'> +): Promise<boolean> { + const trimmed = input.value.trim() + if (!trimmed) return false + if ( + trimmed.startsWith('/') && + !input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') + ) { + return false + } + return !(await dependencies.pastedAttachmentPaths(input.value)) +} + +function logSuggestionLabel( + log: ListLogsResponse['data'][number], + workflowNames: ReadonlyMap<string, string> +): string { + const workflow = + log.workflow?.name || + (log.workflowId ? workflowNames.get(log.workflowId) : undefined) || + log.workflowId || + 'Unknown workflow' + const started = new Date(log.startedAt) + const time = Number.isNaN(started.getTime()) ? log.startedAt : started.toLocaleString() + return `${workflow} · ${time}`.slice(0, 255) +} + +/** + * Builds the same two pools as the home composer from existing public lists: + * workspace resources under `@`, then skills and enabled MCP servers under `/`. + * Each request fails independently so one unavailable resource family does not + * disable the rest of the composer. + */ +function loadSuggestionCandidates( + client: SimClient, + workspaceId: string, + readOnly: boolean, + signal: AbortSignal, + publish: (candidates: ChatSuggestionCandidates) => void +): void { + const query = { workspaceId } + const resourceGroups = { + workflows: [] as SuggestionItem[], + tables: [] as SuggestionItem[], + files: [] as SuggestionItem[], + knowledge: [] as SuggestionItem[], + logs: [] as SuggestionItem[], + } + const slashGroups = { + skills: [] as SuggestionItem[], + mcp: [] as SuggestionItem[], + } + let workflowsForLogs: ListWorkflowsResponse['data'] = [] + let loadedLogs: ListLogsResponse['data'] | null = null + const publishCurrent = () => { + publish({ + resources: [ + ...resourceGroups.workflows, + ...resourceGroups.tables, + ...resourceGroups.files, + ...resourceGroups.knowledge, + ...resourceGroups.logs, + ], + slash: [...slashGroups.skills, ...slashGroups.mcp], + }) + } + const publishLogs = () => { + if (!loadedLogs) return + const workflowNames = new Map(workflowsForLogs.map((workflow) => [workflow.id, workflow.name])) + resourceGroups.logs = loadedLogs.slice(0, MAX_LOG_SUGGESTIONS).map((log) => { + const label = logSuggestionLabel(log, workflowNames) + return { + id: `logs:${log.executionId}`, + value: label, + displayText: label, + description: 'log', + tag: 'logs', + context: { kind: 'logs' as const, executionId: log.executionId, label }, + } + }) + publishCurrent() + } + + const workflowsRequest = requestAllPages<ListWorkflowsResponse['data'][number]>( + client, + V2_OPERATIONS.listWorkflows.path, + { + query, + pageSize: 50, + signal, + auth: 'optional', + } + ).catch(() => []) + void workflowsRequest.then((workflows) => { + workflowsForLogs = workflows + resourceGroups.workflows = workflows.map((workflow) => ({ + id: `workflow:${workflow.id}`, + value: workflow.name, + displayText: workflow.name, + description: 'workflow', + tag: 'workflow', + context: { + kind: 'workflow' as const, + workflowId: workflow.id, + label: workflow.name, + }, + })) + publishCurrent() + publishLogs() + }) + + void requestAllPages<ListTablesResponse['data'][number]>(client, V2_OPERATIONS.listTables.path, { + query, + pageSize: 100, + signal, + auth: 'optional', + }) + .catch(() => []) + .then((tables) => { + resourceGroups.tables = tables.map((table) => ({ + id: `table:${table.id}`, + value: table.name, + displayText: table.name, + description: 'table', + tag: 'table', + context: { kind: 'table' as const, tableId: table.id, label: table.name }, + })) + publishCurrent() + }) + + void requestAllPages<ListFilesResponse['data'][number]>(client, V2_OPERATIONS.listFiles.path, { + query, + pageSize: 100, + signal, + auth: 'optional', + }) + .catch(() => []) + .then((files) => { + resourceGroups.files = files.map((file) => ({ + id: `file:${file.id}`, + value: file.name, + displayText: file.name, + description: 'file', + tag: 'file', + context: { kind: 'file' as const, fileId: file.id, label: file.name }, + })) + publishCurrent() + }) + + void client + .request<ListKnowledgeBasesResponse>(V2_OPERATIONS.listKnowledgeBases.path, { + query, + signal, + auth: 'optional', + }) + .then((page) => page.data) + .catch(() => []) + .then((knowledge) => { + resourceGroups.knowledge = knowledge.map((base) => ({ + id: `knowledge:${base.id}`, + value: base.name, + displayText: base.name, + description: 'knowledge base', + tag: 'knowledge', + context: { kind: 'knowledge' as const, knowledgeId: base.id, label: base.name }, + })) + publishCurrent() + }) + + const logsRequest = client + .request<ListLogsResponse>(V2_OPERATIONS.listLogs.path, { + query: { workspaceId, details: 'basic', order: 'desc', limit: MAX_LOG_SUGGESTIONS }, + signal, + auth: 'optional', + }) + .then((page) => page.data) + .catch(() => []) + void logsRequest.then((logs) => { + loadedLogs = logs + publishLogs() + }) + + void client + .request<ListSkillsResponse>(V2_OPERATIONS.listSkills.path, { query, signal, auth: 'optional' }) + .catch(() => null) + .then((skills) => { + slashGroups.skills = (skills?.data ?? []).map((skill) => ({ + id: `skill:${skill.id}`, + value: skill.name, + displayText: `/${skill.name}`, + description: skill.description, + tag: 'skill', + context: { kind: 'skill' as const, skillId: skill.id, label: skill.name }, + })) + publishCurrent() + }) + + if (!readOnly) { + void client + .request<ListMcpServersResponse>(V2_OPERATIONS.listMcpServers.path, { + query, + signal, + auth: 'optional', + }) + .catch(() => null) + .then((servers) => { + slashGroups.mcp = (servers?.data ?? []) + .filter((server) => server.enabled !== false) + .map((server) => ({ + id: `mcp:${server.id}`, + value: server.name, + displayText: `/${server.name}`, + description: server.description ?? 'MCP server', + tag: 'mcp', + context: { kind: 'mcp' as const, serverId: server.id, label: server.name }, + })) + publishCurrent() + }) + } +} + +const NEW_CHAT_SELECTION_ID = 'sim-cli:new-chat' +const NEW_CHAT_TITLE = 'New chat' + +function chatMenuDescription(chat: ChatSummary, currentChatId?: string): string { + const labels: string[] = [] + if (chat.id === currentChatId) labels.push('current') + if (chat.pinned) labels.push('pinned') + if (chat.active) labels.push('active') + const updated = new Date(chat.updatedAt) + labels.push( + Number.isNaN(updated.getTime()) + ? `updated ${safeOneLine(chat.updatedAt)}` + : updated.toLocaleString() + ) + return labels.join(' · ') +} + +async function selectChat( + client: SimClient, + terminal: ChatTerminal, + workspaceId: string, + currentChatId?: string +): Promise<ChatTerminalSelectResult> { + const chats = await requestAllPages<ChatSummary>(client, V2_OPERATIONS.listChats.path, { + query: { workspaceId }, + pageSize: 100, + auth: 'optional', + }) + return terminal.select({ + prompt: 'Choose a chat', + options: [ + { + id: NEW_CHAT_SELECTION_ID, + label: NEW_CHAT_TITLE, + description: 'start a blank conversation', + }, + ...chats.map((chat) => ({ + id: chat.id, + label: chat.title?.trim() || 'Untitled chat', + description: chatMenuDescription(chat, currentChatId), + })), + ], + }) +} + +async function loadChat( + client: SimClient, + workspaceId: string, + chatId: string, + readOnly: boolean +): Promise<GetChatResponse['data']> { + const response = await client.request<GetChatResponse>( + resolvePath(V2_OPERATIONS.getChat.path, { chatId }), + { + query: { workspaceId, ...(readOnly ? { readOnly: true } : {}) }, + auth: 'optional', + } + ) + return response.data +} + +async function renameChat( + client: SimClient, + workspaceId: string, + chatId: string, + title: string +): Promise<string> { + const body: RenameChatBody = { workspaceId, title } + const response = await client.request<RenameChatResponse>( + resolvePath(V2_OPERATIONS.renameChat.path, { chatId }), + { method: 'PATCH', body, auth: 'optional' } + ) + return response.data.title +} + +function renderStoredAssistantMessage(content: string, formatMarkdown: boolean): string { + const rendered = renderChatStructured( + withoutTrailingStandaloneResource(parseChatStructured(content)), + renderContext(false) + ) + const markdown = new ChatMarkdownStream(formatMarkdown) + return `${markdown.push(rendered.text)}${markdown.finish()}` +} + +/** Removes a terminal-dead resource pointer that the web UI renders as a clickable panel link. */ +function withoutTrailingStandaloneResource( + segments: readonly ChatStructuredSegment[] +): ChatStructuredSegment[] { + let index = segments.length - 1 + let foundResource = false + + while (index >= 0) { + const segment = segments[index] + if (segment.kind === 'workspace_resource') { + foundResource = true + index -= 1 + continue + } + if (segment.kind === 'thinking' || segment.kind === 'options') { + index -= 1 + continue + } + if (segment.kind === 'text' && !segment.text.trim()) { + index -= 1 + continue + } + break + } + + const boundary = segments[index] + if ( + !foundResource || + boundary?.kind !== 'text' || + !boundary.text.trim() || + !/\n[^\S\n]*$/u.test(boundary.text) + ) { + return [...segments] + } + + return [ + ...segments.slice(0, index), + { ...boundary, text: boundary.text.trimEnd() }, + ...segments.slice(index + 1).filter((segment) => { + if (segment.kind === 'workspace_resource') return false + return segment.kind !== 'text' || Boolean(segment.text.trim()) + }), + ] +} + +function showChatHistory( + terminal: ChatTerminal, + title: string | null, + messages: ChatHistoryMessage[], + formatMarkdown: boolean, + status: 'resumed' | 'active' | 'still-active' +): void { + terminal.clearTranscript() + const name = safeOneLine(title ?? '') || 'Untitled chat' + terminal.setChatTitle(name) + const message = + status === 'active' + ? `Opened ${name}. This chat is currently active elsewhere.` + : status === 'still-active' + ? `Refreshed ${name}. This chat remains active elsewhere.` + : `Resumed ${name}.` + terminal.status(message) + for (const message of messages) { + if (message.role === 'user') { + terminal.userMessage(message.content) + continue + } + const rendered = renderStoredAssistantMessage(message.content, formatMarkdown) + if (!rendered) continue + terminal.write(rendered) + if (!rendered.endsWith('\n')) terminal.write('\n') + } +} + +/** + * Best-effort workspace name lookup, unawaited so the header paints + * immediately; a failure just leaves the row out. + */ +async function resolveWorkspaceName( + client: SimClient, + workspaceId: string +): Promise<string | null> { + try { + const response = await client.request<GetWorkspaceResponse>( + resolvePath(V2_OPERATIONS.getWorkspace.path, { workspaceId }), + { auth: 'optional' } + ) + return response.data.workspace.name || null + } catch { + return null + } +} + +async function runInteractive( + client: SimClient, + workspaceId: string, + initialPrompt: string, + initialAttachments: ChatAttachment[], + readOnly: boolean, + dependencies: ChatDependencies, + profileName?: string +): Promise<void> { + const terminal = dependencies.createTerminal() + const suggestionController = new AbortController() + let continuationToken: string | undefined + let currentChatId: string | undefined + let resumedChatActive = false + let pendingAttachments = initialAttachments + let nextPrompt: string | null = initialPrompt || (initialAttachments.length ? '' : null) + let nextPromptQueued = false + let nextPromptDisplay: string | undefined + let nextPromptPastes: ReadonlyMap<number, string> | undefined + let nextPromptContexts: ChatContext[] = [] + let nextPromptConflictRetries = 0 + let pendingQuestions: ChatQuestion[] = [] + + const startNewConversation = () => { + pendingQuestions = [] + continuationToken = undefined + currentChatId = undefined + resumedChatActive = false + terminal.clearTranscript() + terminal.setChatTitle(NEW_CHAT_TITLE) + terminal.status('Started a new conversation.') + } + + try { + terminal.welcome({ chatTitle: NEW_CHAT_TITLE, profile: profileName }) + void resolveWorkspaceName(client, workspaceId).then((name) => { + if (name) terminal.setWorkspaceName(name) + }) + loadSuggestionCandidates( + client, + workspaceId, + readOnly, + suggestionController.signal, + (candidates) => { + terminal.setSuggestionCandidates?.(candidates) + } + ) + if (initialPrompt.trim()) terminal.userMessage(initialPrompt) + while (true) { + if (nextPrompt === null) { + const input = await readUserTurn( + terminal, + pendingAttachments, + dependencies, + pendingQuestions.length > 0 + ) + if (input.kind === 'exit') return + pendingAttachments = input.attachments + if (input.kind === 'idle') { + const questions = pendingQuestions + pendingQuestions = [] + const questionAnswers = await answerQuestions(terminal, questions) + if (questionAnswers.kind === 'exit') return + if (questionAnswers.kind === 'cancel') continue + nextPrompt = questionAnswers.value + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + continue + } + if ((input.kind === 'clear' || input.kind === 'chats') && terminal.hasQueuedInput()) { + terminal.status('Finish queued prompts before changing conversations.') + continue + } + if (input.kind === 'clear') { + startNewConversation() + continue + } + if (input.kind === 'chats') { + pendingQuestions = [] + let selection: ChatTerminalSelectResult + try { + selection = await selectChat(client, terminal, workspaceId, currentChatId) + } catch (error) { + terminal.status( + `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` + ) + continue + } + if (selection.kind === 'eof') return + if (selection.kind === 'cancel') continue + if (selection.id === NEW_CHAT_SELECTION_ID) { + startNewConversation() + continue + } + try { + const chat = await loadChat(client, workspaceId, selection.id, readOnly) + if (!chat.continuationToken) { + throw new SimApiError('Sim Chat did not return a continuation token.', 0) + } + continuationToken = chat.continuationToken + currentChatId = chat.id + resumedChatActive = chat.active + showChatHistory( + terminal, + chat.title, + chat.messages, + dependencies.formatMarkdown(), + chat.active ? 'active' : 'resumed' + ) + } catch (error) { + terminal.status( + `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` + ) + } + continue + } + if (input.kind === 'rename') { + if (!currentChatId) { + terminal.status('Send a message before renaming this chat.') + continue + } + try { + const title = await renameChat(client, workspaceId, currentChatId, input.title) + terminal.setChatTitle(title) + terminal.status(`Renamed chat to ${title}.`) + } catch (error) { + terminal.status( + `Error: ${safeOneLine(error instanceof Error ? error.message : String(error))}` + ) + } + continue + } + pendingQuestions = [] + nextPrompt = input.prompt + nextPromptQueued = input.queued + nextPromptDisplay = input.display + nextPromptPastes = input.pastes + nextPromptContexts = input.contexts ?? [] + nextPromptConflictRetries = 0 + } + + if (resumedChatActive && currentChatId) { + const retryDisplay = nextPromptDisplay ?? nextPrompt + const restorePrompt = (): boolean => + terminal.preload(retryDisplay, { + queued: true, + pastes: nextPromptPastes, + ...(nextPromptContexts.length ? { contexts: nextPromptContexts } : {}), + }) + try { + const chat = await loadChat(client, workspaceId, currentChatId, readOnly) + continuationToken = chat.continuationToken + currentChatId = chat.id + resumedChatActive = chat.active + showChatHistory( + terminal, + chat.title, + chat.messages, + dependencies.formatMarkdown(), + chat.active ? 'still-active' : 'resumed' + ) + if (!chat.active) { + if (retryDisplay.trim()) terminal.userMessage(retryDisplay) + } else { + if (!restorePrompt()) { + terminal.status('The pending prompt could not be restored. Please enter it again.') + } + nextPrompt = null + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + continue + } + } catch (error) { + const restored = restorePrompt() + const message = safeOneLine(error instanceof Error ? error.message : String(error)) + terminal.status( + restored + ? `Error: ${message}. Press Enter to retry.` + : `Error: ${message}. Please enter the prompt again.` + ) + nextPrompt = null + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + continue + } + } + + const sentPrompt = nextPrompt + const sentPromptQueued = nextPromptQueued + const sentPromptDisplay = nextPromptDisplay + const sentPromptPastes = nextPromptPastes + const sentContexts = nextPromptContexts + const sentConflictRetries = nextPromptConflictRetries + const sentAttachments = pendingAttachments + pendingAttachments = [] + const controller = new AbortController() + let sessionReady = false + let submitRequested = false + let submitChecks = Promise.resolve() + const stopListening = terminal.onInterrupt((reason, input) => { + if (reason === 'manual') { + if (!controller.signal.aborted) controller.abort(reason) + return + } + if (input?.kind !== 'line') return + submitChecks = submitChecks.then(async () => { + if (!(await isChatTurnInput(input, dependencies))) return + if (!submitRequested) { + submitRequested = true + if (sessionReady && !controller.signal.aborted) controller.abort(reason) + } + }) + }) + const activity = terminal.activity('Thinking…') + const parser = new ChatStructuredParser() + const markdownEnabled = dependencies.formatMarkdown() + const markdown = new ChatMarkdownStream(markdownEnabled) + const narrationMarkdown = new Map<string, ChatMarkdownStream>() + const questions: ChatQuestion[] = [] + let wroteOutput = false + let pendingWhitespace = '' + let previousWasBlock = false + let outputFinalized = false + let strippedOptions = false + let deferredTrailingResourceParts: RenderPart[] | null = null + + const writePart = (value: string, block: boolean) => { + if (!value) return + let separator = '' + if (wroteOutput && (block || previousWasBlock)) { + const trailingNewlines = pendingWhitespace.match(/\n*$/u)?.[0].length ?? 0 + const leadingNewlines = value.match(/^\n*/u)?.[0].length ?? 0 + separator = '\n'.repeat(Math.max(0, 2 - trailingNewlines - leadingNewlines)) + } + const output = `${pendingWhitespace}${separator}${value}` + const trailing = output.match(/\s+$/u)?.[0] ?? '' + const ready = trailing ? output.slice(0, -trailing.length) : output + if (ready) { + terminal.write(ready) + wroteOutput = true + } + pendingWhitespace = trailing + previousWasBlock = block + } + + const flushDeferredTrailingResource = () => { + if (!deferredTrailingResourceParts) return + for (const part of deferredTrailingResourceParts) writePart(part.value, part.block) + deferredTrailingResourceParts = null + } + + const finishOutput = () => { + if (outputFinalized) return + outputFinalized = true + if (deferredTrailingResourceParts) { + if (wroteOutput) { + deferredTrailingResourceParts = null + pendingWhitespace = '' + } else { + flushDeferredTrailingResource() + } + } + writePart(markdown.finish(), false) + pendingWhitespace = '' + if (wroteOutput) terminal.write('\n') + } + + const finishNarration = (parentId: string) => { + const stream = narrationMarkdown.get(parentId) + if (!stream) return + const delta = stream.finish() + if (delta) activity.event({ kind: 'narration', parentId, delta }) + narrationMarkdown.delete(parentId) + } + + const finishNarrations = () => { + for (const parentId of [...narrationMarkdown.keys()]) finishNarration(parentId) + } + + const renderActivity = (update: ChatActivityUpdate) => { + if (update.kind === 'narration') { + let stream = narrationMarkdown.get(update.parentId) + if (!stream) { + stream = new ChatMarkdownStream(markdownEnabled) + narrationMarkdown.set(update.parentId, stream) + } + const delta = stream.push(update.delta) + if (delta) activity.event({ ...update, delta }) + return + } + + if (update.parentId) finishNarration(update.parentId) + if (update.kind === 'subagent' && update.state !== 'running') finishNarration(update.id) + activity.event(update) + } + + const renderSegments = async (segments: Parameters<typeof renderChatStructured>[0]) => { + const list = typeof segments === 'string' ? parseChatStructured(segments) : [...segments] + for (const segment of list) { + let displaySegment = segment + if (segment.kind === 'options') { + // Suggestions are hidden terminal metadata. Any whitespace the + // model emitted immediately before them belongs to that hidden UI, + // so do not leak it into the transcript or the next composer. + if (!deferredTrailingResourceParts) pendingWhitespace = '' + strippedOptions = true + } else if (segment.kind === 'text' && strippedOptions) { + const text = segment.text.replace(/^\s+/u, '') + if (!text) continue + displaySegment = { ...segment, text } + strippedOptions = false + } else if (segment.kind !== 'thinking') { + strippedOptions = false + } + const rendered = renderChatStructured([displaySegment], renderContext(true)) + if (displaySegment.kind === 'text') { + const value = markdown.push(rendered.text) + if (deferredTrailingResourceParts && !rendered.text.trim()) { + if (value) deferredTrailingResourceParts.push({ value, block: false }) + continue + } + flushDeferredTrailingResource() + writePart(value, false) + } else { + const inline = markdown.flushInline() + if (deferredTrailingResourceParts && !inline.trim()) { + if (inline) deferredTrailingResourceParts.push({ value: inline, block: false }) + } else { + flushDeferredTrailingResource() + writePart(inline, false) + } + /* Reuse the renderer's own block classification rather than + re-deriving it by segment kind, so the streaming and one-shot + paths cannot disagree about spacing. */ + if (displaySegment.kind === 'workspace_resource') { + if (deferredTrailingResourceParts) { + deferredTrailingResourceParts.push(...rendered.parts) + } else if (wroteOutput && pendingWhitespace.includes('\n')) { + deferredTrailingResourceParts = [...rendered.parts] + } else { + for (const part of rendered.parts) writePart(part.value, part.block) + } + } else { + if ( + deferredTrailingResourceParts && + (rendered.parts.length > 0 || rendered.interactions.length > 0) + ) { + flushDeferredTrailingResource() + } + for (const part of rendered.parts) writePart(part.value, part.block) + } + } + for (const interaction of rendered.interactions) { + if (interaction.kind === 'question') questions.push(...interaction.questions) + } + } + } + + try { + const response = await requestChat( + client, + { + workspaceId, + prompt: nextPrompt, + ...(readOnly ? { readOnly: true } : {}), + ...(continuationToken ? { continuationToken } : {}), + ...(sentAttachments.length ? { attachments: sentAttachments } : {}), + ...(sentContexts.length ? { contexts: sentContexts } : {}), + }, + controller.signal + ) + const result = await readChatTurn(response, { + onDelta: (delta) => { + finishNarrations() + activity.clear() + return renderSegments(parser.push(delta)) + }, + onThinking: (delta) => activity.thinking(delta), + onActivity: renderActivity, + onContinuationToken: (token) => { + continuationToken = token + sessionReady = true + if (submitRequested && !controller.signal.aborted) controller.abort('submit') + }, + onChatId: (chatId) => { + currentChatId = chatId + }, + onTitle: (title) => terminal.setChatTitle(title), + }) + if (result.streamedContent) { + // The completion is authoritative. Upstream normally mirrors every + // byte as a delta, but a proxy can omit the last buffered suffix; feed + // that suffix through the same parser before finalizing its state. + if ( + result.content.length > result.streamedContent.length && + result.content.startsWith(result.streamedContent) + ) { + await renderSegments(parser.push(result.content.slice(result.streamedContent.length))) + } + await renderSegments(parser.finish()) + } else { + finishNarrations() + activity.clear() + await renderSegments(result.content) + } + if (!result.continuationToken) { + throw new SimApiError('Sim Chat did not return a continuation token.', 0) + } + finishNarrations() + finishOutput() + activity.complete() + continuationToken = result.continuationToken + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + await submitChecks + if (submitRequested || questions.length === 0) { + pendingQuestions = [] + nextPrompt = null + } else if (terminal.hasQueuedInput()) { + pendingQuestions = questions + nextPrompt = null + } else { + const questionAnswers = await answerQuestions(terminal, questions) + if (questionAnswers.kind === 'exit') return + nextPrompt = questionAnswers.kind === 'answer' ? questionAnswers.value : null + } + } catch (error) { + await submitChecks + const queuedSubmit = controller.signal.aborted && controller.signal.reason === 'submit' + if (!queuedSubmit && !sessionReady) { + pendingAttachments = combineChatAttachments(sentAttachments, pendingAttachments) + } + finishNarrations() + finishOutput() + activity.stop() + if (controller.signal.aborted) { + if (!queuedSubmit) terminal.status('Generation cancelled.') + } else { + const message = error instanceof Error ? error.message : String(error) + const code = + error instanceof SimApiError && error.code ? ` (${safeOneLine(error.code)})` : '' + const conflict = + error instanceof SimApiError && error.status === 409 && error.code === 'CONFLICT' + if (conflict && currentChatId) resumedChatActive = true + if ( + conflict && + !sessionReady && + sentPromptQueued && + continuationToken && + sentConflictRetries < 1 + ) { + nextPrompt = sentPrompt + nextPromptQueued = true + nextPromptDisplay = sentPromptDisplay + nextPromptPastes = sentPromptPastes + nextPromptContexts = sentContexts + nextPromptConflictRetries = sentConflictRetries + 1 + terminal.status('Previous response is still settling. Retrying…') + continue + } + const restored = + !sessionReady && + (sentPromptQueued || conflict) && + terminal.preload(sentPromptDisplay ?? sentPrompt, { + queued: true, + pastes: sentPromptPastes, + ...(sentContexts.length ? { contexts: sentContexts } : {}), + }) + if (conflict && restored) { + terminal.status('Previous response is still settling. Press Enter to retry.') + } else { + terminal.status(`Error: ${safeOneLine(message)}${code}`) + } + } + nextPrompt = null + nextPromptQueued = false + nextPromptDisplay = undefined + nextPromptPastes = undefined + nextPromptContexts = [] + nextPromptConflictRetries = 0 + } finally { + activity.stop() + stopListening() + } + } + } finally { + suggestionController.abort() + terminal.close() + } +} + +function collectFile(value: string, previous: string[] = []): string[] { + return [...previous, value] +} + +/** Creates print-mode and interactive workspace chat. */ +export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command { + const dependencies: ChatDependencies = { + readInput: readPipedInput, + writeOutput: writeCompletedAnswer, + isInteractive: () => + Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY), + createTerminal: () => new ReadlineChatTerminal(), + loadAttachments: loadChatAttachments, + clipboardImage: readClipboardImage, + pastedAttachmentPaths: existingAttachmentPaths, + // The fullscreen chat already requires a TTY and uses ANSI throughout. A + // propagated TERM=dumb value must not leave model Markdown visible inside + // an otherwise fully rendered TUI. + formatMarkdown: () => Boolean(process.stdout.isTTY), + ...overrides, + } + + return new Command('chat') + .description('Ask Sim Chat about the active workspace') + .argument('[prompt...]', 'Question to ask') + .option('-p, --print', 'Print the final response and exit') + .option('-f, --file <path>', 'Attach a local file (repeatable)', collectFile, []) + .option('--read-only', 'Restrict Sim Chat to read-only workspace tools') + .action( + async ( + promptParts: string[], + options: { print?: boolean; file: string[]; readOnly?: boolean }, + command: Command + ) => { + const positionalPrompt = promptParts.join(' ') + const positionalBytes = utf8Bytes(positionalPrompt) + if (positionalBytes > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() + + const interactive = !options.print && dependencies.isInteractive() + if (!options.print && !interactive) { + throw new SimApiError( + 'Interactive Sim Chat requires a terminal. Use sim chat -p for pipelines or redirected output.', + 0 + ) + } + const separatorBytes = positionalPrompt ? 1 : 0 + const pipedInput = interactive + ? '' + : await dependencies.readInput(MAX_CHAT_PROMPT_BYTES - positionalBytes - separatorBytes) + const prompt = composeChatPrompt(promptParts, pipedInput) + if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) throw inputTooLarge() + + const attachments = await dependencies.loadAttachments(options.file ?? []) + if (!interactive && !prompt.trim() && attachments.length === 0) { + throw new SimApiError('Provide a prompt, attach a file, or pipe input to sim chat -p.', 0) + } + + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace(undefined, { auth: 'optional' }) + if (interactive) { + await runInteractive( + client, + workspaceId, + prompt, + attachments, + options.readOnly === true, + dependencies, + profile.name + ) + return + } + await runOneShot( + client, + workspaceId, + prompt, + attachments, + options.readOnly === true, + dependencies + ) + } + ) +} diff --git a/packages/sim-cli/src/commands/protocol/files-download.test.ts b/packages/sim-cli/src/commands/protocol/files-download.test.ts index 78b57300345..7dcda706b52 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.test.ts @@ -7,13 +7,14 @@ import { buildGeneratedCommands } from '../../runtime/build.js' import { streamToFile } from './files-download.js' import { attachProtocolCommands } from './index.js' -const { output } = vi.hoisted(() => ({ +const { output, requestRaw } = vi.hoisted(() => ({ output: { format: 'json' }, + requestRaw: vi.fn(), })) vi.mock('../../context.js', () => ({ clientFrom: () => ({ - client: { request: vi.fn(), requireWorkspace: () => 'ws_local' }, + client: { requestRaw, requireWorkspace: () => 'ws_local' }, profile: { workspaceId: 'ws_local', output: output.format, @@ -29,6 +30,7 @@ let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'sim-dl-')) output.format = 'json' + requestRaw.mockReset() }) afterEach(() => { @@ -88,7 +90,7 @@ describe('streamToFile', () => { describe('files download', () => { it('prints a normalized machine-readable result', async () => { const target = join(dir, 'download.txt') - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) const logged: string[] = [] vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line)) @@ -107,10 +109,14 @@ describe('files download', () => { path: target, status: 'saved', }) + expect(requestRaw).toHaveBeenCalledWith('/api/v2/files/file_1', { + method: 'GET', + query: { workspaceId: 'ws_local' }, + }) }) it('streams raw bytes to stdout with the conventional - destination', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('downloaded', { status: 200 }))) + requestRaw.mockResolvedValue(new Response('downloaded', { status: 200 })) const chunks: Uint8Array[] = [] vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) @@ -125,12 +131,9 @@ describe('files download', () => { }) it('rejects overwrite semantics for stdout', async () => { - const fetch = vi.fn() - vi.stubGlobal('fetch', fetch) - await expect( program().parseAsync(['node', 'sim', 'file', 'download', 'file_1', '-o', '-', '--force']) ).rejects.toThrow(/--force cannot be used/) - expect(fetch).not.toHaveBeenCalled() + expect(requestRaw).not.toHaveBeenCalled() }) }) diff --git a/packages/sim-cli/src/commands/protocol/files-download.ts b/packages/sim-cli/src/commands/protocol/files-download.ts index 6aba29862d3..9a4999c2184 100644 --- a/packages/sim-cli/src/commands/protocol/files-download.ts +++ b/packages/sim-cli/src/commands/protocol/files-download.ts @@ -3,7 +3,8 @@ import { createWriteStream, type WriteStream } from 'node:fs' import { basename } from 'node:path' import type { Command } from 'commander' import { clientFrom } from '../../context.js' -import { SimApiError } from '../../http/client.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' +import { resolvePath, SimApiError } from '../../http/client.js' import { printProtocolResult } from './result.js' /** Streams a fetch body to disk while honoring write-stream backpressure. */ @@ -82,24 +83,13 @@ export function attachFileDownload(files: Command): void { 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) - - // boundary-raw-fetch: binary download cannot pass through the JSON client - const response = await fetch(url, { - headers: { 'x-api-key': profile.apiKey }, + const operation = V2_OPERATIONS.downloadFile + const response = await client.requestRaw(resolvePath(operation.path, { fileId }), { + method: operation.method, + query: { workspaceId }, }) - if (!response.ok || !response.body) { - const raw = await response.text().catch(() => '') - throw new SimApiError( - raw || `Download failed with status ${response.status}`, - response.status - ) + if (!response.body) { + throw new SimApiError('Download returned an empty response.', response.status) } if (options.outputFile === '-') { diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index d0c7f3185bf..ca0d928e59d 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -4,6 +4,7 @@ import type { CompleteFileUploadResponse, CreateFileUploadResponse, } from '../../generated/v2-api.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' import { finishUploadSession } from '../../transfer/upload-session.js' import { printProtocolResult } from './result.js' @@ -19,16 +20,19 @@ export function attachFileUpload(files: Command): void { const workspaceId = client.requireWorkspace() const { name, size } = await localFile(path, options.name) - const created = await client.request<CreateFileUploadResponse>('/api/v2/files/uploads', { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - ...(options.folder !== undefined ? { folderPath: options.folder } : {}), - }, - }) + const created = await client.request<CreateFileUploadResponse>( + V2_OPERATIONS.createFileUpload.path, + { + method: 'POST', + body: { + workspaceId, + name, + contentType: contentTypeFor(name), + size, + ...(options.folder !== undefined ? { folderPath: options.folder } : {}), + }, + } + ) const { session, uploadToken, transfer } = created.data const completed = await finishUploadSession<CompleteFileUploadResponse['data']>( client, diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 33939b7cdde..b3520937f4d 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander' +import { chatCommand } from './chat.js' import { attachFileDownload } from './files-download.js' import { attachFileUpload } from './files-upload.js' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload.js' @@ -15,6 +16,8 @@ function group(program: Command, name: string): Command { /** Attaches commands whose multi-request or binary protocols cannot be generated. */ export function attachProtocolCommands(program: Command): void { + program.addCommand(chatCommand()) + const files = group(program, 'files') attachFileUpload(files) attachFileDownload(files) diff --git a/packages/sim-cli/src/commands/protocol/resource-directory.ts b/packages/sim-cli/src/commands/protocol/resource-directory.ts index e59f50c2200..c2e9d64f8bc 100644 --- a/packages/sim-cli/src/commands/protocol/resource-directory.ts +++ b/packages/sim-cli/src/commands/protocol/resource-directory.ts @@ -12,7 +12,7 @@ import { V2_OPERATIONS, type V2OperationName, } from '../../generated/v2-api.js' -import { SimApiError, type SimClient, type V2Page } from '../../http/client.js' +import { requestAllPages, SimApiError, type SimClient, type V2Page } from '../../http/client.js' import { type Column, printList, text, timestamp } from '../../output/render.js' import { DEFAULT_LIMIT } from '../../runtime/options.js' import { renderResult } from '../../runtime/result.js' @@ -103,20 +103,11 @@ async function listResources( return page.data.slice(0, limit) } - const resources: DirectoryResource[] = [] - let cursor: string | null = null - - do { - const remaining = limit - resources.length - const pageSize = Math.min(remaining, DEFAULT_LIMIT) - const page: V2Page<DirectoryResource> = await client.request(path, { - query: { ...query, limit: pageSize, cursor }, - }) - resources.push(...page.data) - cursor = page.nextCursor - } while (cursor && resources.length < limit) - - return resources.slice(0, limit) + return requestAllPages<DirectoryResource>(client, path, { + query, + pageSize: DEFAULT_LIMIT, + limit, + }) } async function listFolders( diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 78803a51f9e..fd6d4581b84 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -7,6 +7,7 @@ import type { CreateTableImportResponse, GetTableImportResponse, } from '../../generated/v2-api.js' +import { V2_OPERATIONS } from '../../generated/v2-api.js' import { SimApiError, type SimClient } from '../../http/client.js' import { coerce, type FieldSpec } from '../../runtime/request.js' import { contentTypeFor, localFile } from '../../transfer/local-file.js' @@ -146,19 +147,22 @@ export function attachTableImport(tables: Command): void { } } - const started = await client.request<CreateTableImportResponse>('/api/v2/tables/imports', { - method: 'POST', - body: { - workspaceId, - source, - target, - ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), - ...(options.createColumns - ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } - : {}), - ...(options.timezone ? { timezone: options.timezone } : {}), - }, - }) + const started = await client.request<CreateTableImportResponse>( + V2_OPERATIONS.createTableImport.path, + { + method: 'POST', + body: { + workspaceId, + source, + target, + ...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}), + ...(options.createColumns + ? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') } + : {}), + ...(options.timezone ? { timezone: options.timezone } : {}), + }, + } + ) let job: TableImport = started.data.session if (path) { diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 039261dceec..7ac3725d632 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -352,6 +352,59 @@ export type CancelWorkflowExecutionResponse = { } } +/** `POST /api/v2/chat` */ +export type ChatBody = { + workspaceId: string + prompt: string + continuationToken?: string + readOnly?: boolean + attachments?: Array<{ + name: string + mediaType: string + data: string + }> + contexts?: Array< + | { + kind: 'workflow' + workflowId: string + label: string + } + | { + kind: 'table' + tableId: string + label: string + } + | { + kind: 'file' + fileId: string + label: string + } + | { + kind: 'knowledge' + knowledgeId: string + label: string + } + | { + kind: 'logs' + executionId: string + label: string + } + | { + kind: 'skill' + skillId: string + label: string + } + | { + kind: 'mcp' + serverId: string + label: string + } + > +} + +/** Non-JSON response (`stream`). */ +export type ChatResponse = never + /** `POST /api/v2/files/uploads/[uploadId]/complete` */ export type CompleteFileUploadParams = { uploadId: string @@ -1195,25 +1248,6 @@ export type CreateTableRowsBody = | { workspaceId: string data: unknown - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } afterRowId?: string beforeRowId?: string } @@ -1951,6 +1985,31 @@ export type GetBillingStatusResponse = { } } +/** `GET /api/v2/chats/[chatId]` */ +export type GetChatParams = { + chatId: string +} + +export type GetChatQuery = { + workspaceId: string + readOnly?: boolean +} + +export type GetChatResponse = { + data: { + id: string + title: string | null + messages: Array<{ + id: string + role: 'user' | 'assistant' + content: string + timestamp: string + }> + continuationToken: string + active: boolean + } +} + /** `GET /api/v2/credentials/[id]` */ export type GetCredentialParams = { id: string @@ -2548,6 +2607,24 @@ export type GetWorkflowVersionResponse = { } } +/** `GET /api/v2/workspaces/[workspaceId]` */ +export type GetWorkspaceParams = { + workspaceId: string +} + +export type GetWorkspaceResponse = { + data: { + workspace: { + id: string + name: string + color: string + logoUrl: string | null + createdAt: string + updatedAt: string + } + } +} + /** `POST /api/v2/workflows/import` */ export type ImportWorkflowBody = { workspaceId: string @@ -2647,6 +2724,25 @@ export type ListBillingLogsResponse = { nextCursor: string | null } +/** `GET /api/v2/chats` */ +export type ListChatsQuery = { + workspaceId: string + search?: string + limit?: number + cursor?: string +} + +export type ListChatsResponse = { + data: Array<{ + id: string + title: string | null + updatedAt: string + pinned: boolean + active: boolean + }> + nextCursor: string | null +} + /** `GET /api/v2/credentials` */ export type ListCredentialsQuery = { workspaceId: string @@ -3380,6 +3476,23 @@ export type RelocateWorkflowFolderResponse = { } } +/** `PATCH /api/v2/chats/[chatId]` */ +export type RenameChatParams = { + chatId: string +} + +export type RenameChatBody = { + workspaceId: string + title: string +} + +export type RenameChatResponse = { + data: { + id: string + title: string + } +} + /** `PATCH /api/v2/files/[fileId]` */ export type RenameFileParams = { fileId: string @@ -3835,25 +3948,6 @@ export type UpdateRowsByFilterBody = { filter: unknown data: unknown limit?: number - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } } export type UpdateRowsByFilterResponse = { @@ -3995,25 +4089,6 @@ export type UpdateTableRowParams = { export type UpdateTableRowBody = { workspaceId: string data: unknown - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } } export type UpdateTableRowResponse = { @@ -4258,25 +4333,6 @@ export type UpsertTableRowBody = { workspaceId: string data: unknown conflictTarget?: string - __privateSecretProvenance?: { - version: 1 - complete: boolean - selections: Array<{ - key: string - provenance: { - version: 1 - complete: boolean - entries: Array<{ - encryptedValue: string - name?: string - }> - scope?: { - userId: string - workspaceId?: string - } - } - }> - } } export type UpsertTableRowResponse = { @@ -4400,6 +4456,21 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Cancel an execution', }, + chat: { + method: 'POST', + path: '/api/v2/chat', + pathParams: [] as const, + responseMode: 'stream', + summary: 'Ask Sim Chat', + body: { + workspaceId: { kind: 'string', required: true }, + prompt: { kind: 'string', required: true }, + continuationToken: { kind: 'string' }, + readOnly: { kind: 'boolean', default: false }, + attachments: { kind: 'array' }, + contexts: { kind: 'array' }, + }, + }, completeFileUpload: { method: 'POST', path: '/api/v2/files/uploads/[uploadId]/complete', @@ -4993,6 +5064,17 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string' }, }, }, + getChat: { + method: 'GET', + path: '/api/v2/chats/[chatId]', + pathParams: ['chatId'] as const, + responseMode: 'json', + summary: 'Open Sim Chat', + query: { + workspaceId: { kind: 'string', required: true }, + readOnly: { kind: 'boolean' }, + }, + }, getCredential: { method: 'GET', path: '/api/v2/credentials/[id]', @@ -5155,6 +5237,13 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workflow Version', }, + getWorkspace: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]', + pathParams: ['workspaceId'] as const, + responseMode: 'json', + summary: 'Get Workspace', + }, importWorkflow: { method: 'POST', path: '/api/v2/workflows/import', @@ -5222,6 +5311,19 @@ export const V2_OPERATIONS = { cursor: { kind: 'string' }, }, }, + listChats: { + method: 'GET', + path: '/api/v2/chats', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Sim Chats', + query: { + workspaceId: { kind: 'string', required: true }, + search: { kind: 'string' }, + limit: { kind: 'number', default: 30 }, + cursor: { kind: 'string' }, + }, + }, listCredentials: { method: 'GET', path: '/api/v2/credentials', @@ -5642,6 +5744,17 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true }, }, }, + renameChat: { + method: 'PATCH', + path: '/api/v2/chats/[chatId]', + pathParams: ['chatId'] as const, + responseMode: 'json', + summary: 'Rename Sim Chat', + body: { + workspaceId: { kind: 'string', required: true }, + title: { kind: 'string', required: true }, + }, + }, renameFile: { method: 'PATCH', path: '/api/v2/files/[fileId]', @@ -5821,7 +5934,6 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', required: true }, data: { kind: 'unknown', required: true }, limit: { kind: 'integer' }, - __privateSecretProvenance: { kind: 'object' }, }, }, updateSkill: { @@ -5871,7 +5983,6 @@ export const V2_OPERATIONS = { body: { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, - __privateSecretProvenance: { kind: 'object' }, }, }, updateTableView: { @@ -5955,7 +6066,6 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true }, data: { kind: 'unknown', required: true }, conflictTarget: { kind: 'string' }, - __privateSecretProvenance: { kind: 'object' }, }, }, } as const diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 8102090d8f5..5b52cab2b14 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -1,12 +1,44 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands.js' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api.js' -import { formatApiErrorDetails, resolvePath, SimApiError, SimClient } from './client.js' +import { + formatApiErrorDetails, + requestAllPages, + resolvePath, + SimApiError, + SimClient, +} from './client.js' afterEach(() => { vi.unstubAllGlobals() }) +describe('cursor pagination', () => { + it('follows v2 cursors through the requested item limit', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ data: ['a', 'b'], nextCursor: 'next' }) + .mockResolvedValueOnce({ data: ['c'], nextCursor: null }) + + await expect( + requestAllPages<string>({ request } as Pick<SimClient, 'request'>, '/api/v2/items', { + query: { workspaceId: 'workspace-1' }, + pageSize: 2, + limit: 3, + auth: 'optional', + }) + ).resolves.toEqual(['a', 'b', 'c']) + expect(request).toHaveBeenNthCalledWith(1, '/api/v2/items', { + query: { workspaceId: 'workspace-1', limit: 2, cursor: null }, + auth: 'optional', + }) + expect(request).toHaveBeenNthCalledWith(2, '/api/v2/items', { + query: { workspaceId: 'workspace-1', limit: 1, cursor: 'next' }, + auth: 'optional', + }) + }) +}) + describe('API errors', () => { it('keeps structured details and does not misdiagnose an ordinary 404', async () => { vi.stubGlobal( @@ -82,6 +114,93 @@ describe('API errors', () => { }) }) +describe('raw requests', () => { + function client(options: { apiKey?: string } = { apiKey: 'key' }): SimClient { + return new SimClient({ + name: 'default', + endpoint: 'https://sim.example', + apiKey: options.apiKey ?? null, + workspaceId: 'ws_1', + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'env', + workspaceId: 'env', + output: 'default', + }, + }) + } + + it('returns an unconsumed response and forwards an abort signal', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('stream body')) + vi.stubGlobal('fetch', fetch) + const controller = new AbortController() + + const response = await client().requestRaw('/api/v2/chat', { + method: 'POST', + headers: { accept: 'text/event-stream' }, + body: { workspaceId: 'ws_1', prompt: 'hello' }, + signal: controller.signal, + }) + + expect(response.bodyUsed).toBe(false) + expect(await response.text()).toBe('stream body') + expect(fetch).toHaveBeenCalledWith( + 'https://sim.example/api/v2/chat', + expect.objectContaining({ + method: 'POST', + signal: controller.signal, + headers: expect.objectContaining({ + accept: 'text/event-stream', + 'content-type': 'application/json', + 'x-api-key': 'key', + }), + }) + ) + }) + + it('turns an aborted fetch into a clean CLI error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'))) + const controller = new AbortController() + controller.abort() + + await expect( + client().requestRaw('/api/v2/chat', { signal: controller.signal }) + ).rejects.toMatchObject({ + message: 'Request cancelled.', + status: 0, + }) + }) + + it('allows auth-disabled self-hosted chat without sending an API key', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('stream body')) + vi.stubGlobal('fetch', fetch) + const unauthenticated = client({}) + const workspaceId = unauthenticated.requireWorkspace(undefined, { auth: 'optional' }) + + await unauthenticated.requestRaw('/api/v2/chat', { + method: 'POST', + body: { workspaceId, prompt: 'hello' }, + auth: 'optional', + }) + + expect(workspaceId).toBe('ws_1') + expect(fetch).toHaveBeenCalledOnce() + const headers = fetch.mock.calls[0][1].headers as Record<string, string> + expect(headers).not.toHaveProperty('x-api-key') + }) + + it('keeps authentication required by default for every other command', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const unauthenticated = client({}) + + expect(() => unauthenticated.requireWorkspace()).toThrow(/Not logged in/) + await expect(unauthenticated.requestRaw('/api/v2/workflows')).rejects.toThrow(/Not logged in/) + expect(fetch).not.toHaveBeenCalled() + }) +}) + describe('resolvePath', () => { it('substitutes a path parameter', () => { expect(resolvePath('/api/v2/tables/[tableId]/rows', { tableId: 'tbl_1' })).toBe( diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index f195e6364d2..ccecd13ecdf 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -24,7 +24,16 @@ export interface V2Page<T> { nextCursor: string | null } +export interface RequestAllPagesOptions extends Omit<RequestOptions, 'query'> { + query?: Record<string, QueryValue> + /** Server page size; callers choose one accepted by the endpoint contract. */ + pageSize: number + /** Maximum items to return. Omit to follow the cursor through the full list. */ + limit?: number +} + export type QueryValue = string | number | boolean | null | undefined +export type AuthRequirement = 'required' | 'optional' export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' @@ -32,6 +41,14 @@ export interface RequestOptions { body?: unknown /** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */ headers?: Record<string, string> + /** Cancels both the initial request and any subsequent streaming body read. */ + signal?: AbortSignal + /** Self-hosted, auth-disabled routes may deliberately omit a local API key. */ + auth?: AuthRequirement +} + +export interface WorkspaceOptions { + auth?: AuthRequirement } function buildUrl(endpoint: string, path: string, query?: Record<string, QueryValue>): string { @@ -122,8 +139,9 @@ export function formatApiErrorDetails(details: unknown): string[] { export class SimClient { constructor(private readonly profile: ResolvedProfile) {} - private requireAuth(): string { + private resolveApiKey(auth: AuthRequirement = 'required'): string | undefined { if (!this.profile.apiKey) { + if (auth === 'optional') return undefined throw new SimApiError( `Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.name}`, 0 @@ -135,12 +153,13 @@ export class SimClient { /** * 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. + * By default this 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. Auth-disabled self-hosted protocols opt out explicitly. */ - requireWorkspace(explicit?: string): string { - this.requireAuth() + requireWorkspace(explicit?: string, options: WorkspaceOptions = {}): string { + this.resolveApiKey(options.auth) const workspaceId = explicit ?? this.profile.workspaceId if (!workspaceId) { throw new SimApiError( @@ -151,8 +170,16 @@ export class SimClient { return workspaceId } - async request<T>(path: string, options: RequestOptions = {}): Promise<T> { - const apiKey = this.requireAuth() + /** + * Makes a request without consuming its body. Authentication is required + * unless a self-hosted protocol explicitly opts out. + * + * JSON commands use {@link request}; streaming and binary protocols keep the + * raw response so they can process bytes incrementally. HTTP failures still + * become the same structured `SimApiError` either way. + */ + async requestRaw(path: string, options: RequestOptions = {}): Promise<Response> { + const apiKey = this.resolveApiKey(options.auth) const url = buildUrl(this.profile.endpoint, path, options.query) const hasBody = options.body !== undefined @@ -162,23 +189,26 @@ export class SimClient { response = await fetch(url, { method: options.method ?? 'GET', headers: { - 'x-api-key': apiKey, + ...(apiKey ? { 'x-api-key': apiKey } : {}), accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}), ...options.headers, }, body: hasBody ? JSON.stringify(options.body) : undefined, + signal: options.signal, }) } catch (cause) { + if (options.signal?.aborted) { + throw new SimApiError('Request cancelled.', 0) + } throw new SimApiError( `Could not reach ${this.profile.endpoint}: ${(cause as Error).message}`, 0 ) } - const raw = await response.text() - if (!response.ok) { + const raw = await response.text() const error = toApiError(response.status, raw) if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.name}` @@ -186,11 +216,46 @@ export class SimClient { throw error } + return response + } + + async request<T>(path: string, options: RequestOptions = {}): Promise<T> { + const response = await this.requestRaw(path, options) + const raw = await response.text() + if (!raw) return undefined as T return JSON.parse(raw) as T } } +/** Follows a standard v2 cursor envelope without duplicating pagination loops. */ +export async function requestAllPages<T>( + client: Pick<SimClient, 'request'>, + path: string, + options: RequestAllPagesOptions +): Promise<T[]> { + const { query, pageSize, limit: requestedLimit, ...requestOptions } = options + const limit = requestedLimit ?? Number.POSITIVE_INFINITY + if (limit <= 0) return [] + + const items: T[] = [] + let cursor: string | null = null + do { + const page: V2Page<T> = await client.request<V2Page<T>>(path, { + ...requestOptions, + query: { + ...query, + limit: Math.min(pageSize, limit - items.length), + cursor, + }, + }) + items.push(...page.data) + cursor = page.nextCursor + } while (cursor && items.length < limit) + + return items.slice(0, limit) +} + /** * Substitutes `[id]`-style path segments. * diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 77aa78911bc..4fedd5db663 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -16,7 +16,7 @@ program .name('sim') .description('Talk to the Sim API from your terminal') .version('0.1.0') - .option('-p, --profile <name>', 'Profile to use (env: SIM_PROFILE)') + .option('-P, --profile <name>', 'Profile to use (env: SIM_PROFILE)') .option('--endpoint <url>', 'Sim deployment to talk to (env: SIM_ENDPOINT)') .option('-w, --workspace <id>', 'Workspace to target (env: SIM_WORKSPACE)') .addOption( @@ -39,11 +39,12 @@ 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. +~/.sim/credentials (0600). Select one with -P, --profile, or SIM_PROFILE. Examples: $ sim login Authorize the default profile $ sim login --profile dev --endpoint http://localhost:3000 + $ sim chat -p "Which workflows handle support tickets?" $ sim workflows list $ sim logs list --level error --limit 20 $ sim --output json tables get tbl_123 Override output for one command @@ -65,12 +66,12 @@ async function main() { await program.parseAsync(process.argv) } catch (error) { if (error instanceof ProfileConfigError) { - console.error(chalk.red(`Error: ${error.message}`)) + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) process.exit(1) } if (error instanceof SimApiError) { - console.error(chalk.red(`Error: ${error.message}`)) - if (error.code) console.error(chalk.dim(` code: ${error.code}`)) + console.error(chalk.red(`Error: ${sanitize(error.message)}`)) + if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) if (error.details !== undefined) { for (const line of formatApiErrorDetails(error.details)) { console.error(chalk.dim(sanitize(line))) diff --git a/packages/sim-cli/src/output/render.test.ts b/packages/sim-cli/src/output/render.test.ts index ce554b63b5c..41b7ac06f56 100644 --- a/packages/sim-cli/src/output/render.test.ts +++ b/packages/sim-cli/src/output/render.test.ts @@ -231,6 +231,10 @@ describe('sanitize', () => { expect(sanitize('a\u0000b\u0008c\u009bd')).toBe('abcd') }) + it('removes bidi formatting controls while preserving ordinary RTL text', () => { + expect(sanitize('safe\u202eevil\u202c \u2066host\u2069 مرحبا')).toBe('safeevil host مرحبا') + }) + it('takes the following byte with a bare ESC, since ESC + printable is a sequence', () => { expect(sanitize('a\u001bdb')).toBe('ab') }) @@ -239,6 +243,10 @@ describe('sanitize', () => { expect(sanitize('a\tb\nc')).toBe('a\tb\nc') }) + it('normalizes CRLF and removes a lone carriage return that could overwrite a line', () => { + expect(sanitize('first\r\nsecond\roverwrite')).toBe('first\nsecondoverwrite') + }) + it('leaves ordinary text untouched', () => { expect(sanitize('refund policy — 30 days')).toBe('refund policy — 30 days') }) diff --git a/packages/sim-cli/src/output/render.ts b/packages/sim-cli/src/output/render.ts index 5235356e2e5..46cc97008d9 100644 --- a/packages/sim-cli/src/output/render.ts +++ b/packages/sim-cli/src/output/render.ts @@ -1,6 +1,7 @@ import chalk from 'chalk' import { dump } from 'js-yaml' import type { OutputFormat } from '../config/index.js' +import { displayWidth } from './terminal-text.js' export interface Column<T> { header: string @@ -38,11 +39,16 @@ const CONTROL_PATTERN = new RegExp( // 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 + '[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1; CR is normalized below ].join('|'), 'g' ) +// Directional formatting marks can visually reorder an otherwise safe label +// or URL without changing its underlying bytes. Remove only the explicit +// controls; ordinary Hebrew, Arabic, and other right-to-left text is preserved. +const BIDI_CONTROL_PATTERN = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu + /** * Removes terminal control sequences from a server-supplied string. * @@ -51,7 +57,21 @@ const CONTROL_PATTERN = new RegExp( * formatting too. */ export function sanitize(value: string): string { - return value.replace(CONTROL_PATTERN, '') + // Preserve normal Windows line endings without leaving a lone carriage + // return capable of moving the cursor back over already-rendered text. + return value + .replace(/\r\n/g, '\n') + .replace(/\r/g, '') + .replace(CONTROL_PATTERN, '') + .replace(BIDI_CONTROL_PATTERN, '') +} + +/** Flattens untrusted terminal text into one compact, display-safe line. */ +export function safeOneLine(value: string): string { + return sanitize(value) + .replace(/[\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() } export function text(value: unknown): string { @@ -111,8 +131,15 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') * skew every coloured column, so widths are measured on the stripped text while * the coloured text is what gets printed. */ +/** + * Visible width of a cell. + * + * Delegates to the grapheme-aware measurement: the previous implementation + * counted stripped string length, so emoji and East Asian characters measured + * as one column and mis-aligned every table containing them. + */ export function visibleWidth(value: string): number { - return value.replace(ANSI_PATTERN, '').length + return displayWidth(value) } /** diff --git a/packages/sim-cli/src/output/terminal-text.ts b/packages/sim-cli/src/output/terminal-text.ts new file mode 100644 index 00000000000..734f18b61d7 --- /dev/null +++ b/packages/sim-cli/src/output/terminal-text.ts @@ -0,0 +1,106 @@ +/** + * Grapheme-aware terminal text primitives. + * + * Extracted from the chat terminal because they are pure and have no dependency + * on it: width, truncation, padding and cursor-index arithmetic that correctly + * handle combining marks, emoji and East Asian wide characters. `output/render` + * previously carried weaker copies that measured by string length. + */ +const RESET = `${String.fromCharCode(27)}[0m` + +const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +export function graphemes(value: string): Array<{ segment: string; index: number }> { + return [...GRAPHEME_SEGMENTER.segment(value)].map(({ segment, index }) => ({ segment, index })) +} +/** First grapheme cluster of a string, or null when it is empty. */ +export function firstGrapheme(value: string): string | null { + return GRAPHEME_SEGMENTER.segment(value)[Symbol.iterator]().next().value?.segment ?? null +} + +export function previousGraphemeIndex(value: string, cursor: number): number { + let previous = 0 + for (const part of graphemes(value)) { + if (part.index >= cursor) break + previous = part.index + } + return previous +} +export function nextGraphemeIndex(value: string, cursor: number): number { + for (const part of graphemes(value)) { + if (part.index > cursor) return part.index + if (part.index === cursor) return part.index + part.segment.length + } + return value.length +} +export function lineStart(value: string, cursor: number): number { + const newline = value.lastIndexOf('\n', Math.max(0, cursor - 1)) + return newline < 0 ? 0 : newline + 1 +} +export function lineEnd(value: string, cursor: number): number { + const newline = value.indexOf('\n', cursor) + return newline < 0 ? value.length : newline +} +export function displayWidth(value: string): number { + let width = 0 + for (const part of graphemes(value.replace(/\u001b\[[0-9;:]*m/gu, ''))) { + width += graphemeWidth(part.segment) + } + return width +} +export function graphemeWidth(value: string): number { + if (!value || value === '\n') return 0 + if (/^\p{Mark}+$/u.test(value)) return 0 + if (value.includes('\u200d') || /\p{Extended_Pictographic}/u.test(value)) return 2 + const codePoint = value.codePointAt(0) ?? 0 + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0 + return isWideCodePoint(codePoint) ? 2 : 1 +} +export function isWideCodePoint(codePoint: number): boolean { + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)) + ) +} +export function truncateDisplay(value: string, width: number): string { + if (displayWidth(value) <= width) return value + const target = Math.max(0, width - 1) + let result = '' + let used = 0 + for (const part of graphemes(value)) { + const partWidth = displayWidth(part.segment) + if (used + partWidth > target) break + result += part.segment + used += partWidth + } + return `${result}…${RESET}` +} +export function tailToWidth(value: string, width: number): string { + if (displayWidth(value) <= width) return value + const target = Math.max(0, width - 1) + const parts = graphemes(value) + let result = '' + let used = 0 + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index] + const partWidth = displayWidth(part.segment) + if (used + partWidth > target) break + result = `${part.segment}${result}` + used += partWidth + } + return `…${result}` +} +/** Squares off a ragged art line so every box border starts at the same column. */ +export function artPad(line: string, width: number): string { + return ' '.repeat(Math.max(0, width - displayWidth(line))) +} diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index c9d98db84fc..2d352c73a3f 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -9,5 +9,5 @@ export interface OperationSpec { body?: Record<string, FieldSpec> opaqueBody?: boolean summary?: string - responseMode?: 'json' | 'binary' + responseMode?: 'json' | 'binary' | 'stream' } diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index b9056243cb0..e2647dbec24 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -46,7 +46,7 @@ export async function localFile(path: string, override?: string): Promise<LocalF let size: number try { const stats = await stat(path) - if (stats.isDirectory()) throw new SimApiError(`${path} is a directory`, 0) + if (!stats.isFile()) throw new SimApiError(`${path} is not a regular file`, 0) size = stats.size } catch (error) { if (error instanceof SimApiError) throw error diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 5fa5ad826a5..4a6aaf4386e 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -76,7 +76,9 @@ const contractKey = (c: ContractLike) => async function loadContracts(): Promise<Map<string, { name: string; contract: ContractLike }>> { const registry = new Map<string, { name: string; contract: ContractLike }>() const files = readdirSync(V2_CONTRACTS_DIR) - .filter((f) => f.endsWith('.ts') && f !== 'shared.ts') + .filter( + (f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && f !== 'index.ts' && f !== 'shared.ts' + ) .map((f) => path.join(V2_CONTRACTS_DIR, f)) for (const file of [...files, ...EXTRA_CONTRACT_MODULES]) { const mod = (await import(file)) as Record<string, unknown> From de263204fda9af05eb22aff0925227aef3110b50 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <siddharthganesan@gmail.com> Date: Sat, 8 Aug 2026 11:36:28 -0700 Subject: [PATCH 46/46] feat(cli): refine chat and desktop updates --- .github/workflows/desktop-e2e.yml | 4 +- .github/workflows/desktop-release.yml | 4 +- .../desktop/src/main/desktop-settings.test.ts | 16 ++- apps/desktop/src/main/desktop-settings.ts | 8 +- apps/desktop/src/main/terminal-themes.test.ts | 18 ++- apps/desktop/src/main/terminal-themes.ts | 43 ++++--- apps/desktop/src/main/updater.test.ts | 28 ++++ apps/desktop/src/main/updater.ts | 15 ++- .../terminal-session/terminal-session.tsx | 43 ++----- .../resource-content/resource-content.tsx | 28 +++- apps/sim/lib/desktop/appearance.test.ts | 56 +++++++- apps/sim/lib/desktop/appearance.ts | 31 +++++ apps/sim/lib/desktop/index.ts | 10 ++ packages/desktop-bridge/src/index.ts | 81 ++++++++---- .../protocol/chat-attachments.test.ts | 24 ---- .../src/commands/protocol/chat-attachments.ts | 121 ++++++++++++++---- .../protocol/chat-path-extraction.test.ts | 77 +++++++++++ .../src/commands/protocol/chat-suggestions.ts | 7 - .../commands/protocol/chat-terminal.test.ts | 8 +- .../src/commands/protocol/chat-terminal.ts | 26 ++-- .../src/commands/protocol/chat.test.ts | 79 +++++------- .../sim-cli/src/commands/protocol/chat.ts | 104 +++++---------- 22 files changed, 551 insertions(+), 280 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 4feb7b91318..7632b0bea66 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -17,7 +17,7 @@ concurrency: jobs: e2e: name: E2E (${{ matrix.electron }}) - runs-on: macos-14 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -58,7 +58,7 @@ jobs: package-smoke: name: Unsigned package smoke - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d8a9e650d89..17d7e298a5d 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -49,7 +49,7 @@ permissions: jobs: build-sign-notarize: name: Build, Sign, Notarize - runs-on: macos-14 + runs-on: macos-26 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -167,8 +167,8 @@ jobs: if: ${{ inputs.sign }} run: | DMG="$(ls apps/desktop/release/*.dmg | head -1)" - xcrun stapler validate "$DMG" hdiutil attach "$DMG" -mountpoint /tmp/sim-dmg -nobrowse -quiet + xcrun stapler validate /tmp/sim-dmg/*.app spctl --assess --type execute --verbose /tmp/sim-dmg/*.app codesign --verify --deep --strict /tmp/sim-dmg/*.app hdiutil detach /tmp/sim-dmg -quiet diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index 25c137aa8ca..b7849a57558 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -15,6 +15,14 @@ const IMPORTED_PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const IMPORTED_LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} +const IMPORTED_DARK_PALETTE = { + ...TERMINAL_DARK_THEME, + background: '#202020', +} function makeService() { const config = createConfigStore( @@ -191,7 +199,7 @@ describe('desktop settings service', () => { expect(preferences?.browserDownloadDirectory).toBe('/tmp/custom-downloads') }) - it('caches and selects a Terminal or iTerm2 profile', () => { + it('persists a Terminal or iTerm2 profile with appearance-specific palettes', () => { const { config, service } = makeService() const preferences = service.selectTerminalProfile({ @@ -199,6 +207,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(config.get('terminalTheme')).toEqual({ @@ -206,6 +216,8 @@ describe('desktop settings service', () => { name: 'Ocean', source: 'iterm2', palette: IMPORTED_PALETTE, + lightPalette: IMPORTED_LIGHT_PALETTE, + darkPalette: IMPORTED_DARK_PALETTE, }) expect(preferences).toMatchObject({ terminalTheme: { id: 'iterm2:ocean', name: 'Ocean' }, diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index aeaef5a0852..f84b364b40b 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -1,5 +1,6 @@ import { isAbsolute } from 'node:path' import { + cloneTerminalSelectedProfile, type DesktopAppearanceTheme, type DesktopNotificationPayload, type DesktopPreferenceKey, @@ -177,12 +178,7 @@ export function createDesktopSettingsService( return read() }, selectTerminalProfile(profile) { - deps.config.set('terminalTheme', { - id: profile.id, - name: profile.name, - source: profile.source, - palette: { ...profile.palette }, - }) + deps.config.set('terminalTheme', cloneTerminalSelectedProfile(profile)) deps.config.flush() return read() }, diff --git a/apps/desktop/src/main/terminal-themes.test.ts b/apps/desktop/src/main/terminal-themes.test.ts index 55538455b0c..4887ea43342 100644 --- a/apps/desktop/src/main/terminal-themes.test.ts +++ b/apps/desktop/src/main/terminal-themes.test.ts @@ -1,4 +1,4 @@ -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { describe, expect, it } from 'vitest' import { parseTerminalThemeProfiles } from '@/main/terminal-themes' @@ -6,6 +6,10 @@ const PALETTE = { ...TERMINAL_DARK_THEME, background: '#101010', } +const LIGHT_PALETTE = { + ...TERMINAL_LIGHT_THEME, + background: '#fafafa', +} function profile(id: string, overrides: Record<string, unknown> = {}) { return { @@ -22,10 +26,22 @@ describe('parseTerminalThemeProfiles', () => { expect(parseTerminalThemeProfiles([profile('iterm2:ocean')])).toEqual([profile('iterm2:ocean')]) }) + it('preserves separate iTerm2 light and dark palettes', () => { + const separateProfile = profile('iterm2:ocean', { + lightPalette: LIGHT_PALETTE, + darkPalette: PALETTE, + }) + + expect(parseTerminalThemeProfiles([separateProfile])).toEqual([separateProfile]) + }) + it('drops malformed colors and unsupported applications', () => { expect( parseTerminalThemeProfiles([ profile('bad-color', { palette: { ...PALETTE, background: 'rgb(0, 0, 0)' } }), + profile('bad-mode-color', { + lightPalette: { ...LIGHT_PALETTE, foreground: 'white' }, + }), profile('bad-source', { source: 'warp' }), ]) ).toEqual([]) diff --git a/apps/desktop/src/main/terminal-themes.ts b/apps/desktop/src/main/terminal-themes.ts index eecbe2e631c..dfd26cb60f5 100644 --- a/apps/desktop/src/main/terminal-themes.ts +++ b/apps/desktop/src/main/terminal-themes.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { + cloneTerminalSelectedProfile, isTerminalSelectedProfile, TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME, @@ -86,21 +87,25 @@ function terminalPalette(profile) { return palette } -function itermPalette(profile) { - const background = dictionaryColor(profile['Background Color'], LIGHT_THEME.background) +function itermColor(profile, key, suffix, fallback) { + return dictionaryColor(profile[key + suffix], dictionaryColor(profile[key], fallback)) +} + +function itermPalette(profile, suffix) { + const background = itermColor(profile, 'Background Color', suffix, LIGHT_THEME.background) const dark = isDark(background) const fallback = dark ? DARK_THEME : LIGHT_THEME const palette = { background: background, - foreground: dictionaryColor(profile['Foreground Color'], fallback.foreground), - cursor: dictionaryColor(profile['Cursor Color'], fallback.cursor), - cursorAccent: dictionaryColor(profile['Cursor Text Color'], background), - selectionBackground: dictionaryColor(profile['Selection Color'], fallback.selectionBackground), - selectionForeground: dictionaryColor(profile['Selected Text Color'], fallback.foreground) + foreground: itermColor(profile, 'Foreground Color', suffix, fallback.foreground), + cursor: itermColor(profile, 'Cursor Color', suffix, fallback.cursor), + cursorAccent: itermColor(profile, 'Cursor Text Color', suffix, background), + selectionBackground: itermColor(profile, 'Selection Color', suffix, fallback.selectionBackground), + selectionForeground: itermColor(profile, 'Selected Text Color', suffix, fallback.foreground) } for (let index = 0; index < PALETTE_KEYS.length; index += 1) { const key = PALETTE_KEYS[index] - palette[key] = dictionaryColor(profile['Ansi ' + index + ' Color'], fallback[key]) + palette[key] = itermColor(profile, 'Ansi ' + index + ' Color', suffix, fallback[key]) } return palette } @@ -131,12 +136,18 @@ try { const guid = String(profile.Guid || '') const name = String(profile.Name || '') if (!guid || !name) continue - profiles.push({ + const result = { id: 'iterm2:' + encodeURIComponent(guid), name: name, source: 'iterm2', - palette: itermPalette(profile) - }) + palette: itermPalette(profile, '') + } + const separateColors = profile['Use Separate Colors for Light and Dark Mode'] + if (separateColors === true || separateColors === 1) { + result.lightPalette = itermPalette(profile, ' (Light)') + result.darkPalette = itermPalette(profile, ' (Dark)') + } + profiles.push(result) } } catch (_) {} @@ -151,12 +162,7 @@ export function parseTerminalThemeProfiles(value: unknown): TerminalThemeProfile for (const candidate of value) { if (!isTerminalSelectedProfile(candidate) || seen.has(candidate.id)) continue seen.add(candidate.id) - profiles.push({ - id: candidate.id, - name: candidate.name, - source: candidate.source, - palette: { ...candidate.palette }, - }) + profiles.push(cloneTerminalSelectedProfile(candidate)) } return profiles.sort( (left, right) => left.source.localeCompare(right.source) || left.name.localeCompare(right.name) @@ -180,9 +186,8 @@ async function readTerminalThemeProfiles(): Promise<TerminalThemeProfile[]> { let cachedProfiles: TerminalThemeProfile[] | null = null let profileLoad: Promise<TerminalThemeProfile[]> | null = null -/** Reads Terminal.app and iTerm2 profiles once per desktop process. */ +/** Reads current Terminal.app and iTerm2 profiles, coalescing concurrent requests. */ export async function listTerminalThemeProfiles(): Promise<TerminalThemeProfile[]> { - if (cachedProfiles) return cachedProfiles profileLoad ??= readTerminalThemeProfiles() .then((profiles) => { cachedProfiles = profiles diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 0ba00f9fdca..b2ad5db2670 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -25,6 +25,7 @@ import { isNewerVersion, parseSemver, resolveUpdateChannel, + updateCheckIntervalMs, } from '@/main/updater' describe('resolveUpdateChannel', () => { @@ -39,6 +40,17 @@ describe('resolveUpdateChannel', () => { }) }) +describe('updateCheckIntervalMs', () => { + it('checks dev and staging builds every five minutes', () => { + expect(updateCheckIntervalMs('1.2.3-alpha.2')).toBe(5 * 60 * 1000) + expect(updateCheckIntervalMs('1.2.3-beta.1')).toBe(5 * 60 * 1000) + }) + + it('checks production builds every thirty minutes', () => { + expect(updateCheckIntervalMs('1.2.3')).toBe(30 * 60 * 1000) + }) +}) + describe('parseSemver', () => { it('parses plain and v-prefixed versions', () => { expect(parseSemver('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: '' }) @@ -262,6 +274,22 @@ describe('initUpdater state machine', () => { vi.mocked(app.getVersion).mockReturnValue('1.0.0') } }) + + it.each([ + ['1.0.1-alpha.7', 5 * 60 * 1000], + ['1.0.1-beta.7', 5 * 60 * 1000], + ['1.0.1', 30 * 60 * 1000], + ])('schedules %s update polling every %i milliseconds', async (version, interval) => { + vi.mocked(app.getVersion).mockReturnValue(version) + const intervalSpy = vi.spyOn(globalThis, 'setInterval') + try { + await createUpdater({ feedAvailable: true }) + expect(intervalSpy).toHaveBeenCalledWith(expect.any(Function), interval) + } finally { + intervalSpy.mockRestore() + vi.mocked(app.getVersion).mockReturnValue('1.0.0') + } + }) }) function manifest(version: string): string { diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 711875cfdf6..c35d5e9eaec 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -10,7 +10,8 @@ import type { EventRecorder } from '@/main/observability' const logger = createLogger('DesktopUpdater') const INITIAL_CHECK_DELAY_MS = 10_000 -const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000 +const PRERELEASE_CHECK_INTERVAL_MS = 5 * 60 * 1000 +const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 export type UpdateChannel = 'latest' | 'beta' | 'alpha' @@ -72,6 +73,13 @@ export function resolveUpdateChannel(version: string): UpdateChannel { return 'latest' } +/** Dev/staging shells poll rapidly; production shells use a quieter cadence. */ +export function updateCheckIntervalMs(version: string): number { + return resolveUpdateChannel(version) === 'latest' + ? STABLE_CHECK_INTERVAL_MS + : PRERELEASE_CHECK_INTERVAL_MS +} + interface ParsedSemver { major: number minor: number @@ -263,7 +271,8 @@ interface UpdateEngine { /** * Keeps installed shells current against the per-environment update feed: - * checks on launch and every four hours, and mirrors pipeline state to the + * checks on launch, then every five minutes for dev/staging builds or every + * thirty minutes for production builds, and mirrors pipeline state to the * renderer for the settings update UI and the minimum-shell-version gate. * * Developer-ID-signed builds use electron-updater (background download, @@ -528,7 +537,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } const check = () => engine?.check() setTimeout(check, INITIAL_CHECK_DELAY_MS) - setInterval(check, CHECK_INTERVAL_MS) + setInterval(check, updateCheckIntervalMs(currentVersion)) }) return { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 3dde6afcb0f..7a4647f8bfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -11,15 +11,11 @@ import { useState, } from 'react' import { - type DesktopAppearanceTheme, type DesktopZoomAction, type DesktopZoomPercent, resolveDesktopZoom, - TERMINAL_DARK_THEME, - TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, type TerminalShortcutCommand, - type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { @@ -45,7 +41,8 @@ import { getDesktopBridge } from '@/lib/desktop' import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, - resolveDesktopAppearanceTheme, + refreshSelectedTerminalProfile, + resolveTerminalThemePalette, withSelectedProfile, } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' @@ -314,15 +311,7 @@ const TerminalView = memo(function TerminalView({ defaultZoom: DesktopZoomPercent }) { const { resolvedTheme } = useTheme() - const profileTheme = typeof appearanceTheme === 'string' ? undefined : appearanceTheme - const builtInTheme: DesktopAppearanceTheme = - typeof appearanceTheme === 'string' ? appearanceTheme : 'app' - const colorScheme = resolveDesktopAppearanceTheme(builtInTheme, resolvedTheme) - const terminalTheme: TerminalThemePalette = profileTheme - ? profileTheme.palette - : colorScheme === 'dark' - ? TERMINAL_DARK_THEME - : TERMINAL_LIGHT_THEME + const terminalTheme = resolveTerminalThemePalette(appearanceTheme, resolvedTheme) const hostRef = useRef<HTMLDivElement>(null) const terminalRef = useRef<Terminal | null>(null) const fitRef = useRef<FitAddon | null>(null) @@ -756,26 +745,20 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { ) useEffect(() => { + if (!visible) return let active = true - void loadDesktopTerminalAppearance().then((next) => { - if (!active) return - setAppearanceTheme(next.theme) - setDefaultZoom(next.defaultZoom) - }) - return () => { - active = false - } - }, []) - - useEffect(() => { - let active = true - void loadDesktopTerminalThemeProfiles().then((next) => { - if (active) setProfiles(next) - }) + void Promise.all([loadDesktopTerminalAppearance(), loadDesktopTerminalThemeProfiles()]).then( + ([nextAppearance, nextProfiles]) => { + if (!active) return + setProfiles(nextProfiles) + setAppearanceTheme(refreshSelectedTerminalProfile(nextProfiles, nextAppearance.theme)) + setDefaultZoom(nextAppearance.defaultZoom) + } + ) return () => { active = false } - }, []) + }, [visible]) useEffect(() => { let active = true diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 5c7684320dd..7faf2135da8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -1,6 +1,6 @@ 'use client' -import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn' import { Download, @@ -24,6 +24,7 @@ import { reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { prefersInPlaceNavigation } from '@/lib/desktop' import { triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { @@ -73,6 +74,25 @@ const LOADING_SKELETON = ( </div> ) +/** + * Opens an internal app link the way the host expects: a new browser tab on the + * web, and the current view in the desktop app, whose shell would otherwise turn + * the same-origin `window.open` into a second Sim window. + */ +function useOpenInternalLink() { + const router = useRouter() + return useCallback( + (href: string) => { + if (prefersInPlaceNavigation()) { + router.push(href) + return + } + window.open(href, '_blank') + }, + [router] + ) +} + interface ResourceContentProps { workspaceId: string desktopScopeId: string @@ -350,6 +370,7 @@ interface EmbeddedWorkflowActionsProps { } export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWorkflowActionsProps) { + const openInternalLink = useOpenInternalLink() const { navigateToSettings } = useSettingsNavigation() const { data: session } = useSession() const hostContext = useWorkspaceHostContext() @@ -404,7 +425,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor } const handleOpenWorkflow = () => { - window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank') + openInternalLink(`/workspace/${workspaceId}/w/${workflowId}`) } return ( @@ -727,6 +748,7 @@ interface EmbeddedFolderProps { } function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { + const openInternalLink = useOpenInternalLink() const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId) const { data: workflowList = [] } = useWorkflows(workspaceId) @@ -760,7 +782,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) { <button key={w.id} type='button' - onClick={() => window.open(`/workspace/${workspaceId}/w/${w.id}`, '_blank')} + onClick={() => openInternalLink(`/workspace/${workspaceId}/w/${w.id}`)} className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]' > <WorkflowIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' /> diff --git a/apps/sim/lib/desktop/appearance.test.ts b/apps/sim/lib/desktop/appearance.test.ts index f2d9284d585..76ded478bec 100644 --- a/apps/sim/lib/desktop/appearance.test.ts +++ b/apps/sim/lib/desktop/appearance.test.ts @@ -1,4 +1,4 @@ -import { TERMINAL_DARK_THEME } from '@sim/desktop-bridge' +import { TERMINAL_DARK_THEME, TERMINAL_LIGHT_THEME } from '@sim/desktop-bridge' import { afterEach, describe, expect, it, vi } from 'vitest' const { mockBridge } = vi.hoisted(() => ({ mockBridge: { current: undefined as unknown } })) @@ -10,7 +10,9 @@ vi.mock('@/lib/desktop', () => ({ import { loadDesktopTerminalAppearance, loadDesktopTerminalThemeProfiles, + refreshSelectedTerminalProfile, resolveDesktopAppearanceTheme, + resolveTerminalThemePalette, } from './appearance' afterEach(() => { @@ -35,6 +37,58 @@ describe('resolveDesktopAppearanceTheme', () => { }) }) +describe('resolveTerminalThemePalette', () => { + const fallbackPalette = { ...TERMINAL_DARK_THEME, background: '#111111' } + const lightPalette = { ...TERMINAL_LIGHT_THEME, background: '#fafafa' } + const darkPalette = { ...TERMINAL_DARK_THEME, background: '#222222' } + const profile = { + id: 'iterm2:ocean', + name: 'Ocean', + source: 'iterm2' as const, + palette: fallbackPalette, + lightPalette, + darkPalette, + } + + it('uses an imported profile palette matching Sim appearance', () => { + expect(resolveTerminalThemePalette(profile, 'light')).toBe(lightPalette) + expect(resolveTerminalThemePalette(profile, 'dark')).toBe(darkPalette) + }) + + it('falls back to the source palette when a profile has no mode-specific colors', () => { + const legacyProfile = { ...profile, lightPalette: undefined, darkPalette: undefined } + expect(resolveTerminalThemePalette(legacyProfile, 'light')).toBe(fallbackPalette) + expect(resolveTerminalThemePalette(legacyProfile, 'dark')).toBe(fallbackPalette) + }) + + it('keeps built-in Sim themes unchanged', () => { + expect(resolveTerminalThemePalette('light', 'dark')).toBe(TERMINAL_LIGHT_THEME) + expect(resolveTerminalThemePalette('dark', 'light')).toBe(TERMINAL_DARK_THEME) + }) +}) + +describe('refreshSelectedTerminalProfile', () => { + const storedProfile = { + id: 'iterm2:ocean', + name: 'Ocean', + source: 'iterm2' as const, + palette: { ...TERMINAL_DARK_THEME, background: '#111111' }, + } + const refreshedProfile = { + ...storedProfile, + palette: { ...storedProfile.palette, background: '#222222' }, + } + + it('uses newly discovered colors for the active source profile', () => { + expect(refreshSelectedTerminalProfile([refreshedProfile], storedProfile)).toBe(refreshedProfile) + }) + + it('keeps built-in and unavailable profile selections unchanged', () => { + expect(refreshSelectedTerminalProfile([refreshedProfile], 'app')).toBe('app') + expect(refreshSelectedTerminalProfile([], storedProfile)).toBe(storedProfile) + }) +}) + describe('loadDesktopTerminalAppearance', () => { it('returns a cached profile selection without waiting for source discovery', async () => { const selectedProfile = { diff --git a/apps/sim/lib/desktop/appearance.ts b/apps/sim/lib/desktop/appearance.ts index 2702c3c0983..00caae6e4bf 100644 --- a/apps/sim/lib/desktop/appearance.ts +++ b/apps/sim/lib/desktop/appearance.ts @@ -4,7 +4,10 @@ import { isDesktopAppearanceTheme, isDesktopZoomPercent, isTerminalAppearanceTheme, + TERMINAL_DARK_THEME, + TERMINAL_LIGHT_THEME, type TerminalAppearanceTheme, + type TerminalThemePalette, type TerminalThemeProfile, } from '@sim/desktop-bridge' import { getDesktopBridge } from '@/lib/desktop' @@ -66,6 +69,15 @@ export function withSelectedProfile( : profiles } +/** Replaces a persisted profile snapshot with freshly discovered source colors. */ +export function refreshSelectedTerminalProfile( + profiles: TerminalThemeProfile[], + theme: TerminalAppearanceTheme +): TerminalAppearanceTheme { + if (typeof theme === 'string') return theme + return profiles.find(({ id }) => id === theme.id) ?? theme +} + /** * Resolves `app` against next-themes' raw or resolved value. `system` stays * meaningful for browser CDP; terminal callers treat it as the light fallback @@ -78,3 +90,22 @@ export function resolveDesktopAppearanceTheme( if (preference !== 'app') return preference return appTheme === 'light' || appTheme === 'dark' || appTheme === 'system' ? appTheme : 'system' } + +/** + * Resolves built-in and imported terminal palettes against Sim's live + * appearance. Imported profiles always follow the app appearance — they carry + * their own colors, so there is no separate preference to pin them to. + */ +export function resolveTerminalThemePalette( + theme: TerminalAppearanceTheme, + appTheme: string | undefined +): TerminalThemePalette { + if (typeof theme !== 'string') { + return resolveDesktopAppearanceTheme('app', appTheme) === 'dark' + ? (theme.darkPalette ?? theme.palette) + : (theme.lightPalette ?? theme.palette) + } + return resolveDesktopAppearanceTheme(theme, appTheme) === 'dark' + ? TERMINAL_DARK_THEME + : TERMINAL_LIGHT_THEME +} diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 1d31fb67cbf..f902743f021 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -55,6 +55,16 @@ export function hasDesktopSettings(): boolean { return isDesktopApp() } +/** + * True when an internal link must navigate the current view rather than open a + * second one. The shell has no tab strip, so its window-open policy routes a + * same-origin `window.open` to a full new Sim window — where a browser would + * have added a background tab, the desktop app throws up another window. + */ +export function prefersInPlaceNavigation(): boolean { + return isDesktopApp() +} + /** * The device switches for the browser and terminal, cached because the chat UI * reads availability synchronously while the shell only answers over async diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index b4404aab108..61936588960 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -724,7 +724,15 @@ export interface TerminalSelectedProfile { id: string name: string source: TerminalThemeSource + /** + * Palette used when the source does not provide appearance-specific colors. + * Ignored once both `lightPalette` and `darkPalette` are present. + */ palette: TerminalThemePalette + /** Optional palette used while Sim is in light appearance. */ + lightPalette?: TerminalThemePalette + /** Optional palette used while Sim is in dark appearance. */ + darkPalette?: TerminalThemePalette } export type TerminalThemeProfile = TerminalSelectedProfile @@ -737,41 +745,60 @@ const TERMINAL_THEME_PALETTE_KEYS: readonly (keyof TerminalThemePalette)[] = [ ...TERMINAL_THEME_ANSI_KEYS, ] +const TERMINAL_THEME_OPTIONAL_PALETTE_KEYS = ['cursorAccent', 'selectionForeground'] as const + const TERMINAL_THEME_COLOR_PATTERN = /^#[0-9a-f]{6}$/i +function isTerminalThemeColor(value: unknown): value is string { + return typeof value === 'string' && TERMINAL_THEME_COLOR_PATTERN.test(value) +} + +function isTerminalThemePalette(value: unknown): value is TerminalThemePalette { + if (typeof value !== 'object' || value === null) return false + const palette = value as Partial<TerminalThemePalette> + return ( + TERMINAL_THEME_PALETTE_KEYS.every((key) => isTerminalThemeColor(palette[key])) && + TERMINAL_THEME_OPTIONAL_PALETTE_KEYS.every( + (key) => palette[key] === undefined || isTerminalThemeColor(palette[key]) + ) + ) +} + export function isTerminalSelectedProfile(value: unknown): value is TerminalSelectedProfile { if (typeof value !== 'object' || value === null) return false const candidate = value as Partial<TerminalSelectedProfile> - if ( - typeof candidate.id !== 'string' || - candidate.id.length === 0 || - candidate.id.length > 300 || - typeof candidate.name !== 'string' || - candidate.name.length === 0 || - candidate.name.length > 200 || - (candidate.source !== 'terminal' && candidate.source !== 'iterm2') || - typeof candidate.palette !== 'object' || - candidate.palette === null - ) { - return false - } - if ( - !TERMINAL_THEME_PALETTE_KEYS.every( - (key) => - typeof candidate.palette?.[key] === 'string' && - TERMINAL_THEME_COLOR_PATTERN.test(candidate.palette[key]) - ) - ) { - return false - } - return (['cursorAccent', 'selectionForeground'] as const).every( - (key) => - candidate.palette?.[key] === undefined || - (typeof candidate.palette[key] === 'string' && - TERMINAL_THEME_COLOR_PATTERN.test(candidate.palette[key])) + return ( + typeof candidate.id === 'string' && + candidate.id.length > 0 && + candidate.id.length <= 300 && + typeof candidate.name === 'string' && + candidate.name.length > 0 && + candidate.name.length <= 200 && + (candidate.source === 'terminal' || candidate.source === 'iterm2') && + isTerminalThemePalette(candidate.palette) && + (candidate.lightPalette === undefined || isTerminalThemePalette(candidate.lightPalette)) && + (candidate.darkPalette === undefined || isTerminalThemePalette(candidate.darkPalette)) ) } +/** + * Copies only the known profile fields, so untrusted source output and stored + * config never carry extra keys. The single definition of a profile's shape — + * new palette slots are added here rather than at each call site. + */ +export function cloneTerminalSelectedProfile( + profile: TerminalSelectedProfile +): TerminalSelectedProfile { + return { + id: profile.id, + name: profile.name, + source: profile.source, + palette: { ...profile.palette }, + ...(profile.lightPalette ? { lightPalette: { ...profile.lightPalette } } : {}), + ...(profile.darkPalette ? { darkPalette: { ...profile.darkPalette } } : {}), + } +} + export interface DesktopPreferences { notificationsEnabled: boolean notificationSounds: boolean diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts index d0de466d569..642b20a8498 100644 --- a/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.test.ts @@ -4,10 +4,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { combineChatAttachments, - existingAttachmentPaths, loadChatAttachment, loadChatAttachments, - parseAttachmentPaths, } from './chat-attachments.js' const temporaryDirectories: string[] = [] @@ -110,25 +108,3 @@ describe('chat attachments', () => { ) }) }) - -describe('attachment path parsing', () => { - it('supports quoted and terminal-escaped paths', () => { - expect(parseAttachmentPaths("'/tmp/one two.md' /tmp/three\\ four.png")).toEqual([ - '/tmp/one two.md', - '/tmp/three four.png', - ]) - expect(() => parseAttachmentPaths("'/tmp/open")).toThrow(/Unclosed quote/) - }) - - it('recognizes a pasted path containing spaces before trying shell splitting', async () => { - const path = await fixture('a file.txt', 'hello') - await expect(existingAttachmentPaths(path)).resolves.toEqual([path]) - await expect(existingAttachmentPaths('this is a normal question')).resolves.toBeNull() - }) - - it('rejects pasted candidate lists beyond the per-turn limit', async () => { - const candidates = Array.from({ length: 6 }, (_, index) => `/tmp/sim-file-${index}`).join(' ') - - await expect(existingAttachmentPaths(candidates)).resolves.toBeNull() - }) -}) diff --git a/packages/sim-cli/src/commands/protocol/chat-attachments.ts b/packages/sim-cli/src/commands/protocol/chat-attachments.ts index 7e313c74bc9..0697bac11c1 100644 --- a/packages/sim-cli/src/commands/protocol/chat-attachments.ts +++ b/packages/sim-cli/src/commands/protocol/chat-attachments.ts @@ -205,22 +205,34 @@ export async function loadChatAttachments(paths: string[]): Promise<ChatAttachme return attachments } +/** A token from a submitted line, with its span in the original input. */ +interface AttachmentToken { + value: string + start: number + end: number +} + /** - * Splits `/attach` input using the subset terminals produce for dragged paths: - * whitespace separation, single/double quotes, and backslash escapes. + * Splits an input line into shell-style tokens, honoring single quotes, double + * quotes and backslash escapes, and keeping each token's span so callers can + * rewrite the original text in place. */ -export function parseAttachmentPaths(input: string): string[] { - const paths: string[] = [] +function tokenizeAttachmentInput(input: string): AttachmentToken[] { + const tokens: AttachmentToken[] = [] let value = '' let quote: 'single' | 'double' | null = null let escaped = false + let start = -1 - const push = () => { - if (value) paths.push(value) + const push = (end: number) => { + if (value) tokens.push({ value, start, end }) value = '' + start = -1 } - for (const character of input.trim()) { + for (let index = 0; index < input.length; index++) { + const character = input[index] as string + if (start < 0 && !/\s/.test(character)) start = index if (escaped) { value += character escaped = false @@ -239,7 +251,7 @@ export function parseAttachmentPaths(input: string): string[] { continue } if (/\s/.test(character) && quote === null) { - push() + push(index) continue } value += character @@ -247,8 +259,8 @@ export function parseAttachmentPaths(input: string): string[] { if (escaped) value += '\\' if (quote !== null) throw attachmentError('Unclosed quote in attachment path.') - push() - return paths + push(input.length) + return tokens } /** @@ -259,6 +271,13 @@ export function parseAttachmentPaths(input: string): string[] { * image and the whole read fails. `loadChatAttachment` caps the size on fstat * and again on read, which is where the limit belongs anyway. */ +/** Returns the POSIX path of a file copied in Finder, which carries no text flavor. */ +const APPLE_SCRIPT_FILE = [ + 'on run', + 'return POSIX path of (the clipboard as «class furl»)', + 'end run', +] + const APPLE_SCRIPT = [ 'on run argv', 'set outputPath to item 1 of argv', @@ -278,9 +297,12 @@ const APPLE_SCRIPT = [ ] /** Best-effort macOS clipboard image extraction, used by the paste keystroke. */ -export async function readClipboardImage(): Promise<ChatAttachment | null> { +export async function readClipboardAttachment(): Promise<ChatAttachment | null> { if (process.platform !== 'darwin') return null + return (await readClipboardImage()) ?? (await readClipboardFile()) +} +async function readClipboardImage(): Promise<ChatAttachment | null> { const directory = await mkdtemp(join(tmpdir(), 'sim-chat-clipboard-')) const path = join(directory, 'clipboard.png') try { @@ -296,29 +318,76 @@ export async function readClipboardImage(): Promise<ChatAttachment | null> { } } +/** + * Reads a file copied in Finder. + * + * The `furl` coercion is lenient — plain clipboard text comes back as a path + * that was never on disk — so the result is only trusted once it stats as a + * real file. + */ +async function readClipboardFile(): Promise<ChatAttachment | null> { + try { + const args = APPLE_SCRIPT_FILE.flatMap((line) => ['-e', line]) + const { stdout } = await execFileAsync('osascript', args, { timeout: 5_000 }) + const path = stdout.trim() + if (!path || !(await stat(path)).isFile()) return null + return await loadChatAttachment(path) + } catch { + return null + } +} + /** True when every parsed path names an existing regular file. */ -export async function existingAttachmentPaths(input: string): Promise<string[] | null> { - let wholePath +async function isFile(path: string): Promise<boolean> { try { - wholePath = await stat(input.trim()) + return (await stat(path)).isFile() } catch { - wholePath = null + return false } - if (wholePath?.isFile()) return [input.trim()] +} + +/** Paths found inside a message, and the message with each replaced by a tag. */ +export interface ExtractedAttachments { + paths: string[] + text: string +} + +/** + * Pulls existing file paths out of a message, wherever they appear. + * + * A token only counts when it resolves to a real file, so prose that merely + * looks path-like — a snippet, a URL fragment — stays literal text. Each match + * is swapped for a `[File #N]` tag so the reader can see what was attached and + * delete it to detach. + */ +export async function extractAttachmentPaths(input: string): Promise<ExtractedAttachments | null> { + /* A path pasted whole may contain unescaped spaces, which tokenizing would + split apart, so the entire line gets the first look. */ + const whole = input.trim() + if (whole.includes('/') && (await isFile(whole))) return { paths: [whole], text: '[File #1]' } - let paths: string[] + let tokens: AttachmentToken[] try { - paths = parseAttachmentPaths(input) + tokens = tokenizeAttachmentInput(input) } catch { return null } - if (paths.length === 0 || paths.length > MAX_CHAT_ATTACHMENTS) return null - for (const path of paths) { - try { - if (!(await stat(path)).isFile()) return null - } catch { - return null - } + + const matches: Array<{ token: AttachmentToken; path: string }> = [] + for (const token of tokens) { + if (matches.length >= MAX_CHAT_ATTACHMENTS) break + if (!token.value.includes('/') && !token.value.includes('\\')) continue + if (await isFile(token.value)) matches.push({ token, path: token.value }) } - return paths + if (matches.length === 0) return null + + let text = '' + let cursor = 0 + for (const [index, match] of matches.entries()) { + text += `${input.slice(cursor, match.token.start)}[File #${index + 1}]` + cursor = match.token.end + } + text += input.slice(cursor) + + return { paths: matches.map((match) => match.path), text: text.trim() } } diff --git a/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts b/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts new file mode 100644 index 00000000000..839559f705e --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/chat-path-extraction.test.ts @@ -0,0 +1,77 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { extractAttachmentPaths } from './chat-attachments.js' + +let dir: string +let file: string +let spaced: string + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'sim-extract-')) + file = join(dir, 'report.pdf') + spaced = join(dir, 'my report.pdf') + writeFileSync(file, 'x') + writeFileSync(spaced, 'x') +}) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +describe('extractAttachmentPaths', () => { + it('pulls a path out of surrounding prose and leaves a tag', async () => { + const result = await extractAttachmentPaths(`summarize ${file} for me`) + expect(result).toEqual({ paths: [file], text: 'summarize [File #1] for me' }) + }) + + it('handles a path at the start or end of the line', async () => { + expect((await extractAttachmentPaths(`${file} what is this`))?.text).toBe( + '[File #1] what is this' + ) + expect((await extractAttachmentPaths(`look at ${file}`))?.text).toBe('look at [File #1]') + }) + + it('numbers multiple attachments in order', async () => { + const result = await extractAttachmentPaths(`diff ${file} against ${file}`) + expect(result?.text).toBe('diff [File #1] against [File #2]') + expect(result?.paths).toHaveLength(2) + }) + + it('understands quoted and escaped paths with spaces', async () => { + expect((await extractAttachmentPaths(`read "${spaced}" please`))?.paths).toEqual([spaced]) + const escaped = spaced.replace(/ /gu, '\\ ') + expect((await extractAttachmentPaths(`read ${escaped} please`))?.paths).toEqual([spaced]) + }) + + it('leaves path-like prose alone when the file does not exist', async () => { + expect(await extractAttachmentPaths('check /nope/missing.png please')).toBeNull() + expect(await extractAttachmentPaths('see src/does-not-exist.ts line 4')).toBeNull() + }) + + it('attaches a relative path that resolves against the working directory', async () => { + expect((await extractAttachmentPaths('read src/index.ts'))?.paths).toEqual(['src/index.ts']) + }) + + it('returns null for a message with no paths', async () => { + expect(await extractAttachmentPaths('hello there')).toBeNull() + }) + + it('takes a whole line that is one unescaped path with spaces', async () => { + expect((await extractAttachmentPaths(` ${spaced} `))?.paths).toEqual([spaced]) + }) + + it('stops at the per-turn attachment limit and leaves the rest as text', async () => { + const line = Array.from({ length: 6 }, () => file).join(' ') + const result = await extractAttachmentPaths(line) + expect(result?.paths).toHaveLength(5) + expect(result?.text).toBe(`[File #1] [File #2] [File #3] [File #4] [File #5] ${file}`) + }) + + it('keeps the line breaks in a multi-line message', async () => { + const result = await extractAttachmentPaths(`first line\nsummarize ${file}\nlast line`) + expect(result?.text).toBe('first line\nsummarize [File #1]\nlast line') + }) + + it('does not throw on an unclosed quote', async () => { + expect(await extractAttachmentPaths(`read "${file}`)).toBeNull() + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts index c4ccbf17cf5..f5ab77154c7 100644 --- a/packages/sim-cli/src/commands/protocol/chat-suggestions.ts +++ b/packages/sim-cli/src/commands/protocol/chat-suggestions.ts @@ -184,13 +184,6 @@ export function contextSpans( /** Composer slash commands, the source for the `/` menu. */ export const SLASH_COMMANDS: SuggestionItem[] = [ - { - id: 'attach', - value: '/attach', - displayText: '/attach <paths>', - description: 'attach local files to the next turn', - tag: 'command', - }, { id: 'clear', value: '/clear', diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts index 089ffc54601..c32b4372232 100644 --- a/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.test.ts @@ -510,7 +510,8 @@ describe('ReadlineChatTerminal', () => { expect(suggestion).toBeGreaterThanOrEqual(0) expect(thinking).toBeGreaterThan(suggestion) - expect(thinking).toBe(composer - 2) + expect(thinking).toBe(composer - 3) + expect(lines[composer - 2]).toBe('') expect(lines[composer - 1]).toBe(' ') expect(lines[composer + 1]).toBe(' ') expect(composer + 1).toBe(lines.length - 2) @@ -523,7 +524,7 @@ describe('ReadlineChatTerminal', () => { const renderedThinking = renderedLines.findIndex((line) => line?.includes('Thinking…')) const renderedComposer = renderedLines.findIndex((line) => line?.startsWith(' ❯ /')) expect(renderedThinking).toBeGreaterThanOrEqual(0) - expect(renderedComposer).toBe(renderedThinking + 2) + expect(renderedComposer).toBe(renderedThinking + 3) expectUserPanelRow(screen.terminal, renderedComposer - 1, columns) expectUserPanelRow(screen.terminal, renderedComposer, columns) expectUserPanelRow(screen.terminal, renderedComposer + 1, columns) @@ -2028,7 +2029,8 @@ describe('ReadlineChatTerminal', () => { expect(status).toMatch(/^[·•●] Thinking…$/u) expect(status).not.toContain('Planning next step') expect(statusRow).toBeGreaterThanOrEqual(0) - expect(statusRow).toBe(composerRow - 2) + expect(statusRow).toBe(composerRow - 3) + expect(panel[composerRow - 2]).toBe('') expect(panel[composerRow - 1]).toBe(' ') expect(panel[composerRow + 1]).toBe(' ') diff --git a/packages/sim-cli/src/commands/protocol/chat-terminal.ts b/packages/sim-cli/src/commands/protocol/chat-terminal.ts index e4afac55352..fe465c7661d 100644 --- a/packages/sim-cli/src/commands/protocol/chat-terminal.ts +++ b/packages/sim-cli/src/commands/protocol/chat-terminal.ts @@ -15,6 +15,9 @@ import { truncateDisplay, } from '../../output/terminal-text.js' +/** Which tag `noteAttachment` writes into the composer. */ +export type ChatAttachmentKind = 'Image' | 'File' + export type ChatTerminalInput = | { kind: 'line' @@ -97,8 +100,8 @@ export interface ChatTerminal { setChatTitle(title: string): void /** Fills in the workspace name once the lookup resolves. */ setWorkspaceName(name: string): void - /** Inserts an `[Image #N]` tag at the cursor for a just-attached image. */ - noteAttachment(): void + /** Inserts an `[Image #N]` or `[File #N]` tag at the cursor for a just-attached file. */ + noteAttachment(kind?: ChatAttachmentKind): void /** Supplies the home-composer `@` resource and `/` skill/MCP pools. */ setSuggestionCandidates?(candidates: ChatSuggestionCandidates): void /** Clears the visible conversation while preserving the active terminal session. */ @@ -321,7 +324,7 @@ export class ReadlineChatTerminal implements ChatTerminal { private resourceCandidates: SuggestionItem[] = [] private slashCandidates: SuggestionItem[] = [] private selectedContexts: ChatContext[] = [] - private nextAttachmentNumber = 1 + private readonly nextAttachmentNumber = new Map<ChatAttachmentKind, number>() private pasting = false private pasteBuffer = '' private pastedText = new Map<number, string>() @@ -1754,12 +1757,14 @@ export class ReadlineChatTerminal implements ChatTerminal { /* Keep the suggestion menu visually separate from the activity line. The composer's shaded top row already separates activity from input. */ const suggestionGap = suggestionRows.length ? [''] : [] + const activityGap = activityRows.length ? [''] : [] const composerCursor = { row: topMargin.length + suggestionRows.length + suggestionGap.length + activityRows.length + + activityGap.length + 1 + layout.cursor.row - firstVisible, @@ -1771,6 +1776,7 @@ export class ReadlineChatTerminal implements ChatTerminal { ...suggestionRows, ...suggestionGap, ...activityRows, + ...activityGap, userPanelRow(), ...visibleRows.map((line) => userPanelRow(line)), userPanelRow(), @@ -2034,8 +2040,10 @@ export class ReadlineChatTerminal implements ChatTerminal { )}${RESET}\n\n` } - noteAttachment(): void { - const token = `[Image #${this.nextAttachmentNumber++}]` + noteAttachment(kind: ChatAttachmentKind = 'Image'): void { + const number = this.nextAttachmentNumber.get(kind) ?? 1 + this.nextAttachmentNumber.set(kind, number + 1) + const token = `[${kind} #${number}]` const before = this.draft.slice(0, this.cursor) const separator = !before || /\s$/u.test(before) ? '' : ' ' this.insertText(`${separator}${token} `) @@ -2561,11 +2569,11 @@ function cursorTo(row: number, column: number): string { } /** - * Spans of `[Image #N]` tags, so a pasted attachment reads as a tag rather than - * loose text. Derived per render like context spans, so deleting the tag stops - * the highlight with no bookkeeping. + * Spans of `[Image #N]` and `[File #N]` tags, so an attachment reads as a tag + * rather than loose text. Derived per render like context spans, so deleting the + * tag stops the highlight with no bookkeeping. */ -const ATTACHMENT_TOKEN = /\[Image #\d+\]/gu +const ATTACHMENT_TOKEN = /\[(?:Image|File) #\d+\]/gu function attachmentSpans(text: string): Array<{ start: number; end: number }> { return [...text.matchAll(ATTACHMENT_TOKEN)].map((match) => ({ diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index b0b9d826f4c..40bd546da27 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -1014,7 +1014,7 @@ describe('interactive chat', () => { mocks.requestRaw.mockResolvedValueOnce(completed('Retried', 'next-token')) const terminal = new FakeTerminal( [ - { kind: 'line', value: '/attach "/private/tmp/notes.txt"' }, + { kind: 'clipboard', value: '' }, { kind: 'line', value: '/chats' }, { kind: 'line', value: prompt, display, pastes, contexts }, { kind: 'line', value: prompt, display, pastes, contexts }, @@ -1027,8 +1027,8 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - loadAttachments: async (paths) => (paths.length ? [attachment] : []), - pastedAttachmentPaths: async () => null, + clipboardAttachment: async () => attachment, + extractAttachmentPaths: async () => null, }).parseAsync(['node', 'sim', 'chat']) expect(detailRequests).toBe(3) @@ -1413,7 +1413,7 @@ describe('interactive chat', () => { ]) }) - it('waits for queued path confirmation before answering a retained question', async () => { + it('attaches a queued path without answering a retained question', async () => { const question = '<question>{"type":"single_select","prompt":"Proceed?","options":[{"id":"yes","label":"Yes"}]}</question>' const attachment: ChatAttachment = { @@ -1432,7 +1432,6 @@ describe('interactive chat', () => { queued: true, display: '/private/tmp/report.txt', }, - { kind: 'line', value: '/attach "/private/tmp/report.txt"' }, { kind: 'line', value: '/exit' }, ], [{ kind: 'answer', values: ['Yes'] }] @@ -1441,21 +1440,18 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths: async (value) => - value === '/private/tmp/report.txt' ? ['/private/tmp/report.txt'] : null, + extractAttachmentPaths: async (value: string) => + value === '/private/tmp/report.txt' + ? { paths: ['/private/tmp/report.txt'], text: '[File #1]' } + : null, loadAttachments: async () => [attachment], }).parseAsync(['node', 'sim', 'chat', 'start']) - expect(terminal.preloads).toContainEqual({ - value: '/attach "/private/tmp/report.txt"', - queued: false, - }) expect(terminal.questions).toHaveLength(1) expect(mocks.requestRaw.mock.calls[1][1].body).toEqual({ workspaceId: 'ws_local', prompt: 'Proceed? — Yes', continuationToken: 'token-1', - attachments: [attachment], }) }) @@ -1549,7 +1545,7 @@ describe('interactive chat', () => { ) }) - it('requires an explicit Enter on a preloaded /attach command for pasted paths', async () => { + it('attaches a pasted path inline and sends the surrounding text', async () => { const attachment: ChatAttachment = { name: 'report.txt', mediaType: 'text/plain', @@ -1557,58 +1553,55 @@ describe('interactive chat', () => { } const absolutePath = '/private/tmp/report.txt' const terminal = new FakeTerminal([ - { kind: 'line', value: absolutePath }, - { kind: 'line', value: `/attach "${absolutePath}"` }, - { kind: 'line', value: 'Inspect this file' }, + { kind: 'line', value: `Inspect ${absolutePath} closely` }, { kind: 'line', value: '/exit' }, ]) mocks.requestRaw.mockResolvedValue(completed('Done')) - const pastedAttachmentPaths = vi.fn(async (value: string) => - value === absolutePath ? [absolutePath] : null + const extractAttachmentPaths = vi.fn(async (value: string) => + value.includes(absolutePath) + ? { paths: [absolutePath], text: value.replace(absolutePath, '[File #1]') } + : null ) const loadAttachments = vi.fn(async (paths: string[]) => (paths.length ? [attachment] : [])) await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths, + extractAttachmentPaths, loadAttachments, }).parseAsync(['node', 'sim', 'chat']) expect(loadAttachments).toHaveBeenCalledWith([absolutePath]) - expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) - expect(terminal.statuses).toContain( - 'File path detected. Press Enter to attach it, or edit the command.' - ) + expect(terminal.preloads).toEqual([]) expect(terminal.statuses.some((status) => status.startsWith('Unknown command:'))).toBe(false) expect(mocks.requestRaw.mock.calls[0][1].body).toEqual({ workspaceId: 'ws_local', - prompt: 'Inspect this file', + prompt: 'Inspect [File #1] closely', attachments: [attachment], }) }) - it('does not read or upload a detected path when confirmation is cancelled', async () => { + it('never reads a path out of a slash command', async () => { const absolutePath = '/private/tmp/private.txt' const terminal = new FakeTerminal([ - { kind: 'line', value: absolutePath }, + { kind: 'line', value: `/rename ${absolutePath}` }, { kind: 'line', value: '/exit' }, ]) - const pastedAttachmentPaths = vi.fn(async (value: string) => - value === absolutePath ? [absolutePath] : null - ) + const extractAttachmentPaths = vi.fn(async () => ({ + paths: [absolutePath], + text: '[File #1]', + })) const loadAttachments = vi.fn(async () => []) await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths, + extractAttachmentPaths, loadAttachments, }).parseAsync(['node', 'sim', 'chat']) - expect(terminal.reads[1].initialValue).toBe(`/attach "${absolutePath}"`) - expect(loadAttachments).toHaveBeenCalledTimes(1) - expect(loadAttachments).toHaveBeenCalledWith([]) + expect(extractAttachmentPaths).not.toHaveBeenCalled() + expect(loadAttachments).not.toHaveBeenCalledWith([absolutePath]) expect(mocks.requestRaw).not.toHaveBeenCalled() }) @@ -1628,8 +1621,8 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - clipboardImage: async () => attachment, - pastedAttachmentPaths: async () => null, + clipboardAttachment: async () => attachment, + extractAttachmentPaths: async () => null, }).parseAsync(['node', 'sim', 'chat']) expect(terminal.reads[1].initialValue).toBe('') @@ -1738,7 +1731,7 @@ describe('interactive chat', () => { expect(terminal.preloads).toEqual([]) }) - it('leaves the active turn running for a queued path recognized by normal chat input', async () => { + it('steers the active turn with a queued line carrying a file path', async () => { const pathInput = { kind: 'line' as const, value: 'report.txt', @@ -1747,8 +1740,8 @@ describe('interactive chat', () => { } const terminal = new FakeTerminal([pathInput, { kind: 'line', value: '/exit' }]) let requestSignal: AbortSignal | undefined - const pastedAttachmentPaths = vi.fn(async (value: string) => - value === 'report.txt' ? ['report.txt'] : null + const extractAttachmentPaths = vi.fn(async (value: string) => + value === 'report.txt' ? { paths: ['report.txt'], text: '[File #1]' } : null ) mocks.requestRaw.mockImplementationOnce( async (_path: string, options: { signal: AbortSignal }) => { @@ -1762,15 +1755,11 @@ describe('interactive chat', () => { await program(async () => '', vi.fn(), { isInteractive: () => true, createTerminal: () => terminal, - pastedAttachmentPaths, + extractAttachmentPaths, }).parseAsync(['node', 'sim', 'chat', 'original']) - expect(requestSignal?.aborted).toBe(false) - expect(terminal.preloads).toContainEqual({ - value: '/attach "report.txt"', - queued: false, - }) - expect(mocks.requestRaw).toHaveBeenCalledTimes(1) + expect(requestSignal?.aborted).toBe(true) + expect(terminal.preloads).toEqual([{ value: 'report.txt', queued: true }]) }) it('queues /chats without interrupting the active stream', async () => { diff --git a/packages/sim-cli/src/commands/protocol/chat.ts b/packages/sim-cli/src/commands/protocol/chat.ts index d372f9bb54a..8459163295e 100644 --- a/packages/sim-cli/src/commands/protocol/chat.ts +++ b/packages/sim-cli/src/commands/protocol/chat.ts @@ -21,10 +21,10 @@ import { safeOneLine, sanitize } from '../../output/render.js' import { type ChatAttachment, combineChatAttachments, - existingAttachmentPaths, + type ExtractedAttachments, + extractAttachmentPaths, loadChatAttachments, - parseAttachmentPaths, - readClipboardImage, + readClipboardAttachment, } from './chat-attachments.js' import { ChatMarkdownStream } from './chat-markdown.js' import { @@ -50,8 +50,8 @@ export interface ChatDependencies { isInteractive: () => boolean createTerminal: () => ChatTerminal loadAttachments: (paths: string[]) => Promise<ChatAttachment[]> - clipboardImage: () => Promise<ChatAttachment | null> - pastedAttachmentPaths: (input: string) => Promise<string[] | null> + clipboardAttachment: () => Promise<ChatAttachment | null> + extractAttachmentPaths: (input: string) => Promise<ExtractedAttachments | null> formatMarkdown: () => boolean } @@ -383,8 +383,8 @@ function explainInteractiveCommands(terminal: ChatTerminal): void { terminal.status( [ 'Commands:', - ' /attach <paths> attach local files to the next turn', - ' ctrl+v attach an image from the clipboard (or cmd+v on macOS)', + ' ctrl+v attach the clipboard image or file (or cmd+v on macOS)', + ' <file path> drop or type a path to attach the file', ' /clear start a new conversation', ' /chats view and switch chats', ' /rename <title> rename the active chat', @@ -399,12 +399,6 @@ function attachmentStatus(attachments: ChatAttachment[]): string { return `Attached for the next turn (${attachments.length}/${5}): ${names}` } -function attachmentCommand(paths: string[]): string | null { - if (paths.some((path) => /[\u0000-\u001f\u007f]/u.test(path))) return null - const quoted = paths.map((path) => `"${path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) - return `/attach ${quoted.join(' ')}` -} - async function addPaths( current: ChatAttachment[], paths: string[], @@ -423,18 +417,18 @@ async function addPaths( } } -async function addClipboardImage( +async function addClipboardAttachment( current: ChatAttachment[], terminal: ChatTerminal, dependencies: ChatDependencies ): Promise<ChatAttachment[]> { - const image = await dependencies.clipboardImage() + const pasted = await dependencies.clipboardAttachment() /* Paste feedback is the `[Image #N]` tag in the composer, not a transcript line: the tag says what was attached and disappears when it is deleted. */ - if (!image) return current + if (!pasted) return current try { - const combined = combineChatAttachments(current, [image]) - terminal.noteAttachment() + const combined = combineChatAttachments(current, [pasted]) + terminal.noteAttachment(pasted.mediaType.startsWith('image/') ? 'Image' : 'File') return combined } catch { return current @@ -461,12 +455,12 @@ async function readUserTurn( continue } if (input.kind === 'clipboard') { - attachments = await addClipboardImage(attachments, terminal, dependencies) + attachments = await addClipboardAttachment(attachments, terminal, dependencies) continue } if (input.kind === 'selection') continue - const trimmed = input.value.trim() + let trimmed = input.value.trim() if (trimmed === '/exit' || trimmed === '/quit') return { kind: 'exit' } if (trimmed === '/help') { explainInteractiveCommands(terminal) @@ -492,42 +486,18 @@ async function readUserTurn( } return { kind: 'rename', title, attachments } } - if (trimmed === '/attach' || trimmed.startsWith('/attach ')) { - const rawPaths = trimmed.slice('/attach'.length).trim() - if (!rawPaths) { - terminal.status('Usage: /attach <path> [more paths]') - continue - } - try { - attachments = await addPaths( - attachments, - parseAttachmentPaths(rawPaths), - terminal, - dependencies - ) - } catch (error) { - terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) - } - continue - } - if (trimmed) { - const pastedPaths = await dependencies.pastedAttachmentPaths(input.value) - if (pastedPaths) { - // A dragged path is still just user input. Preload an explicit command - // so the next Enter is the user's confirmation before any bytes are read. - const command = attachmentCommand(pastedPaths) - if (!command) { - terminal.status('The detected path cannot be safely preloaded. Use /attach manually.') - continue - } - if (!terminal.preload(command)) { - terminal.status( - 'File path detected, but newer composer input took priority. Use /attach to add it.' - ) + let prompt = input.value + if (trimmed && !trimmed.startsWith('/')) { + const extracted = await dependencies.extractAttachmentPaths(input.value) + if (extracted) { + try { + attachments = await addPaths(attachments, extracted.paths, terminal, dependencies) + prompt = extracted.text + trimmed = prompt.trim() + } catch (error) { + terminal.status(`Error: ${error instanceof Error ? error.message : String(error)}`) continue } - terminal.status('File path detected. Press Enter to attach it, or edit the command.') - continue } } if (trimmed.startsWith('/')) { @@ -540,13 +510,13 @@ async function readUserTurn( } } if (!trimmed && attachments.length === 0) continue - if (utf8Bytes(input.value) > MAX_CHAT_PROMPT_BYTES) { + if (utf8Bytes(prompt) > MAX_CHAT_PROMPT_BYTES) { terminal.status('Error: Chat input exceeds the 10 MiB limit.') continue } return { kind: 'turn', - prompt: input.value, + prompt, attachments, queued: input.queued === true, ...(input.display === undefined ? {} : { display: input.display }), @@ -577,19 +547,13 @@ async function answerQuestions( return { kind: 'answer', value: answers.join('\n') } } -async function isChatTurnInput( - input: Extract<ChatTerminalInput, { kind: 'line' }>, - dependencies: Pick<ChatDependencies, 'pastedAttachmentPaths'> -): Promise<boolean> { +function isChatTurnInput(input: Extract<ChatTerminalInput, { kind: 'line' }>): boolean { const trimmed = input.value.trim() if (!trimmed) return false - if ( - trimmed.startsWith('/') && - !input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') - ) { - return false - } - return !(await dependencies.pastedAttachmentPaths(input.value)) + return ( + !trimmed.startsWith('/') || + input.contexts?.some((context) => context.kind === 'skill' || context.kind === 'mcp') === true + ) } function logSuggestionLabel( @@ -1192,7 +1156,7 @@ async function runInteractive( } if (input?.kind !== 'line') return submitChecks = submitChecks.then(async () => { - if (!(await isChatTurnInput(input, dependencies))) return + if (!isChatTurnInput(input)) return if (!submitRequested) { submitRequested = true if (sessionReady && !controller.signal.aborted) controller.abort(reason) @@ -1493,8 +1457,8 @@ export function chatCommand(overrides: Partial<ChatDependencies> = {}): Command Boolean(process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY), createTerminal: () => new ReadlineChatTerminal(), loadAttachments: loadChatAttachments, - clipboardImage: readClipboardImage, - pastedAttachmentPaths: existingAttachmentPaths, + clipboardAttachment: readClipboardAttachment, + extractAttachmentPaths, // The fullscreen chat already requires a TTY and uses ANSI throughout. A // propagated TERM=dumb value must not leave model Markdown visible inside // an otherwise fully rendered TUI.